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

Flights

Investigate, reproduce, or fix this item in an isolated repository. Each Flight preserves its outcome and agent session.

New Flights are paused Configure model inference, GitHub, and a sandbox provider to start another Flight. Existing results remain available below.
No Flights yet

Start a Flight and preserve its objective, outcome, and session here.

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.