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

Comments