What happened?
When tuist cache && tuist generate is used, Tuist flattens transitive xcframeworks of static intermediates into the top-level consumer’s Link Binary With Libraries build phase. The same static .xcframework ends up referenced from multiple consumers’ link phases — exactly the condition StaticProductsGraphLinter already warns about when it is expressed via a declared graph edge:
"<static product> has been linked from <linkers>, it is a static product so may introduce unwanted side effects."
But when the same condition is produced by the cache-flatten path, the linter is silent. The flatten path bypasses the graph dictionary entirely — it goes through LinkGenerator from linkableDependencies() and writes straight into the pbxproj’s frameworks build phase, while StaticProductsGraphLinter only inspects graphTraverser.dependencies.keys.
Result: identical link phase, identical user-visible risk, but tuist generate reports zero warnings. Whether this also surfaces as a dyld "Class X is implemented in both …" warning at runtime depends on downstream factors (-ObjC, whether symbols are referenced, …) — but the detection gap exists at generation time regardless of any of them, and so does the linter’s existing warning for the equivalent declared-edge case.
This is the detection-side companion to #8504, which tracks the underlying flatten behavior. The two surfaces are independent: even if the flatten itself is intentional or hard to change, having the existing staticSideEffects warning land at generation time would meaningfully de-risk the failure mode. The repro and analysis below are scoped to that detection question only.
Root cause (analysis)
Where the dup gets created (cache-flatten path)
cli/Sources/TuistGenerator/Generator/LinkGenerator.swift:
let linkableDependencies = try graphTraverser
.linkableDependencies(path: path, name: target.name).sorted()
// ↑ this set already contains the transitively-flattened xcframeworks
for dependency in linkableDependencies {
switch dependency {
case let .xcframework(path, _, _, status, condition):
try addBuildFile(path, condition: condition, status: status)
// …
}
}
cli/Sources/TuistCore/Graph/GraphTraverser.swift (linkableDependencies):
let transitiveStaticTargetReferences = transitiveStaticDependencies(from: targetGraphDependency)
let staticDependenciesPrecompiledLibrariesAndFrameworks =
transitiveStaticTargetReferences.flatMap { dependency in
self.graph.dependencies[dependency, default: []]
.lazy
.filter { $0.isPrecompiled && $0.isLinkable }
}
references.formUnion(allDependencies.compactMap {
dependencyReference(to: $0, from: targetGraphDependency)
})
linkableDependencies() walks transitive static targets and pulls up their precompiled deps too. There is no early-out when a dynamic intermediate already absorbs them — every leaf precompiled framework on the static path is added to the consumer’s link line.
Where the linter looks (declared-edge path only)
cli/Sources/TuistGenerator/Linter/StaticProductsGraphLinter.swift:
struct StaticProductsGraphLinter: StaticProductsGraphLinting {
func lint(graphTraverser: GraphTraversing, …) -> [LintingIssue] {
warnings(in: Array(graphTraverser.dependencies.keys), …)
}
Input is graphTraverser.dependencies.keys — the graph’s edge dictionary, not what LinkGenerator ends up writing into the link phase.
The map is built by walking declared edges only (buildStaticProductsMap → DFS, push static products into an unlinked bucket, drain into a linked bucket whenever a canLinkStaticProducts() node is visited). When the cache-flatten path adds, say, Feature.xcframework and DupSeedKit.xcframework to the consumer’s link phase without adding Consumer → Feature to graph.dependencies, the resulting Feature.linkers = [Bridge] (size 1) → staticDependencyWarning returns [] → silent.
Why LinkingStatus cannot work around this
cli/Sources/ProjectDescription/TargetDependency.swift:
public enum LinkingStatus: String, Codable, Hashable, Sendable {
case required
case optional
case none
}
LinkGenerator.swift:
guard status != .none else { return }
GraphTraverser.swift:
fileprivate mutating func formUnionPreferringRequiredStatus(_ other: …) {
for newRef in other {
if let existingRef = first(where: { $0.hasSamePath(as: newRef) }) {
if newRef.linkingStatus == .required, existingRef.linkingStatus == .optional {
remove(existingRef); insert(newRef)
}
} else { insert(newRef) }
}
}
There is no .none-vs-.required arbitration. An explicit .linkingStatus(.none) on the consumer is silently overridden by the cache-flatten reference (which carries the default .required). Empirically verified — see the controlled-variant table below, the .none row produces the same MD5 and the same link phase as baseline.
Also: .external(name:) has no status parameter at all (TargetDependency.swift — case external(name: String, condition: PlatformCondition? = nil)). SPM dependencies cannot opt out even in principle.
Suggested upstream fix candidates (detection only)
Two non-exclusive options, both small and self-contained:
- Have
StaticProductsGraphLinter consume graphTraverser.linkableDependencies(...) output (or an equivalent post-flatten view) instead of (or in addition to) graph.dependencies. The same staticProduct → [linker] map can be computed against the actual link phase the user will get.
- At cache-flatten time in
GraphTraverser.linkableDependencies(), attribute each pulled-up xcframework back to the consumer as a synthetic graph edge (perhaps tagged .cacheFlattened). Existing detection then works unchanged, and other passes (focus mode, schemes) also see reality.
Option 1 is more localized; option 2 makes the rest of the toolchain more honest at the cost of a graph schema change.
Either keeps the underlying flatten behavior in #8504 untouched.
Expected behavior
If the link phase Tuist writes contains the same static .xcframework referenced from two different consumers, StaticProductsGraphLinter should emit its existing staticSideEffects warning — regardless of whether the duplication came from a declared graph edge or from cache-mode transitive flattening.
How do we reproduce it?
Minimal reproduction (4 modules + 1 local SPM, ≈10 files). Chain: App → Bridge(dynamic) → Feature(static) → Proxy(dynamic) → DupSeedKit(static SPM).
The dynamic targets in this repro have OTHER_LDFLAGS = -ObjC set. This is not load-bearing for the detection issue itself — Tuist’s link phase will contain the same duplicated .xcframework regardless. -ObjC is included so reviewers can verify the symptom end-to-end with a single nm against the linked product, without having to reason about which symbols may or may not have been dead-stripped.
dup-repro/
├── Tuist.swift
├── Workspace.swift
├── Tuist/Package.swift
├── LocalSeed/
│ ├── Package.swift
│ └── Sources/DupSeedKit/Seed.swift
├── Modules/
│ ├── Proxy/{Project.swift, Sources/Proxy.swift}
│ ├── Feature/{Project.swift, Sources/Feature.swift}
│ ├── Bridge/{Project.swift, Sources/Bridge.swift}
│ └── App/{Project.swift, Sources/AppDelegate.swift}
Tuist.swift:
import ProjectDescription
let config = Config(
project: .tuist(
compatibleXcodeVersions: .all,
cacheOptions: .options(
profiles: .profiles(
["development": .profile(.allPossible, except: [])],
default: "development"
)
)
)
)
Workspace.swift:
import ProjectDescription
let workspace = Workspace(
name: "DupRepro",
projects: ["Modules/Proxy", "Modules/Feature", "Modules/Bridge", "Modules/App"]
)
Tuist/Package.swift:
// swift-tools-version: 5.9
import PackageDescription
#if TUIST
import ProjectDescription
let packageSettings = PackageSettings()
#endif
let package = Package(
name: "ExternalDeps",
dependencies: [.package(path: "../LocalSeed")]
)
LocalSeed/Package.swift:
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "DupSeedKit",
platforms: [.iOS(.v15)],
products: [.library(name: "DupSeedKit", targets: ["DupSeedKit"])],
targets: [.target(name: "DupSeedKit", path: "Sources/DupSeedKit")]
)
LocalSeed/Sources/DupSeedKit/Seed.swift:
import Foundation
@objc public class DupSeedClass: NSObject {
@objc public func ping() -> String { "pong" }
}
@objc public class DupSeedAnotherClass: NSObject {}
Modules/Proxy/Project.swift — dynamic, declares the SPM, force-loads with -ObjC:
import ProjectDescription
let project = Project(
name: "Proxy",
targets: [
.target(
name: "Proxy",
destinations: [.iPhone],
product: .framework,
bundleId: "com.example.Proxy",
deploymentTargets: .iOS("15.0"),
sources: ["Sources/**"],
dependencies: [.external(name: "DupSeedKit")],
settings: .settings(base: ["OTHER_LDFLAGS": "$(inherited) -ObjC"])
)
]
)
Modules/Feature/Project.swift — static intermediate:
import ProjectDescription
let project = Project(
name: "Feature",
targets: [
.target(
name: "Feature",
destinations: [.iPhone],
product: .staticFramework,
bundleId: "com.example.Feature",
deploymentTargets: .iOS("15.0"),
sources: ["Sources/**"],
dependencies: [.project(target: "Proxy", path: "../Proxy")]
)
]
)
Modules/Bridge/Project.swift — dynamic, force-loads Feature with -ObjC:
import ProjectDescription
let project = Project(
name: "Bridge",
targets: [
.target(
name: "Bridge",
destinations: [.iPhone],
product: .framework,
bundleId: "com.example.Bridge",
deploymentTargets: .iOS("15.0"),
sources: ["Sources/**"],
dependencies: [.project(target: "Feature", path: "../Feature")],
settings: .settings(base: ["OTHER_LDFLAGS": "$(inherited) -ObjC"])
)
]
)
Modules/App/Project.swift — only declares Bridge. Feature and DupSeedKit are not in App’s dependency list:
import ProjectDescription
let project = Project(
name: "App",
targets: [
.target(
name: "App",
destinations: [.iPhone],
product: .app,
bundleId: "com.example.App",
deploymentTargets: .iOS("15.0"),
infoPlist: .extendingDefault(with: ["UILaunchScreen": [:]]),
sources: ["Sources/**"],
dependencies: [.project(target: "Bridge", path: "../Bridge")],
settings: .settings(base: ["OTHER_LDFLAGS": "$(inherited) -ObjC"])
)
]
)
Module sources are trivial (@objc public class Bridge: NSObject { @objc public func feature() -> Feature { Feature() } } etc.) — full repo available as a tarball.
Steps
mise x -- tuist install
mise x -- tuist cache --cache-profile development
mise x -- tuist generate --no-open --cache-profile development
mise x -- xcodebuild -workspace DupRepro.xcworkspace -scheme App \
-destination "generic/platform=iOS Simulator" -configuration Debug build
Then nm the resulting .app:
APP=~/Library/Developer/Xcode/DerivedData/DupRepro-*/Build/Products/Debug-iphonesimulator/App.app
nm -gU "$APP/App.debug.dylib" | grep DupSeed
nm -gU "$APP/Frameworks/Bridge.framework/*" | grep DupSeed
nm -gU "$APP/Frameworks/Proxy.framework/*" | grep DupSeed
Result on Tuist 4.191.3:
App.app/App.debug.dylib _OBJC_CLASS_$__TtC10DupSeedKit12DupSeedClass ✅
App.app/Frameworks/Proxy.framework _OBJC_CLASS_$__TtC10DupSeedKit12DupSeedClass ✅ → dyld dup
App.app/Frameworks/Bridge.framework (none)
tuist generate --no-open output: ✔ Success — Project generated. — no lint warning.
Controlled-variant table (3 cases × 3 builds, byte-deterministic)
To confirm the silence is structural and not order-dependent, I varied only App.Project.swift’s declaration of the (already cache-flattened) transitive dep, regenerating and rebuilding 3 times per variant:
App declares Feature as |
generated pbxproj MD5 (3 runs) |
linter warning |
runtime dup |
not declared (only Bridge) — baseline |
1623dbfa… 1623dbfa… 1623dbfa… |
❌ silent |
✅ reproduced |
.project("Feature", …, status: .required) |
1623dbfa… 1623dbfa… 1623dbfa… |
✅ "… linked from target 'App' and target 'Bridge', it is a static product so may introduce unwanted side effects." |
✅ reproduced |
.project("Feature", …, status: .none) |
1623dbfa… 1623dbfa… 1623dbfa… |
❌ silent |
✅ reproduced |
All 9 generated pbxprojs have the same MD5. The link phase Tuist actually writes is identical across the three variants. The runtime dup is identical. The only thing that changes is whether the linter wakes up — and it only wakes up when the user redundantly re-declares a transitive that was already going to be linked anyway.
The redundant declaration in the .required row is also exactly the situation a real codebase will normally avoid (you wouldn’t re-list a transitive that the graph already provides), so the typical structure ends up at the baseline row — link phase contains the duplicated .xcframework, lint is silent.
Notes
tuist graph reports success; the failure mode is only visible at runtime (nm / dyld) or by inspecting the generated pbxproj’s PBXFrameworksBuildPhase.
-ObjC is included in the repro for end-to-end observability (see “How do we reproduce it?”). It is not required for the detection issue itself — Tuist’s link phase contains the duplicated .xcframework either way, and the existing StaticProductsGraphLinter warning fires for declared edges with or without -ObjC.
- Removing the cache (running plain
tuist generate --no-open with no prior tuist cache) eliminates the flatten and the dup — the link phase contains only Bridge.xcframework for App, as expected. So this is specifically a binary-cache code path issue.
- The
LinkingStatus.none row demonstrates that the existing API surface offers no opt-out: the cache-flatten reference outranks an explicit .none because of the merge logic in formUnionPreferringRequiredStatus.
dup-repro-share.zip
Error log
There is no error log — the symptom is the absence of the warning.
For comparison, when the duplicated linker relationship is forced into the graph (the .required row in the table above), the existing linter produces:
! Warning
The following items may need attention:
▸ Target 'Feature' has been linked from target 'App' and target 'Bridge',
it is a static product so may introduce unwanted side effects.
✔ Success
Project generated.
This is the warning we expect to see in the cache-flatten case as well.
macOS version
26.2
Tuist version
4.191.3
Xcode version
26.2