Examples: add Tuist multi-target integration example#281
Conversation
Ports ExampleMultiProjectIntegration to a Tuist-generated project. The source code (types, mocks, previews) is identical; the structural change is that Subproject and the host app are compiled as separate modules and cross-module dependencies flow through a `.safedi` module-info artifact instead of `additionalDirectoriesToInclude`. This exercises a SafeDITool codepath that the SPM build-tool plugin does not use today: `generate --module-info-output` on the leaf module and `--dependent-module-info-file-path` on the consumer. Structure: - Project.swift declares two targets with `TargetScript.pre` phases that invoke Scripts/generate-safedi.sh. - Subproject's phase writes $(BUILT_PRODUCTS_DIR)/SafeDI/Subproject.safedi. - Host's phase consumes that artifact and writes the dependency tree and mock Swift files into ExampleTuistIntegration/Generated/. Stub files are committed so Tuist's source glob resolves them at project-gen time; the script overwrites them on every build. - Tuist/Package.swift declares SafeDI as a local path dependency; the SafeDIGenerator SPM plugin is not used. - README walks through tuist install / tuist generate setup. - .swiftformat excludes the Generated/ directory so the tool's output isn't fought by the linter. - CI gains a `spm-tuist-integration` job that installs Tuist via Homebrew, pre-builds SafeDITool, runs `tuist install` + `tuist generate --no-open`, and then `xcodebuild`s the generated workspace. https://claude.ai/code/session_01NjQgRqZ62a3GW44zuo9oaZ
Iterated against local Tuist 4.183.0 + Xcode 26.4:
- Type correctly as FileListGlob/Path in Project.swift — Tuist's script-
phase input/output paths are strongly typed and String literals don't
propagate through non-literal array construction.
- Bypass Tuist/Package.swift. Tuist's SPM integration couldn't resolve
SafeDI's trait-gated internal targets (SafeDICore/SafeDIMacros/
SafeDITool/SafeDIToolBinary); depending on SafeDI through Xcode's
native SPM client via Project.packages works around that.
- Bump deployment target to macOS 14.0 to match the SwiftUI APIs used in
LoggedInView / NameEntryView (same target the existing multi-project
example uses).
- Switch the script to the two-step scan + generate flow so the manifest
and parse-cache files land in $DERIVED_FILE_DIR instead of the
committed Generated/ directory. This also fixes a path-identity issue
between inline-scan and re-parse that surfaced as a `noRootFound`
manifest error when CSV paths were absolute.
- Exclude Derived/ from swiftformat (Tuist writes there at generate
time and the generated bundle-support file trips --linebreakAtEndOfFile).
- Drop the `Tuist Install` CI step (no more Tuist/Package.swift to
resolve) and update the README to match.
Verified locally:
▸ tuist generate succeeds (produces .xcworkspace)
▸ xcodebuild build succeeds
▸ Subproject.safedi lands in $(BUILT_PRODUCTS_DIR)/SafeDI/
▸ NotesApp+SafeDI.swift + 3 mock files generate into
ExampleTuistIntegration/Generated/
▸ swiftformat --lint is clean
Homebrew removed the `tuist` formula (`brew install --formula tuist` fails with "No available formula with the name \"tuist\""). The official installer (`curl -Ls https://tuist.dev/install.sh | bash`) routes through mise and places the shim under ~/.local/share/mise/shims.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #281 +/- ##
==========================================
Coverage 100.00% 100.00%
==========================================
Files 41 41
Lines 6940 7043 +103
==========================================
+ Hits 6940 7043 +103 🚀 New features to boost your workflow:
|
|
@codex please review this PR. It adds a Tuist-based port of the existing multi-project example where the subproject emits a |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 91acdc8b0e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| # Homebrew | ||
| brew install --formula tuist | ||
|
|
||
| # Curl installer | ||
| curl -Ls https://tuist.dev/install.sh | bash |
There was a problem hiding this comment.
Remove nonfunctional Tuist install commands from README
The setup section still tells users to run brew install --formula tuist and curl -Ls https://tuist.dev/install.sh | bash, but this PR’s own CI workflow documents that Tuist no longer ships a Homebrew formula and that the install.sh URL returns 404. On a fresh machine, following these documented paths will fail before tuist generate, so the example’s onboarding instructions are currently broken unless users already know to use mise instead.
Useful? React with 👍 / 👎.
…README Codex review: README told users to `brew install --formula tuist` and `curl -Ls https://tuist.dev/install.sh | bash`, both of which the CI iteration on this PR already demonstrated don't work. Replace with the mise-based path that CI actually uses.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6aec6b9ec2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| mise install tuist@latest | ||
| mise use -g tuist@latest |
There was a problem hiding this comment.
Pin Tuist install command to tested version
The README says this setup is "pinned" and "reproducible" with CI, but these commands install tuist@latest, which can silently move to an untested major/minor release and break tuist generate for users while CI remains on 4.183.0. Please pin the same concrete version here that the workflow uses so local setup and CI stay aligned.
Useful? React with 👍 / 👎.
| equivalent of the first-time setup steps above: it installs Tuist via | ||
| Homebrew, builds `SafeDITool`, runs `tuist generate`, then builds the |
There was a problem hiding this comment.
Update CI docs to match the mise installer
This section says the CI job installs Tuist via Homebrew, but the workflow actually installs via mise. Because Tuist no longer ships a Homebrew formula (as noted earlier in this same README), this mismatch sends readers toward a non-working path and makes troubleshooting harder.
Useful? React with 👍 / 👎.
…ption Codex second pass: - README's Installing Tuist section said "pinned" but called `mise install tuist@latest`, which can silently drift off CI. Pin to the same 4.183.0 the workflow installs, with a note pointing at the single source of truth in ci.yml. - README's CI section said the job installed Tuist via Homebrew — now matches reality (mise, pinned).
|
Codex Review: Didn't find any major issues. Swish! ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Address user feedback that the earlier version hardcoded too much and
so wasn't really usable as a template:
- Source lists are glob-driven. Project.swift uses `FileListGlob`
patterns Tuist expands at generate time; the shell script uses
`find`. Neither has per-file entries anymore. Adding/removing a
`.swift` source is a `tuist generate` away — no manifest edit.
- Generated output list isn't hardcoded either. The script declares a
single timestamp marker as its build-phase output for dep-analysis
purposes; the generated `.swift` files are picked up by Tuist's
source glob. New `@Instantiable(isRoot:)` / `@Instantiable(generateMock: true)`
declarations produce new files without manifest edits.
- SafeDITool comes from the published artifact bundle. Instead of
building from source, a tiny Tuist/Package.swift declares a binary
target pointing at the 2.0.0-beta-5 .artifactbundle.zip. `tuist
install` drives SPM's `.binaryTarget(url:checksum:)` mechanism and
unpacks to a stable location the build-phase script reads from:
Tuist/.build/artifacts/tuist/SafeDITool/...
- Project.packages switches from local path (`../..`) to the
published SafeDI SPM release, so a copy-paster doesn't need to
vendor or relocate SafeDI.
- README is rewritten to explain the copy-paste edit list: target
names / bundle IDs / module dirs in Project.swift, case labels in
the script, and the single version pin shared between Project.swift
and Tuist/Package.swift.
- CI reinstates `tuist install` (so SPM downloads the binary) and
drops the local SafeDITool compile step.
Verified locally:
▸ rm -rf Tuist/.build ExampleTuistIntegration.xcworkspace ...
▸ tuist install → downloads artifact bundle
▸ tuist generate → produces workspace with glob-expanded sources
▸ xcodebuild build → both targets pass, host Generated/ files
populated, Subproject.safedi at $(BUILT_PRODUCTS_DIR)/SafeDI/
▸ swiftformat --lint clean
User called out the two-source-of-truth version pins and the "trait- gated internal targets" rationale I'd used to justify them. The rationale was wrong — my earlier attempt to consume SafeDI through `.external(name: "SafeDI")` failed because I also had `PackageSettings(productTypes: [...])` overrides in place that forced Tuist into a stricter map-every-target mode. Without those overrides, Tuist resolves SafeDI fine against the default `prebuilt` trait. And because the default trait chain pulls in SafeDI's `SafeDIToolBinary` for the SafeDIGenerator plugin, `tuist install` already downloads the published `SafeDITool.artifactbundle.zip` as a side effect — there's no need for a separate binary-target declaration in Tuist/Package.swift. Consolidated to a single `.package(url:from:)` in Tuist/Package.swift: - Project.swift: drop `Project.packages`; targets use `.external(name: "SafeDI")`. - Scripts/generate-safedi.sh: read SafeDITool from `Tuist/.build/artifacts/safedi/SafeDIToolBinary/...` (the path SPM uses when it unpacks the binary target SafeDI declares). - Tuist/Package.swift: plain `.package(url: SafeDI..., from: "2.0.0-beta-5")`. Bumping SafeDI is now a one-line edit. Verified locally with `rm -rf Tuist/.build; tuist install && tuist generate && xcodebuild build`.
…e Generated/ User flagged that committed generated-Swift stubs are wrong — generated files shouldn't be in git, full stop. Fixed by making the codegen part of the normal Tuist flow instead: - `Project.swift` shells out to `Scripts/generate-safedi.sh all` at manifest-evaluation time (i.e. during `tuist generate`), populating `ExampleTuistIntegration/Generated/` before Tuist evaluates the source glob. The generated Swift files flow into the pbxproj's compile phase the same way any on-disk source would. - `Scripts/generate-safedi.sh` now works standalone (SRCROOT defaults to the script's grandparent directory; DERIVED_FILE_DIR / BUILT_PRODUCTS_DIR already had `.build/`-scoped fallbacks). - Added an `all` mode that runs both `subproject` and `host` in sequence — what the manifest needs at generate time. - `Generated/` is `.gitignore`d; stubs deleted. - Per-target Xcode build-phase scripts still regenerate on every build, so edits during an iteration cycle don't require a `tuist generate` rerun. User's asked-for workflow (`tuist install && tuist generate && xcodebuild build`) now works on a fresh checkout with nothing committed except source code, manifests, and the `Package.resolved` lockfile.
…mple The old safeditool.sh was broken: it downloaded from `.../releases/download/$VERSION/SafeDITool-arm64`, but current SafeDITool releases ship `SafeDITool.artifactbundle.zip` rather than bare per-arch binaries. It also hardcoded `Views/NotesApp.swift` and built the manifest JSON by hand — predating `SafeDITool scan`. The new ExampleTuistIntegration script (same repo) is a better reference for the same use case: glob-driven sources, scan + generate, cross-module `.safedi` handoff. Consumers who want a non-SPM setup swap the SPM-path lookup for a curl of the artifact bundle.
…aths User pointed out the marker file was reinventing the wheel. It came from an earlier iteration where Generated/ didn't exist at `tuist generate` time (stubs era). Now that Project.swift runs the codegen during `tuist generate`, the generated .swift files are on disk by the time Tuist builds its outputPaths list — we can just enumerate them. Project.swift's host script phase now declares the actual `Generated/*.swift` files as `outputPaths`, so Xcode's dep-analysis gets per-file tracking (same shape as the SPM plugin). Dropped the marker-file write from generate-safedi.sh and the marker mention from the README and PR description.
|
@codex re-review please. Since your last pass, the PR's structure changed meaningfully:
Local verification: clean-slate |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cd06acdbab
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| try? process.run() | ||
| process.waitUntilExit() |
There was a problem hiding this comment.
Fail fast when generate-time SafeDI bootstrap fails
The manifest bootstrap currently swallows script launch failures (try?) and never checks terminationStatus, so tuist generate can succeed even when SafeDI codegen failed (for example, missing tuist install, missing binary, or scan/generate errors). In that state, Generated/ is not populated before Tuist resolves the source glob, so the generated files are omitted from the project and the app later fails to compile/link with much less actionable diagnostics. The bootstrap should propagate non-zero exits as a hard manifest error.
Useful? React with 👍 / 👎.
| host_output_directory="$SRCROOT/$module_dir/Generated" | ||
| mkdir -p "$host_output_directory" | ||
|
|
||
| "$safedi_tool" scan \ |
There was a problem hiding this comment.
Prune stale generated Swift files before host generation
Host generation writes into a persistent, source-globbed Generated/ directory but never removes files that are no longer present in the current scan manifest. If a root/mock type is deleted or renamed, old *+SafeDI*.swift outputs remain and are still compiled via sources: ["ExampleTuistIntegration/**/*.swift"], which can produce hard compiler failures from references to removed types. Cleaning stale outputs (or generating into a non-source scratch directory) avoids this failure mode.
Useful? React with 👍 / 👎.
Addressing user feedback that code gen should be invisible — a build
artifact, not a scripted side effect that lands in the source tree.
- `Project.swift` runs `SafeDITool scan` at `tuist generate` time
(not the full generate) to discover which Swift files SafeDI will
emit for the host module, then declares each one via
`.generated("$(DERIVED_FILE_DIR)/<basename>")`. The tool is the
authority on its own output naming, so no regex heuristic / no
drift risk.
- `Scripts/generate-safedi.sh` now writes every `.swift` output to
`$(DERIVED_FILE_DIR)`. The host target's `Generated/` directory no
longer exists anywhere.
- Dropped the `all` mode from the script — only the per-target Xcode
pre-compile phase invokes it now.
- `.gitignore` drops the `ExampleTuistIntegration/Generated/` rule
(no such path exists anymore).
Verified clean-slate: `rm -rf .build Tuist/.build *.xcworkspace
*.xcodeproj Derived && tuist install && tuist generate --no-open &&
xcodebuild clean build` all succeed, and no generated `.swift` files
appear anywhere in the source tree — only under Xcode's DerivedData.
Codex review on #281: the manifest-time scan via Process swallowed launch errors, non-zero exits, and bad manifest JSON via `try?`, then returned an empty outputs list. `tuist generate` would succeed but with no `.generated(...)` entries in the pbxproj, and the error would only surface later as a cryptic compile failure against missing SafeDI initializers. Replace the silent-fallback paths with a tiny `fatal(_:)` helper that prints `error:` to stderr and exits non-zero. Each failure point now surfaces its own reason: - SafeDITool binary missing (typically `tuist install` wasn't run). - Source enumeration empty. - Process launch failure. - Non-zero scan exit — captured stderr is printed so the user sees the tool's actual error. - Scan manifest unreadable. The success path is unchanged. Verified clean-slate build still passes; `tuist generate` after `rm -rf Tuist/.build` surfaces Tuist's own built-in "run tuist install first" error before my manifest even runs.
|
@codex addressed the P1 in 3db6c55 — manifest-time scan failures now surface via a |
Summary
Testing
Committed on branch
|
Follow-up to the P1 hardening in 3db6c55 — codex flagged that the scratch-directory creation in SafeDIOutputDiscovery still used `try?`, which would swallow the failure and make the subsequent CSV write produce a confusing "file doesn't exist" error. Replace with explicit `do/catch` + `fatal(_:)` so that path surfaces immediately like the others.
|
Thanks for the update — sounds good, and I agree that this closes out the remaining I didn’t make any additional code changes in this follow-up, so there was nothing to commit and no new PR to open. |
The link-check job failed because `../ExampleTuistIntegration` only exists on the still-open PR #281 branch, not on main. Reference the Tuist example by name without the hyperlink — avoids cross-PR coupling without losing the intent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the project-specific subproject|host mode flag with a generic positional API: the first argument is this module's name (also the directory under SRCROOT and the basename of the emitted .safedi artifact); any further arguments are dependent module names whose .safedi artifacts are passed to SafeDITool generate via --dependent-module-info-file-path. Every invocation now writes <module-name>.safedi unconditionally — modules without consumers ignore it. Project.swift's pre-compile scripts now spell out the relationship at the call site (Subproject; ExampleTuistIntegration Subproject) so the wiring is discoverable from the manifest, and the host's .safedi is added to its outputPaths so Xcode's incremental dependency analysis tracks it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…288) ## Summary - Adds **`TuistPlugins/SafeDITuist`** — a Tuist plugin exposing two helpers (`SafeDI.preCompileScript(...)` and `SafeDI.generatedSource`) so a consuming `Project.swift` doesn't need to know anything about SafeDITool's binary layout, manifest schema, or build-phase invocation shape. - Rewires `Examples/ExampleTuistIntegration` to consume the plugin. `Project.swift` drops from ~239 lines (with embedded `Process` glue + JSON decoder) to ~70 lines that look like idiomatic Tuist. - Uses `SafeDITool generate --combined-output` (added in #283, shipped in `2.0.0-beta-6`) so all generated Swift lands at one fixed path, `$(DERIVED_FILE_DIR)/SafeDIGenerated.swift`. Adding/removing `@Instantiable` declarations changes only the file's *contents* — Xcode picks them up incrementally on the next `xcodebuild`, no `tuist generate` round-trip. ## Why Per discussion on #281: a Tuist user lifting the prior example had to copy 115 lines of binary-locating + JSON-decoding scaffolding into their `Project.swift`. That's not Tuist-idiomatic. The plugin is the actual Tuist-native answer — adopting projects do `import SafeDITuist` and call helpers, full stop. For real adoption, consumers reference the plugin via: ```swift .git( url: "https://github.com/dfed/SafeDI", tag: "<version>", directory: "TuistPlugins/SafeDITuist", ) ``` The in-repo example uses `.local(path:)` for development. (Quirk: Tuist resolves `.local(path:)` against `manifestDir/Tuist`, so the path needs an extra leading `..` — comment + README cover this.) ## Plugin API ```swift public enum SafeDI { /// Single fixed-path generated source — register once, never goes stale. public static let generatedSource: SourceFileGlob public static func preCompileScript( module: String, dependents: [String] = [], ) -> TargetScript } ``` - `preCompileScript` wires `SafeDITool generate --combined-output` for one module. Always emits `$(BUILT_PRODUCTS_DIR)/SafeDI/<module>.safedi` and `$(DERIVED_FILE_DIR)/SafeDIGenerated.swift`. Consumers list upstream module names in `dependents:` to feed those `.safedi` artifacts through `--dependent-module-info-file-path`. - `generatedSource` is a single static `SourceFileGlob` — the `.generated(...)` entry to add to a target's `sources`. Path is fixed; only its contents change. ## How the example wires up ```swift import ProjectDescription import SafeDITuist let project = Project( name: "ExampleTuistIntegration", targets: [ .target( name: "Subproject", ..., sources: SourceFilesList(globs: [ .glob("Subproject/**/*.swift"), SafeDI.generatedSource, ]), scripts: [SafeDI.preCompileScript(module: "Subproject")], dependencies: [.external(name: "SafeDI")], ), .target( name: "ExampleTuistIntegration", ..., sources: SourceFilesList(globs: [ .glob("ExampleTuistIntegration/**/*.swift"), SafeDI.generatedSource, ]), scripts: [SafeDI.preCompileScript( module: "ExampleTuistIntegration", dependents: ["Subproject"], )], dependencies: [.target(name: "Subproject"), .external(name: "SafeDI")], ), ], ) ``` ## Verification - `tuist install` resolves the plugin + SPM packages cleanly. - `tuist generate` builds the workspace — the plugin runs no manifest-time scan, so this is fast. - `xcodebuild build` succeeds — both targets emit their `.safedi`, host compiles `SafeDIGenerated.swift`. - **Seamless-rebuild verified**: added `generateMock: true` to a previously-undecorated `@Instantiable` type, ran `xcodebuild` *without* re-running `tuist generate`, the new mock appears in `SafeDIGenerated.swift` and the build picks it up. - Embedded shell verified POSIX-clean against `sh`/`bash`/`dash` `-n` parses. - `./CLI/lint.sh` clean. ## Test plan - [x] CI's `Build Tuist Integration` job passes. - [x] Manual: `@Instantiable` annotation churn doesn't require `tuist generate`. ## Notes - Targets PR #281 — should be merged on top of that branch, or folded into it. - Bumps the example's `Tuist/Package.swift` pin from `2.0.0-beta-5` to `2.0.0-beta-6` (the first release containing `--combined-output`). - Drops the standalone `Scripts/generate-safedi.sh`; the same logic now lives inside the plugin as embedded POSIX shell. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3441f81d3b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let moduleSources: [FileListGlob] = [.glob("\(module)/**/*.swift")] | ||
| let dependentInputPaths: [FileListGlob] = dependents.map { | ||
| FileListGlob.glob("$(BUILT_PRODUCTS_DIR)/SafeDI/\($0).safedi") |
There was a problem hiding this comment.
Track extra config directories as script inputs
Extend the script inputs beyond "\(module)/**/*.swift" when dependency analysis is enabled, because SafeDITool generate also reads files reached through #SafeDIConfiguration(additionalDirectoriesToInclude:). Right now, edits in those additional directories do not appear in inputPaths, so Xcode can skip the script and leave SafeDIGenerated.swift/.safedi stale until a clean build or unrelated source change triggers regeneration.
Useful? React with 👍 / 👎.
The link-check job failed because `../ExampleTuistIntegration` only exists on the still-open PR #281 branch, not on main. Reference the Tuist example by name without the hyperlink — avoids cross-PR coupling without losing the intent.
…safedi_compile Mirrors the shape of the Tuist plugin helper that landed in #281 (`SafeDI.preCompileScript(module:dependents:)`), which writes both the `.safedi` module-info artifact and the combined `.swift` source in a single SafeDITool invocation. The previous Bazel split forced producer-with-mocks targets to invoke SafeDITool twice on the same sources for no gain. ## Rule shape ```starlark load("@safedi//bazel:safedi.bzl", "safedi_compile") safedi_compile( name = "Foo_safedi", srcs = glob([...]), dependents = ["//OtherModule:OtherModule_safedi"], # optional ) ``` Outputs two artifacts: - `<rule>.swift` — combined dependency tree + mocks + mock configuration. Exposed via `DefaultInfo`, so a downstream `swift_library.srcs = glob([...]) + [":<rule>"]` picks it up exactly the way Tuist's `SafeDI.generatedSource` adds the generated source to a target. - `<rule>.safedi` — module-info artifact. Carried via the new `SafeDIInfo` provider; cross-module consumers list the producer rule label in their own `safedi_compile.dependents`. ## Wins - Halves SafeDITool invocations for any producer-with-mocks target. - Tighter API parallel with Tuist (one call site per module). - The `providers = [SafeDIInfo]` constraint on `dependents` blocks passing arbitrary labels — type-safer than the old `allow_files = [".safedi"]` constraint. ## Updates - `bazel/safedi.bzl` — single `safedi_compile` rule + `SafeDIInfo` provider. File-level docstring rewritten to call out the Tuist helper parallel. - `bazel/BUILD.bazel` — comment refers to `safedi_compile`. - `Examples/ExampleBazelIntegration/Subproject/BUILD.bazel` — uses `safedi_compile`; the Subproject's own `swift_library` now includes its `:Subproject_safedi` output (matters once mocks land for Subproject, harmless before). - `Examples/ExampleBazelIntegration/ExampleBazelIntegration/BUILD.bazel` — uses `safedi_compile` with `dependents` instead of `module_infos`. - `Examples/ExampleBazelIntegration/README.md` — comparison table + prose updated to reference `safedi_compile`. ## Verified - `bazelisk build //Sources/...` from repo root — green. - `cd Examples/ExampleBazelIntegration && bazelisk build //...` — green. - `swift build --traits sourceBuild` — green. - `./CLI/lint.sh` — clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Examples/ExampleTuistIntegrationthat mirrorsExampleMultiProjectIntegration's source code (types, mocks, previews) but structures the project with Tuist.Subprojectframework are compiled as separate modules. The subproject emits a.safedimodule-info artifact viaSafeDITool generate --module-info-output; the host consumes it via--dependent-module-info-file-path— the host never re-parses subproject sources..safediartifact handoff.How the user runs it
Generated code is a true build artifact. Nothing lands in the source tree: no committed stubs, no
.gitignoredGenerated/directory, no pre-commit bootstrap step.Project.swiftasksSafeDITool scanat manifest-evaluation time which Swift files the tool will emit and registers each as.generated("$(DERIVED_FILE_DIR)/..."); the pre-compile script writes those files at build time.How it differs from
ExampleMultiProjectIntegration.xcodeprojProject.swift+Tuist.swift#SafeDIConfiguration(additionalDirectoriesToInclude: ["Subproject"])— host re-scans subproject sources.safedi; host reads it via--dependent-module-info-file-pathSafeDIGeneratorSPM build-tool pluginTargetScript.prephases invokingSafeDITool(samescan+generate --swift-manifestflow the plugin uses)XCLocalSwiftPackageReferencein pbxprojTuist/Package.swiftdepends on SafeDI;Project.swifttargets use.external(name: "SafeDI")tuist installfetches it via SafeDI's defaultprebuilttrait chain$(DERIVED_FILE_DIR), declared via.generated(…)How it's meant to be copy-pasted
No hardcoded file lists anywhere:
find(in the script) andFileListGlob(in the manifest) enumerate inputs;SafeDITool scanattuist generatetime enumerates the outputs the tool will emit. Consumer edits when lifting into a new project:Project.swiftScripts/generate-safedi.shcaselabels (subproject/host) if module names differ, or add branches for more targetsTuist/Package.swiftObsoletes
Examples/PrebuildScript/safeditool.shThe old script was broken: it downloaded from
.../releases/download/$VERSION/SafeDITool-arm64(current SafeDI releases shipSafeDITool.artifactbundle.zipinstead), hardcodedViews/NotesApp.swift, and built the manifest JSON by hand (predatedSafeDITool scan). Deleted;Documentation/Manual.md's pre-build-script reference now points at the new example's script.CI
New
spm-tuist-integrationjob on macos-26: installs Tuist viamise(pinned to 4.183.0), runstuist installandtuist generate --no-open, thenxcodebuild builds the generated workspace.GITHUB_TOKENis hoisted to the job env so mise / Tuist's SPM resolver aren't rate-limited as anonymous.Test plan
rm -rf Tuist/.build .build Derived *.xcworkspace *.xcodeproj && tuist install && tuist generate --no-open && xcodebuild clean buildall succeed from a clean slate locally (Tuist 4.183.0, Xcode 26.4).Subproject.safediis emitted to$(BUILT_PRODUCTS_DIR)/SafeDI/during the subproject's build phase.NotesApp+SafeDI.swift,LoggedInView+SafeDIMock.swift,NameEntryView+SafeDIMock.swift,SafeDIMockConfiguration.swiftall land in$(DERIVED_FILE_DIR)— none in the source tree.swift run swiftformat --lintis clean.Tuist/Package.resolved.spm-tuist-integrationpasses on macos-26.