Hive Hive
Sign in

StaticProductsGraphLinter is silent when binary cache flattens transitive xcframeworks into the consumer’s link phase

GitHub issue · Open

Metadata
Source
tuist/tuist #10753
Updated
Jun 11, 2026
Domains
Cache Generated projects
Details

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.swiftcase 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

Comments
D
dosubot[bot] May 12, 2026

Thanks for the thorough analysis, @mqzkim. This is a well-documented detection gap.

Your findings align with what’s been reported in issue #9594 [1], where binary cache usage during tuist test similarly caused duplicate symbols of statically linked dependencies — and the linter only fired when dependencies were made explicitly redundant in the manifest.

The root cause is as you’ve identified: StaticProductsGraphLinter operates on the declared graph edges (graphTraverser.dependencies.keys) [2], while linkableDependencies() in GraphTraverser performs transitive flattening of static dependencies and their precompiled xcframeworks at generation time [3]. These are two separate views of “what gets linked,” and the linter only sees the first.

Interestingly, there is logic in linkableDependencies() that tries to exclude static xcframeworks already absorbed by dynamic xcframeworks [4], but this appears to not cover all scenarios — particularly the chain you’ve reproduced (App → Bridge(dynamic) → Feature(static) → Proxy(dynamic) → DupSeedKit(static SPM)).

Of your two suggested fixes, option 1 (having the linter consume the post-flatten linkableDependencies() output) seems like the most localized and least risky change. It would close the detection gap without requiring a graph schema change.

For deeper discussion or to coordinate on a fix, I’d suggest posting this on the community forum under the “Tuist” category — the maintainers tend to engage more on design-level proposals there. You could link back to this issue and focus the discussion on whether option 1 or option 2 is preferred upstream.

To reply, just mention @dosu.


Share context across your team and agents. Try Dosu.

Leave Feedback Learn about tuist with Dosu Add Dosu to your team

M
mqzkim May 13, 2026

Thanks for the thorough analysis, @mqzkim. This is a well-documented detection gap.

Your findings align with what’s been reported in issue #9594 [1], where binary cache usage during tuist test similarly caused duplicate symbols of statically linked dependencies — and the linter only fired when dependencies were made explicitly redundant in the manifest.

The root cause is as you’ve identified: StaticProductsGraphLinter operates on the declared graph edges (graphTraverser.dependencies.keys) [2], while linkableDependencies() in GraphTraverser performs transitive flattening of static dependencies and their precompiled xcframeworks at generation time [3]. These are two separate views of “what gets linked,” and the linter only sees the first.

Interestingly, there is logic in linkableDependencies() that tries to exclude static xcframeworks already absorbed by dynamic xcframeworks [4], but this appears to not cover all scenarios — particularly the chain you’ve reproduced (App → Bridge(dynamic) → Feature(static) → Proxy(dynamic) → DupSeedKit(static SPM)).

Of your two suggested fixes, option 1 (having the linter consume the post-flatten linkableDependencies() output) seems like the most localized and least risky change. It would close the detection gap without requiring a graph schema change.

For deeper discussion or to coordinate on a fix, I’d suggest posting this on the community forum under the “Tuist” category — the maintainers tend to engage more on design-level proposals there. You could link back to this issue and focus the discussion on whether option 1 or option 2 is preferred upstream.

To reply, just mention @dosu.

Share context across your team and agents. Try Dosu.

Leave Feedback Learn about tuist with Dosu Add Dosu to your team

@dosubot Quick clarification on the linkableDependencies() “absorption” mechanism, since it’s not quite what it looks like — and the actual shape strengthens the case for option 1.

The block at GraphTraverser.swift:583-591 is not absorption — its name is the opposite of what it does:

let staticXCFrameworksLinkedByDynamicXCFrameworkDependencies = filterDependencies(
from: Set(precompiledDynamicLibrariesAndFrameworks).filter { $0.xcframeworkDependency != nil },
test: {
$0.xcframeworkDependency?.linking == .static &&
$0.xcframeworkDependency?.swiftModules.isEmpty == false
},
skip: { $0.xcframeworkDependency == nil }
)

It walks dynamic xcframeworks and pulls static xcframeworks reachable through them into the consumer’s link line — explicitly when those statics carry Swift modules. The intent is “the consumer needs the static’s Swift module at compile time even if the dynamic intermediate already absorbed the binary symbols.” The side effect is that the same .xcframework ends up referenced from every consumer that transitively reaches it through a dynamic xcframework, so its binary symbols get re-linked too → dup.

You can see the asymmetry codified in two existing tests in GraphTraverserTests.swift:

tuist cache produces every cacheable target as an .xcframework, so the second path is always taken; the absorption-assumed first path is never hit when cache is in use. Combined with multiple dynamic xcframeworks transitively reaching the same static SPM, this is exactly the chain produced in the repro for this issue and in #9594.

This doesn’t change the ask in this issue — option 1 (have StaticProductsGraphLinter consume linkableDependencies() output, or an equivalent post-flatten view) is still the right detection-side fix regardless of why the static .xcframework ended up in two consumers’ link phases. It would warn on exactly the second test’s expected output the moment it appears in two consumers, which matches the existing declared-edge warning’s intent.

The behavior question (should the second test’s expected output change? should xcframeworks built by cache carry an “absorbed” marker that disables the over-inclusion?) is the real subject of #8504 and involves a tradeoff with Swift module accessibility — separate concern, deliberately out of scope here.

Happy to move the design discussion to the community forum if that’s preferred — wanted to leave the analysis on the issue first so the trail is co-located with the repro.

D
dosubot[bot] May 13, 2026

Thanks for the correction on the staticXCFrameworksLinkedByDynamicXCFrameworkDependencies block — you’re right that the naming is confusing and worth clarifying. However, looking at the code more carefully, that block actually excludes those static xcframeworks from linking rather than adding them [1]. The comment states: “Static xcframeworks reached through a dynamic xcframework are intentionally NOT linked at the consumer level. The dynamic xcframework already absorbed their symbols during its build.”

That said, the broader point you’re making still holds — the duplication is happening somewhere in the cache-flatten path, and it’s demonstrably producing the duplicated link phase entries you’ve shown in your repro. The two test cases you cited do show different expected outputs for .framework vs .xcframework intermediates, and when tuist cache converts everything to .xcframework, the traversal paths shift.

What’s particularly interesting is that the codebase itself acknowledges this exact problem in Generator.swift [2]:

// When mutating the graph to use cache, we currently end up double linking some frameworks.
// To workaround those false positive warnings, we lint the graph before we replace source modules with xcframeworks
// And assume the changes in the mapper are correct.
try await lint(graphTraverser: GraphTraverser(graph: environment.initialGraphWithSources ?? graph))

So the linter is deliberately run against the pre-cache graph (initialGraphWithSources) specifically to avoid “false positive” warnings from the cache mutation. This is the structural reason for the silence you’re observing — it’s not an oversight but an intentional workaround. The assumption is that the cache mapper’s changes are “correct,” but as your repro demonstrates, that assumption doesn’t always hold.

This confirms your option 1 is the right direction, but the fix needs to be more nuanced than just switching the linter to run post-cache — since the current code explicitly avoids that to suppress noise. The real fix likely requires either:

  • Making the cache mapper produce a correct (non-duplicating) graph, so linting post-cache doesn’t produce false positives
  • Or having the linter distinguish between “true” duplicates (your case) and expected cache-introduced references

Given this is a design-level question about the cache mapper’s contract, I’d recommend posting on the community forum under “Tuist” — the analysis here is excellent and self-contained, and the forum would be a good place to get maintainer input on which approach to pursue. The key framing would be: the initialGraphWithSources workaround masks real duplicates alongside false positives.

To reply, just mention @dosu.


Docs are dead. Just use Dosu.

Leave Feedback Ask Dosu about tuist Share Dosu with your team

M
mqzkim May 13, 2026

Thanks for the correction on the staticXCFrameworksLinkedByDynamicXCFrameworkDependencies block — you’re right that the naming is confusing and worth clarifying. However, looking at the code more carefully, that block actually excludes those static xcframeworks from linking rather than adding them [1]. The comment states: “Static xcframeworks reached through a dynamic xcframework are intentionally NOT linked at the consumer level. The dynamic xcframework already absorbed their symbols during its build.”

That said, the broader point you’re making still holds — the duplication is happening somewhere in the cache-flatten path, and it’s demonstrably producing the duplicated link phase entries you’ve shown in your repro. The two test cases you cited do show different expected outputs for .framework vs .xcframework intermediates, and when tuist cache converts everything to .xcframework, the traversal paths shift.

What’s particularly interesting is that the codebase itself acknowledges this exact problem in Generator.swift [2]:

// When mutating the graph to use cache, we currently end up double linking some frameworks. // To workaround those false positive warnings, we lint the graph before we replace source modules with xcframeworks // And assume the changes in the mapper are correct. try await lint(graphTraverser: GraphTraverser(graph: environment.initialGraphWithSources ?? graph)) So the linter is deliberately run against the pre-cache graph (initialGraphWithSources) specifically to avoid “false positive” warnings from the cache mutation. This is the structural reason for the silence you’re observing — it’s not an oversight but an intentional workaround. The assumption is that the cache mapper’s changes are “correct,” but as your repro demonstrates, that assumption doesn’t always hold.

This confirms your option 1 is the right direction, but the fix needs to be more nuanced than just switching the linter to run post-cache — since the current code explicitly avoids that to suppress noise. The real fix likely requires either:

  • Making the cache mapper produce a correct (non-duplicating) graph, so linting post-cache doesn’t produce false positives
  • Or having the linter distinguish between “true” duplicates (your case) and expected cache-introduced references

Given this is a design-level question about the cache mapper’s contract, I’d recommend posting on the community forum under “Tuist” — the analysis here is excellent and self-contained, and the forum would be a good place to get maintainer input on which approach to pursue. The key framing would be: the initialGraphWithSources workaround masks real duplicates alongside false positives.

To reply, just mention @dosu.

Docs are dead. Just use Dosu.

Leave Feedback Ask Dosu about tuist Share Dosu with your team

@dosubot The original framing in this issue still holds: the silence comes from StaticProductsGraphLinter walking graph.dependencies.keys and only counting .target deps as linkers, while the duplication that produces the dup happens at the link-line level (linkableDependencies() output) where the intermediates are xcframeworks. Lining the linker map up with linkableDependencies() output across all targets is the smallest fix that closes this.

D
dosubot[bot] May 13, 2026

Agreed — the Generator.swift workaround I cited (linting against initialGraphWithSources) is an additional layer that masks the problem, but the fundamental gap is exactly as you originally stated: the linter builds its staticProduct → [linkers] map from declared graph edges, while the actual link phase is determined by linkableDependencies() output. Making the linter consume the latter directly is the correct and minimal fix regardless of whether the pre-cache linting workaround stays or goes.

The issue and analysis are clear as-is. I’d encourage posting on the community forum under the “Tuist” category to get maintainer eyes on this — the fix is well-scoped enough that it could likely move forward as a PR with the repro you’ve already provided. The forum post could focus on: “should StaticProductsGraphLinter build its linker map from linkableDependencies() output instead of graph.dependencies.keys?” with a link back here for the full analysis.

To reply, just mention @dosu.


Docs are dead. Just use Dosu.

Leave Feedback Ask Dosu about tuist Share Dosu with your team