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