TuistPlugins: add SafeDITuist plugin for seamless Tuist integration#288
Conversation
Adds a Tuist plugin (TuistPlugins/SafeDITuist) that exposes two helpers consumers call from Project.swift: SafeDI.preCompileScript(module:dependents:generatedOutputs:) SafeDI.generatedSources(for:) The first returns a TargetScript.pre wired with an embedded POSIX shell that runs SafeDITool scan + generate at build time, emits $(BUILT_PRODUCTS_DIR)/SafeDI/<module>.safedi for downstream consumers, and feeds dependents' .safedi artifacts through --dependent-module-info-file-path. The second runs scan at `tuist generate` time and returns [.generated(...)] entries pointing at $(DERIVED_FILE_DIR), so Xcode wires build-time outputs into the compile phase. The example's Project.swift drops to ~60 lines: no Process glue, no scan-manifest decoder, no shell script in the source tree. Tuist.swift declares the plugin via .local(path:); a real adopting project would use .git(url:tag:directory:) — documented in README and in the example's Tuist.swift. Verified end-to-end: tuist install, tuist generate, xcodebuild. 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: 81e12e657e
ℹ️ 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".
Previously the embedded build-time shell piped find through sed/tr under `set -eu`. POSIX sh has no `pipefail`, so a non-zero `find` exit (unreadable subdir, broken symlink) was masked when the downstream pipeline stages succeeded — SafeDITool would then run against a partial source list. Fix: write find's output to a tempfile in a standalone command so `set -e` catches its exit, then build the CSV via a POSIX `while read` loop. No pipeline, no extension dependencies. 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: 2b69b2fdfb
ℹ️ 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".
Two fixes: - The optional `sources:` parameter on `preCompileScript` fed only Xcode's `inputPaths` while the embedded script always scanned `$SRCROOT/<module>/**/*.swift`. A consumer narrowing `sources:` would get stale .safedi/generated Swift outputs because Xcode would skip rebuilds when files outside the narrower glob changed but the script still read them. Drop the parameter — the helper now uses one glob for both `inputPaths` and the scan, so they can't drift. Documented the layout assumption; consumers with non-conforming module shapes can build their own TargetScript. - Documentation/Manual.md pointed at the deleted `Examples/ExampleTuistIntegration/Scripts/generate-safedi.sh`. Repoint at the embedded shell inside `TuistPlugins/SafeDITuist/ProjectDescriptionHelpers/SafeDI.swift`, which is now the working reference for driving SafeDITool from a pre-build script. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## claude/tuist-example-project-VXdUq #288 +/- ##
=====================================================================
Coverage 100.00% 100.00%
=====================================================================
Files 41 41
Lines 7043 6940 -103
=====================================================================
- Hits 7043 6940 -103 🚀 New features to boost your workflow:
|
Plain `tuist generate` after adding/removing files is generic Tuist behavior, not SafeDI-specific — calling it out blurred which step the plugin actually requires. Restate the guidance to call out only the SafeDI-specific case: changes to @INSTANTIABLE(isRoot:) or @INSTANTIABLE(generateMock:) declarations alter generated output filenames, so the manifest-time scan needs to re-run via tuist generate to refresh the .generated(...) source list. 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: da7ebfc300
ℹ️ 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".
SafeDI 2.0.0-beta-6 ships --combined-output, which collapses every emitted Swift file (dependency trees, mocks, mock configuration) into one fixed path. Wiring the plugin to it eliminates the footgun where adding/removing an @INSTANTIABLE(isRoot:) or @INSTANTIABLE(generateMock:) declaration silently required `tuist generate` to refresh the .generated(...) source list — adding annotations now changes only the *contents* of $(DERIVED_FILE_DIR)/SafeDIGenerated.swift, never its name, so Xcode picks them up incrementally on the next xcodebuild. Plugin API change: - Drops `SafeDI.generatedSources(for:)` (a function returning [SourceFileGlob] from a manifest-time scan) for `SafeDI.generatedSource` (a single static SourceFileGlob constant pointing at the fixed combined-output path). - Drops `generatedOutputs:` parameter from `preCompileScript` — the helper now declares the fixed combined path as its own output internally. Also drops: - The ~120-line ManifestScanner enum that ran SafeDITool scan in a Process at `tuist generate` time and decoded its JSON manifest. - The `--swift-manifest` plumbing in the embedded shell — we now invoke `generate` with `--combined-output` directly, no scan step required. Verified end-to-end: `tuist install`, `tuist generate`, `xcodebuild build` all succeed. Hot-loop verified: adding `generateMock: true` to a previously-undecorated type and rebuilding (without re-running `tuist generate`) regenerates SafeDIGenerated.swift with the new mock and the build picks it up. Bumps the example's Tuist/Package.swift pin from 2.0.0-beta-5 to 2.0.0-beta-6 (which contains #283). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`$module` is also used as the basename of the .safedi artifact written to $BUILT_PRODUCTS_DIR/SafeDI/. When a consumer passes a slash-containing name like "Sources/App", `module_info_output` becomes $BUILT_PRODUCTS_DIR/SafeDI/Sources/App.safedi but only the SafeDI/ dir exists, so SafeDITool's write fails with a file-not-found error before writing anything. Fix: `mkdir -p "$(dirname "$module_info_output")"` after composing the path. No-op for flat names; creates intermediates for nested. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The doc string described the script doing its "own scan" — leftover phrasing from when the script ran `SafeDITool scan` before generate. With --combined-output the script just `find`s the source list and hands it to `generate` directly. Reword to say so plainly so readers don't conflate the source enumeration with `SafeDITool scan` (which is no longer invoked). Comment-only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
TuistPlugins/SafeDITuist— a Tuist plugin exposing two helpers (SafeDI.preCompileScript(...)andSafeDI.generatedSource) so a consumingProject.swiftdoesn't need to know anything about SafeDITool's binary layout, manifest schema, or build-phase invocation shape.Examples/ExampleTuistIntegrationto consume the plugin.Project.swiftdrops from ~239 lines (with embeddedProcessglue + JSON decoder) to ~70 lines that look like idiomatic Tuist.SafeDITool generate --combined-output(added in SafeDITool: add --combined-output flag to generate #283, shipped in2.0.0-beta-6) so all generated Swift lands at one fixed path,$(DERIVED_FILE_DIR)/SafeDIGenerated.swift. Adding/removing@Instantiabledeclarations changes only the file's contents — Xcode picks them up incrementally on the nextxcodebuild, notuist generateround-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 doimport SafeDITuistand call helpers, full stop.For real adoption, consumers reference the plugin via:
The in-repo example uses
.local(path:)for development. (Quirk: Tuist resolves.local(path:)againstmanifestDir/Tuist, so the path needs an extra leading..— comment + README cover this.)Plugin API
preCompileScriptwiresSafeDITool generate --combined-outputfor one module. Always emits$(BUILT_PRODUCTS_DIR)/SafeDI/<module>.safediand$(DERIVED_FILE_DIR)/SafeDIGenerated.swift. Consumers list upstream module names independents:to feed those.safediartifacts through--dependent-module-info-file-path.generatedSourceis a single staticSourceFileGlob— the.generated(...)entry to add to a target'ssources. Path is fixed; only its contents change.How the example wires up
Verification
tuist installresolves the plugin + SPM packages cleanly.tuist generatebuilds the workspace — the plugin runs no manifest-time scan, so this is fast.xcodebuild buildsucceeds — both targets emit their.safedi, host compilesSafeDIGenerated.swift.generateMock: trueto a previously-undecorated@Instantiabletype, ranxcodebuildwithout re-runningtuist generate, the new mock appears inSafeDIGenerated.swiftand the build picks it up.sh/bash/dash-nparses../CLI/lint.shclean.Test plan
Build Tuist Integrationjob passes.@Instantiableannotation churn doesn't requiretuist generate.Notes
Tuist/Package.swiftpin from2.0.0-beta-5to2.0.0-beta-6(the first release containing--combined-output).Scripts/generate-safedi.sh; the same logic now lives inside the plugin as embedded POSIX shell.🤖 Generated with Claude Code