Hive Hive
Sign in

fix(cli): anchor SwiftPM module-map flags on absolute derived-dir to survive symlinked .build/checkouts

GitHub issue · Closed

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

Summary

  • Xcode 26.5’s explicit-modules dep scanner canonicalises -fmodule-map-file= paths before opening them. Tuist emits these paths as \$(SRCROOT)/../../tuist-derived/... for external SwiftPM consumers, which physically traverses .build/checkouts/. CI caches that replace .build/checkouts/ with a symlink to a separate volume (notably Namespace’s nscloud-cache-action, but also any tool that backs .build/ with a content-addressed mount) cause the .. segments to resolve into the cache volume’s .build/, where tuist-derived/ doesn’t exist. The build fails with module map file ... not found. The same workflow on Xcode 26.4.1 worked because that toolchain’s dep scanner normalised the path lexically before opening it.
  • Switch the anchor on those flags from \$(SRCROOT) to \$(PROJECT_DIR). For external SwiftPM projects Tuist’s ExternalDependencyPathWorkspaceMapper overrides SRCROOT to point back into the SwiftPM checkouts/ directory, but \$(PROJECT_DIR) is not overridden — it stays at the generated .xcodeproj’s parent, which lives under <scratch>/tuist-derived/Projects/<Pkg>/. The substituted flag value (\$(PROJECT_DIR)/../../ModuleMaps/<X>/<X>.modulemap) therefore stays entirely inside tuist-derived/ and never traverses the symlinked checkouts/ subtree, even after Xcode 26.5 canonicalises symlinks.
  • Bump the generated_app_with_realm fixture from realm-swift 10.46 to 20.0.4 so it compiles on Xcode 26.5 (the older pin bundled an s2geometry copy that specialises std::is_pod, removed from the iOS 26.5 SDK’s libc++).

Root cause

ModuleMapMapper.swift computed flag values via moduleMap.relative(to: targetID.projectPath) and prefixed them with \$(SRCROOT). For external SwiftPM projects, \$(SRCROOT) is the SwiftPM checkout dir (.build/checkouts/<Pkg>/), and the modulemap lives in .build/tuist-derived/ModuleMaps/. The lexical relative path is ../../tuist-derived/.... Up through Xcode 26.4.1 clang opened those paths via standard POSIX resolution, which the customer’s CI exercised happily with .build/checkouts/ symlinked. Xcode 26.5’s tightened explicit-modules dep scanner resolves the symlink first, then traverses .. from the symlink target — landing in the cache volume’s .build/ where tuist-derived/ does not exist. The kernel returns ENOENT and the dep scanner reports module map file ... not found.

Why \$(PROJECT_DIR), not bare absolute paths or a custom variable

Several shapes fix the symlink trap. The constraints they have to satisfy:

  1. Substituted path must contain no .. segments through .build/checkouts/. Anything that traverses the symlinked subtree gets canonicalised wrong on Xcode 26.5+.
  2. Hashed flag string must be machine-portable. SettingsContentHasher hashes setting values verbatim, so any absolute path that varies by \$HOME / runner work-dir / CI identity will invalidate binary-cache and selective-testing hashes across machines.
Approach Satisfies (1) Satisfies (2) Notes
Bare absolute path (/Users/.../.build/tuist-derived/...) Cache hashes diverge across machines
New user-defined setting (\$(TUIST_SPM_DERIVED_DIR)) ✓ if hasher filters the key Works but adds a new build setting and a hasher exception
\$(PROJECT_DIR) (this PR) Existing Xcode-native variable; resolves to .xcodeproj parent (inside tuist-derived/); not overridden by ExternalDependencyPathWorkspaceMapper’s SRCROOT override
Move tuist-derived/ inside each checkout But SwiftPM owns .build/checkouts/<Pkg>/ and would wipe it on resolve/update. Rejected.
Place generated .xcodeproj directly at <scratch>/tuist-derived/<Pkg>.xcodeproj Removes .. entirely but is a wider restructure of ExternalDependencyPathWorkspaceMapper. Out of scope.

\$(PROJECT_DIR) is the smallest viable change: zero new build settings, zero hasher changes, no fixture data poisoning, and the literal \$(PROJECT_DIR) token in flag strings hashes identically on every machine.

Scope

  • Only paths inside tuist-derived/ change emission shape. Paths that legitimately live in sibling SwiftPM checkouts (e.g. \$(SRCROOT)/../realm-core/src/module.modulemap) or inside the consumer’s own \$(SRCROOT) keep the existing form — they stay within the symlinked subtree and resolve correctly under canonicalisation.
  • Only external SwiftPM projects (Project.type == .external, with swiftPackageManagerScratchDirectory set) opt into the \$(PROJECT_DIR) anchor. Local projects are unchanged because for local projects \$(PROJECT_DIR) and \$(SRCROOT) resolve to the same directory by default.

Validation

Reproduced and verified A/B against two fixtures. generated_app_with_realm (bumped to realm-swift 20.0.4 in this PR) is a literal one-to-one match for the customer’s failing log — same package, same Bid.modulemap / s2geometry.modulemap paths, same realm-swift/../.. and realm-core/../.. traversal patterns. generated_ios_app_with_spm_dependencies (KSCrash) gives a second, smaller-graph repro using the same structural pattern.

Fixture Xcode 26.5, no symlink Xcode 26.5, .build/checkouts symlinked — before fix Xcode 26.5, .build/checkouts symlinked — with fix
generated_app_with_realm (Realm 20.0.4) BUILD SUCCEEDED [115s] module map file '.../checkouts/realm-swift/../../tuist-derived/ModuleMaps/Bid/Bid.modulemap' not found (and .../s2geometry/s2geometry.modulemap) → BUILD FAILED BUILD SUCCEEDED [114s]
generated_ios_app_with_spm_dependencies BUILD SUCCEEDED module map file '.../checkouts/KSCrash/../../tuist-derived/ModuleMaps/KSCrashCore/KSCrashCore.modulemap' not foundBUILD FAILED BUILD SUCCEEDED [49.6s]

All four pre-fix-failure cells were exercised by regenerating each fixture with the released Tuist binary (which still emits the old \$(SRCROOT)/../../tuist-derived/... shape) and keeping the symlink layout from Namespace’s cache action. The post-fix-success cells were exercised by regenerating with a tuist binary built from this branch (which emits \$(PROJECT_DIR)/../../ModuleMaps/...).

Confirmed in the post-fix pbxproj that the flag emission is -fmodule-map-file=\$(PROJECT_DIR)/../../ModuleMaps/<Module>/<Module>.modulemap for paths under tuist-derived/, and that no new user-defined build settings are injected.

ModuleMapMapperTests and SettingsContentHasherTests both pass against this branch.

Test plan

  • xcsiftbuild test -workspace Tuist.xcworkspace -scheme Tuist-Workspace -only-testing TuistGeneratorTests/ModuleMapMapperTests
  • xcsiftbuild test -workspace Tuist.xcworkspace -scheme Tuist-Workspace -only-testing TuistHasherTests/SettingsContentHasherTests
  • Manual: regenerate generated_app_with_realm or generated_ios_app_with_spm_dependencies, confirm the pbxproj emits \$(PROJECT_DIR)/../../<rel> shapes for tuist-derived/-resident paths and that no \$(SRCROOT)/../../tuist-derived/... shapes remain

🤖 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