Hive
tuist generate emits a false-positive “duplicate xcframework dependency” warning when an xcframework binary target is reached via two or more external SwiftPM products
GitHub issue · Open
Why is this needed?
TargetLinter.lintDuplicateDependency warns about a duplicate .xcframework dependency that project generation itself already de-duplicates, so the warning is a false positive: it flags a graph state that never reaches the emitted project. The linter and the generator disagree.
When it happens. A SwiftPM package (consumed via .external) vends a binary (xcframework) target through more than one product — either by listing the binary target in multiple products’ targets: arrays, or because multiple library targets each declare it in their dependencies:. A Tuist target that links two or more of those products then ends up with the same .xcframework(path:) entry appearing multiple times in its resolved Target.dependencies. lintDuplicateDependency counts identical TargetDependency values and emits one .warning per duplicated entry:
The following items may need attention:
▸ Target 'App' has duplicate xcframework dependency specified: 'MyBinary.xcframework'
Why it is a false positive (the inconsistency):
Project generation collapses these duplicates to a single reference: the emitted .pbxproj contains exactly one PBXFileReference for the xcframework and one entry per build phase (Frameworks / Embed). So the duplicate exists only in the pre-generation dependency list that the linter inspects — not in the generated project, and not at link/runtime. SwiftPM (Xcode-native integration) resolves the identical situation silently with no diagnostic. The warning therefore has no actionable cause for the user: the consuming target’s manifest is correct, the duplication is an artifact of transitive convergence on a shared binary, and the user cannot remove it without dropping a product they legitimately need.
Impact:
- False-positive noise on every
generate/graphrun. It cannot be silenced by configuration (theseverityis hard-coded.warning; there is no per-rule suppression), and it cannot be fixed in the consuming manifest (the duplication originates in the dependency’s manifest, reached via two products). - Erodes signal: persistent false positives train teams to ignore
tuist generatewarnings, which can mask a genuinelintDuplicateDependencyhit (e.g. a dependency truly listed twice in a hand-authored manifest — the case the rule is actually valuable for). - CI friction for setups that treat Tuist warnings as failures.
Reason in one line: the duplicate-dependency linter does not account for the de-duplication tha generation already performs on precompiled (xcframework) dependencies reached through multiple SwiftPM products.
Steps to reproduce (so the fix can be verified)
Minimal self-contained project (no private deps). Any small .xcframework works as MyBinary.xcframework.
repro/
Tuist.swift
Tuist/Package.swift
Project.swift
Sources/App/App.swift
SharedBinaryPkg/Package.swift
SharedBinaryPkg/Sources/ProductA/A.swift
SharedBinaryPkg/Sources/ProductB/B.swift
SharedBinaryPkg/MyBinary.xcframework # any xcframework
Tuist/Package.swift
// swift-tools-version: 5.9
import PackageDescription
#if TUIST
import ProjectDescription
let packageSettings = PackageSettings()
#endif
let package = Package(name: "Deps", dependencies: [.package(path: "../SharedBinaryPkg")])
SharedBinaryPkg/Package.swift — the dependency that vends one binary target through two products:
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "SharedBinaryPkg",
platforms: [.macOS(.v12)],
products: [
.library(name: "ProductA", targets: ["ProductA", "MyBinary"]), // <- binary in product A
.library(name: "ProductB", targets: ["ProductB", "MyBinary"]), // <- same binary in product B
],
targets: [
.target(name: "ProductA", dependencies: ["MyBinary"]),
.target(name: "ProductB", dependencies: ["MyBinary"]),
.binaryTarget(name: "MyBinary", path: "MyBinary.xcframework"),
]
)
Project.swift — one target links both products:
import ProjectDescription
let project = Project(
name: "App",
targets: [
.target(
name: "App", destinations: .macOS, product: .framework,
bundleId: "com.test.App", deploymentTargets: .macOS("12.0"),
sources: ["Sources/App/**"],
dependencies: [.external(name: "ProductA"), .external(name: "ProductB")]
)
]
)
Run:
tuist install
tuist generate --no-open
Actual: a warning — Target 'App' has duplicate xcframework dependency specified: 'MyBinary.xcframework'. Yet App.xcodeproj/project.pbxproj contains a single PBXFileReference for MyBinary.xcframework and a single entry in the Frameworks/Embed phases (generation already de-duplicated it).
Expected: no warning — consistent with what generation emits (one reference) and with SwiftPM’s silent de-duplication of a shared binary target.
Environment
- Tuist
4.202.0-canary.1(the relevant code is long-standing; expected on the 4.20x line generally) - macOS 26.5.1
- Xcode 26.5.0
- SwiftPM integration via
Tuist/Package.swift+.external(name:)
Steps to address the need
Where the warning is produced — cli/Sources/TuistGenerator/Linter/TargetLinter.swift,
lintDuplicateDependency(target:):
private func lintDuplicateDependency(target: Target) -> [LintingIssue] {
typealias Occurrence = Int
var seen: [TargetDependency: Occurrence] = [:]
target.dependencies.forEach { seen[$0, default: 0] += 1 }
let duplicates = seen.enumerated().filter { $0.element.value > 1 }
return duplicates.map {
.init(
reason: "Target '\(target.name)' has duplicate \($0.element.key.typeName) dependency specified: '\($0.element.key.name)'",
severity: .warning
)
}
}
The rule counts every TargetDependency value, including .xcframework(path:expectedSignature:status:condition:)
entries that the graph carries more than once because the same binary target was reached through multiple
SwiftPM products. Generation later collapses them; the linter does not, so they disagree.
Suggested fix — two options (prefer 1):
- De-duplicate identical
.xcframework(precompiled) dependencies during graph assembly, so the graph a target carries matches what generation emits. The duplicates here are byte-identicalTargetDependency.xcframeworkvalues (samepath,status,condition,expectedSignature); collapsing them where the external-SwiftPM dependency closure is built (e.g. when a target’s transitive precompiled dependencies are flattened, aroundPackageInfoMapper/ the graph mapping that resolves.externalproduct closures) removes the inconsistency at the source and the linter then naturally sees one. This is the most principled fix because it makes the graph consistent with generation. - Narrower, linter-only fix: in
lintDuplicateDependency, collapse identical precompiled-binary dependencies (.xcframework, and arguably.framework/.librarythat resolve to the same artifact path) before counting — i.e. only warn about a duplicated.xcframeworkif the duplicate entries are not identical. Keep flagging duplicated authored dependencies (.target/.projectlisted twice in a hand-written manifest), which is the genuine mistake the rule is valuable for. This is a localized change if touching graph assembly is considered too broad.
Acceptance / verification:
- The reproduction above (and a unit test in
TargetLinterTestswith two identical.xcframeworkdependencies on one target) produces nolintDuplicateDependencyissue. - A target with two genuinely-distinct duplicate authored deps (same
.target/.projecttwice) still warns (no regression to the rule’s real purpose). - The generated
.pbxprojis unchanged (it was already single-reference) — only the spurious warning disappears.
Related (asymmetry precedent):
Tuist already filters/handles SwiftPM build-tool plugins inconsistently between generation and dependency-edge resolution (fixed in #11454). This is the same class of generation-vs-graph inconsistency, here between generation’s xcframework de-duplication and the linter.
No GitHub comments yet.