Hive Hive
Sign in

fix(cli): de-duplicate buildable-folder references and add cross-target membership

GitHub issue · Closed

Metadata
Source
tuist/tuist #11202
Updated
Jun 24, 2026
Domains
Generated projects
Details

What

Fixes the two related buildableFolders: gaps reported for the iAuditor migration, both of which materialise stray, duplicated flat PBXFileReference entries at the generated project root.

Issue A — xcconfig / Info.plist inside a buildable folder get flat root-level references. When a target’s xcconfigs or infoPlist: .file(...) live inside a buildableFolders: folder, the generated project added a flat root-level PBXFileReference for each, duplicating files already shown nested inside the synchronized folder. With xcconfig-per-target-per-configuration setups this produced dozens of stray entries.

  • xcconfigs inside a buildable folder now use Xcode 16’s anchored reference (baseConfigurationReferenceAnchor + baseConfigurationReferenceRelativePath) — exactly what Xcode writes for the same layout — instead of a plain baseConfigurationReference plus a flat file reference.
  • Info.plist and entitlements inside a buildable folder no longer get a flat reference; they are surfaced through the INFOPLIST_FILE / CODE_SIGN_ENTITLEMENTS build settings (unchanged).

Issue B — no API for additive cross-target buildable-folder membership. A file inside target A’s folder that must also compile into target B previously required an explicit sources:/resources: glob on B, which materialises another flat root-level reference. Adds an additive, target-addressed exception form:

buildableFolders: [
.folder("App", exceptions: [
.exception(excluded: ["Supporting/App-Info.plist"]), // existing, subtractive (owning target)
.exception(target: "AppTests", included: ["SharedStub.swift"]), // new, additive (foreign target)
]),
]

This generates a PBXFileSystemSynchronizedBuildFileExceptionSet whose target is the foreign target, so the file is compiled into it without any standalone PBXFileReference/PBXBuildFile — the navigator shows it once, inside the folder.

How it maps to the pbxproj. A buildable folder (PBXFileSystemSynchronizedRootGroup) holds one PBXFileSystemSynchronizedBuildFileExceptionSet per target. The set’s membershipExceptions is a list of folder-relative paths whose meaning is relative to the set’s target: for the folder’s owning target the paths are exclusions, for any other target they are inclusions. So excluded: and included: both serialize to the same membershipExceptions field — only the set’s target (owner vs foreign) distinguishes a removal from an addition, which is why the API needs two inputs. The included file gets no standalone PBXFileReference/PBXBuildFile, and it stays a member of its owning target unless also excluded.

App /* PBXFileSystemSynchronizedRootGroup, owned by target App */
├─ exception set { target = App, membershipExceptions = ("Supporting/App-Info.plist") } // excluded from App
└─ exception set { target = AppTests, membershipExceptions = ("SharedStub.swift") } // included into AppTests

Per-file compilerFlags / platformFilters on the exception map to additionalCompilerFlagsByRelativePath / platformFiltersByRelativePath on the same set.

Why / root cause

A single code path in ProjectFileElements.generate(fileElement:) creates a flat PBXFileReference for any explicitly-referenced file that lives inside a synchronized root group, and ConfigGenerator points baseConfigurationReference at that flat reference. That one path produced all three symptoms (stray xcconfig refs, stray Info.plist refs, and stray cross-target source refs). Xcode 16 instead uses anchored base-configuration references and foreign-target exception sets, which XcodeProj already round-trips.

Approach notes

  • xcconfig: suppressed flat-reference collection for xcconfigs inside a buildable folder; ConfigGenerator looks up the enclosing synchronized root group and sets the anchor + relative path (deepest enclosing folder wins for nested folders).
  • Info.plist / entitlements: suppressed flat-reference collection only; build settings already carry the path.
  • Cross-target: the existing sources: glob behaviour is intentionally left intact (backward compatible); the new .exception(target:included:) is the clean opt-in. Foreign-target exception sets are created in a post-pass after every PBXTarget exists.
  • Cache hashing: TargetContentHasher folds files added to a target from sibling targets’ folders (identity, content, compiler flags, platform filters) into that target’s content hash, so adding/removing a cross-target inclusion or changing an included file invalidates the consuming target’s cache. Targets without foreign inclusions hash unchanged.
  • Required the anchored-reference API, so XcodeProj is bumped 9.9.0 → 9.13.0 (#1037 / #1016; additive only, no breaking changes).

Validation

Reproduced both issues e2e with tuist 4.198.1 against the minimal repro (gutiago/tuist-buildable-folder-bundle-module-bug@repro/buildable-folder-issues): the root Project group held the App/AppTests synchronized folders plus four stray flat refs (App-Debug.xcconfig, App-Info.plist, App-Release.xcconfig, SharedStub.swift), baseConfigurationReference pointed at the flat xcconfigs, and the only exception set targeted the owning App target.

Verified the fix e2e by building this branch (swift build --product tuist, compiles cleanly against XcodeProj 9.13.0) and regenerating an equivalent fixture that uses the new API:

Before (4.198.1) After (this branch)
Root Project group App, AppTests, App-Debug.xcconfig, App-Info.plist, App-Release.xcconfig, SharedStub.swift Derived, App, AppTests — zero stray refs
xcconfig baseConfigurationReference → flat ref baseConfigurationReferenceAnchor = /* App */ + relative path, no flat ref
Info.plist flat ref + INFOPLIST_FILE INFOPLIST_FILE string only, no flat ref
SharedStub.swift flat PBXFileReference + PBXBuildFile exception set target = /* AppTests */, membershipExceptions = (SharedStub.swift), no flat ref

The owning Info.plist exclusion still targets App; the new additive set targets AppTests.

Unit tests added at every layer (TDD): ProjectFileElementsTests (no flat ref for Info.plist/xcconfig inside a folder), ConfigGeneratorTests (anchored base-config + no flat ref), TargetGeneratorTests (foreign-target exception set), BuildableFolderException+ManifestMapperTests (new manifest form maps target + globbed included). swiftformat --lint clean; swiftlint adds no new violations in production code.

Integration + acceptance tests (e2e):

  • ProjectDescriptorGeneratorTests runs the full project generation and asserts, in one pass, the anchored config reference, the foreign-target exception set, and the absence of flat xcconfig/Info.plist/source references — guarding the orchestrator wiring (that generateProject invokes the foreign-exception pass), which the per-layer unit tests don’t exercise.
  • GenerateAcceptanceTests + a new generated_app_with_buildable_folder_membership fixture exercise the real public manifest API (.exception(target:included:), xcconfig/Info.plist inside the folder) through tuist generate, asserting the same pbxproj outcomes. Generate-only, so it stays in the fast acceptance lane. The fixture’s generated output was verified against a locally built tuist.

Note

The unit tests live in TuistGeneratorTests/TuistLoaderTests, which are defined only in the Tuist project (not Package.swift), so they are executed by CI rather than locally. The fix itself was verified locally end-to-end (built tuist, regenerated the fixture, inspected the .pbxproj — table above).

Package.resolved was bumped surgically for the XcodeProj pin. A full tuist install re-resolve currently conflicts on an unrelated package (apple.swift-protobuf, reproducible on main), so the local build used swift build --replace-scm-with-registry --force-resolved-versions to honour the committed lockfile. Worth regenerating Package.resolved via tuist install in CI so originHash is recomputed.

Closes the two reports: “Generated project adds flat root-level file references for xcconfigs living inside buildable folders” and “No API to compile a file from another target’s buildable folder (additive exception sets)”.

🤖 Generated with Claude Code

Comments
T
tuist[bot] Jun 10, 2026

🛠️ Tuist Run Report 🛠️

Previews 📦
App Commit Open on device
Tuist b92d8c9cc
Tests 🧪
Scheme Status Cache hit rate Tests Skipped Ran Commit
TuistAcceptanceTests 0 % 0 0 0 b92d8c9cc
TuistApp 58 % 28 0 28 b92d8c9cc
TuistUnitTests 78 % 5329 1 5328 b92d8c9cc
Flaky Tests ⚠️
  • TuistUnitTests: 3 flaky tests (View all)
Test case Module Suite
parseTestStatuses_returnsPassingModuleNames() TuistXCResultServiceTests XCResultServiceTests
parseTestStatuses_returnsCorrectStatuses() TuistXCResultServiceTests XCResultServiceTests
parseTestStatuses_extractsModuleAndSuiteNames() TuistXCResultServiceTests XCResultServiceTests
Builds 🔨
Scheme Status Duration Commit
TuistApp 3m 9s b92d8c9cc
TuistUnitTests 3m 19s b92d8c9cc
Bundles 🧰
Bundle Commit Install size Download size
Tuist b92d8c9cc 20.3 MBΔ -14.2 KB (-0.07%) 15.1 MBΔ +6.8 KB (+0.05%)
TA
tuist-atlas[bot] Jun 12, 2026

This fix is now available in version 4.199.0. Update to this version for de-duplicated buildable-folder references and cross-target membership support.