Skip to content

Examples: add Tuist multi-target integration example#281

Merged
dfed merged 19 commits intomainfrom
claude/tuist-example-project-VXdUq
May 2, 2026
Merged

Examples: add Tuist multi-target integration example#281
dfed merged 19 commits intomainfrom
claude/tuist-example-project-VXdUq

Conversation

@dfed
Copy link
Copy Markdown
Owner

@dfed dfed commented Apr 22, 2026

Summary

  • New Examples/ExampleTuistIntegration that mirrors ExampleMultiProjectIntegration's source code (types, mocks, previews) but structures the project with Tuist.
  • The host app and the Subproject framework are compiled as separate modules. The subproject emits a .safedi module-info artifact via SafeDITool generate --module-info-output; the host consumes it via --dependent-module-info-file-path — the host never re-parses subproject sources.
  • This exercises a SafeDITool codepath the SPM plugin doesn't use today: true separate-compilation with .safedi artifact handoff.

How the user runs it

tuist install     # pulls SafeDI + SafeDITool artifact bundle via SPM
tuist generate    # scans for expected outputs, registers them via .generated(…)
xcodebuild build  # pre-compile script phase runs SafeDITool scan + generate,
                  # writing every .swift output into $(DERIVED_FILE_DIR)

Generated code is a true build artifact. Nothing lands in the source tree: no committed stubs, no .gitignored Generated/ directory, no pre-commit bootstrap step. Project.swift asks SafeDITool scan at 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

MultiProject Tuist
Project format committed .xcodeproj Tuist Project.swift + Tuist.swift
Cross-module types #SafeDIConfiguration(additionalDirectoriesToInclude: ["Subproject"]) — host re-scans subproject sources Subproject emits .safedi; host reads it via --dependent-module-info-file-path
SafeDI code gen SafeDIGenerator SPM build-tool plugin Hand-rolled TargetScript.pre phases invoking SafeDITool (same scan + generate --swift-manifest flow the plugin uses)
SafeDI package link XCLocalSwiftPackageReference in pbxproj Tuist/Package.swift depends on SafeDI; Project.swift targets use .external(name: "SafeDI")
SafeDITool binary built from source (SPM trait flip) downloaded as SafeDI's published artifact bundle — tuist install fetches it via SafeDI's default prebuilt trait chain
Generated code location derived-data, plugin-managed $(DERIVED_FILE_DIR), declared via .generated(…)

How it's meant to be copy-pasted

No hardcoded file lists anywhere: find (in the script) and FileListGlob (in the manifest) enumerate inputs; SafeDITool scan at tuist generate time enumerates the outputs the tool will emit. Consumer edits when lifting into a new project:

Where What
Project.swift target names, bundle IDs, deployment targets, module directory names
Scripts/generate-safedi.sh the two case labels (subproject / host) if module names differ, or add branches for more targets
Tuist/Package.swift bump SafeDI version — single source of truth; artifact bundle follows automatically

Obsoletes Examples/PrebuildScript/safeditool.sh

The old script was broken: it downloaded from .../releases/download/$VERSION/SafeDITool-arm64 (current SafeDI releases ship SafeDITool.artifactbundle.zip instead), hardcoded Views/NotesApp.swift, and built the manifest JSON by hand (predated SafeDITool scan). Deleted; Documentation/Manual.md's pre-build-script reference now points at the new example's script.

CI

New spm-tuist-integration job on macos-26: installs Tuist via mise (pinned to 4.183.0), runs tuist install and tuist generate --no-open, then xcodebuild builds the generated workspace. GITHUB_TOKEN is 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 build all succeed from a clean slate locally (Tuist 4.183.0, Xcode 26.4).
  • Subproject.safedi is emitted to $(BUILT_PRODUCTS_DIR)/SafeDI/ during the subproject's build phase.
  • Host's generated NotesApp+SafeDI.swift, LoggedInView+SafeDIMock.swift, NameEntryView+SafeDIMock.swift, SafeDIMockConfiguration.swift all land in $(DERIVED_FILE_DIR) — none in the source tree.
  • swift run swiftformat --lint is clean.
  • No generated files or binaries are committed — only source, manifests, and Tuist/Package.resolved.
  • New CI job spm-tuist-integration passes on macos-26.

claude and others added 4 commits April 22, 2026 05:31
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
Copy link
Copy Markdown

codecov Bot commented Apr 22, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (f5be952) to head (3441f81).
⚠️ Report is 6 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##              main      #281    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files           41        41            
  Lines         6940      7043   +103     
==========================================
+ Hits          6940      7043   +103     

see 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@dfed
Copy link
Copy Markdown
Owner Author

dfed commented Apr 22, 2026

@codex please review this PR. It adds a Tuist-based port of the existing multi-project example where the subproject emits a .safedi module-info artifact that the host target consumes — the host never re-parses subproject sources. Local tuist generate + xcodebuild both succeed; all CI checks are green.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +48 to +52
# Homebrew
brew install --formula tuist

# Curl installer
curl -Ls https://tuist.dev/install.sh | bash
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.
@dfed
Copy link
Copy Markdown
Owner Author

dfed commented Apr 22, 2026

@codex I addressed the README feedback in 6aec6b9 — replaced the non-functional brew / install.sh instructions with the mise-based install path that CI is using. Ready for another look.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +49 to +50
mise install tuist@latest
mise use -g tuist@latest
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +131 to +132
equivalent of the first-time setup steps above: it installs Tuist via
Homebrew, builds `SafeDITool`, runs `tuist generate`, then builds the
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge 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 👍 / 👎.

dfed added 2 commits April 21, 2026 23:33
…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).
@dfed
Copy link
Copy Markdown
Owner Author

dfed commented Apr 22, 2026

@codex addressed both follow-ups in e0f3c2d — pinned the README install to tuist@4.183.0 to match CI, and corrected the CI-section description from "Homebrew" to "mise, pinned 4.183.0". Ready for another look.

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

ℹ️ 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".

dfed added 5 commits April 22, 2026 07:01
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.
@dfed
Copy link
Copy Markdown
Owner Author

dfed commented Apr 22, 2026

@codex re-review please. Since your last pass, the PR's structure changed meaningfully:

  • Generated files are now gitignored. Codegen runs during tuist generate (Project.swift shells out to Scripts/generate-safedi.sh all) so ExampleTuistIntegration/Generated/ is populated before Tuist evaluates the source glob. Per-target Xcode build phases regenerate on each build. No stubs committed.
  • Single SafeDI reference. Dropped Project.packages; Tuist/Package.swift now declares the sole SafeDI dep, and targets use .external(name: "SafeDI"). SafeDI's default prebuilt trait pulls the SafeDIToolBinary artifact bundle as a side effect of tuist install, so the script reads it from Tuist/.build/artifacts/safedi/SafeDIToolBinary/... — no curl in the shell.
  • No hardcoded file lists anywhere. find in the script, FileListGlob in the manifest for inputs; SafeDITool scan discovers outputs and Project.swift enumerates the populated Generated/ directory at generate time to compute concrete outputPaths (no marker-file workaround).
  • Deleted the broken Examples/PrebuildScript/safeditool.sh (wrong release URL, hardcoded paths); repointed Documentation/Manual.md's pre-build-script reference at the new example's script.

Local verification: clean-slate tuist install && tuist generate --no-open && xcodebuild build passes; swiftformat --lint is clean.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +25 to +26
try? process.run()
process.waitUntilExit()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +182 to +185
host_output_directory="$SRCROOT/$module_dir/Generated"
mkdir -p "$host_output_directory"

"$safedi_tool" scan \
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.
@dfed
Copy link
Copy Markdown
Owner Author

dfed commented Apr 22, 2026

@codex addressed the P1 in 3db6c55 — manifest-time scan failures now surface via a fatal(_:) helper that prints error: to stderr and exits non-zero (binary-missing, source-list-empty, process-launch-error, non-zero-scan-exit with captured stderr, and manifest-unreadable each have their own message). P2 is already mooted by the generate-to-$(DERIVED_FILE_DIR) refactor — no source-tree Generated/ exists for stale files to linger in.

@chatgpt-codex-connector
Copy link
Copy Markdown

Summary

Testing

  • bash -n Examples/ExampleTuistIntegration/Scripts/generate-safedi.sh
  • ⚠️ swiftformat --lint Examples/ExampleTuistIntegration/Project.swift (warning: swiftformat is not installed in this environment)

Committed on branch work as:

  • b792d52ExampleTuistIntegration: fail fast when manifest scratch dir can't be created

View task →

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.
@dfed
Copy link
Copy Markdown
Owner Author

dfed commented Apr 22, 2026

@codex applied the remaining try? cleanup in 650cc6e — the scratch-dir createDirectory now hits the same fatal(_:) helper as the other manifest-time failure paths. Thanks for the follow-up.

@chatgpt-codex-connector
Copy link
Copy Markdown

Thanks for the update — sounds good, and I agree that this closes out the remaining try? cleanup in the manifest-time path.

I didn’t make any additional code changes in this follow-up, so there was nothing to commit and no new PR to open.

View task →

@dfed dfed self-assigned this Apr 22, 2026
@dfed dfed marked this pull request as ready for review April 22, 2026 19:04
dfed added a commit that referenced this pull request Apr 23, 2026
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.
Comment thread Examples/ExampleTuistIntegration/README.md Outdated
Comment thread Examples/ExampleTuistIntegration/Scripts/generate-safedi.sh Outdated
dfed and others added 2 commits April 28, 2026 07:08
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>
@dfed dfed merged commit 353609e into main May 2, 2026
17 checks passed
@dfed dfed deleted the claude/tuist-example-project-VXdUq branch May 2, 2026 04:43
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +72 to +74
let moduleSources: [FileListGlob] = [.glob("\(module)/**/*.swift")]
let dependentInputPaths: [FileListGlob] = dependents.map {
FileListGlob.glob("$(BUILT_PRODUCTS_DIR)/SafeDI/\($0).safedi")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

dfed added a commit that referenced this pull request May 2, 2026
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.
dfed added a commit that referenced this pull request May 2, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants