Skip to content

TuistPlugins: add SafeDITuist plugin for seamless Tuist integration#288

Merged
dfed merged 7 commits intoclaude/tuist-example-project-VXdUqfrom
claude/tuist-plugin-helpers
May 2, 2026
Merged

TuistPlugins: add SafeDITuist plugin for seamless Tuist integration#288
dfed merged 7 commits intoclaude/tuist-example-project-VXdUqfrom
claude/tuist-plugin-helpers

Conversation

@dfed
Copy link
Copy Markdown
Owner

@dfed dfed commented Apr 28, 2026

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 SafeDITool: add --combined-output flag to generate #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:

.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

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

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

  • CI's Build Tuist Integration job passes.
  • Manual: @Instantiable annotation churn doesn't require tuist generate.

Notes

  • Targets PR Examples: add Tuist multi-target integration example #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

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

Comment thread TuistPlugins/SafeDITuist/ProjectDescriptionHelpers/SafeDI.swift Outdated
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>
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: 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".

Comment thread TuistPlugins/SafeDITuist/ProjectDescriptionHelpers/SafeDI.swift
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
Copy link
Copy Markdown

codecov Bot commented Apr 28, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (682335f) to head (3d84bc9).
⚠️ Report is 1 commits behind head on claude/tuist-example-project-VXdUq.

Additional details and impacted files

Impacted file tree graph

@@                          Coverage Diff                          @@
##           claude/tuist-example-project-VXdUq      #288    +/-   ##
=====================================================================
  Coverage                              100.00%   100.00%            
=====================================================================
  Files                                      41        41            
  Lines                                    7043      6940   -103     
=====================================================================
- Hits                                     7043      6940   -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.

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

Comment thread TuistPlugins/SafeDITuist/ProjectDescriptionHelpers/SafeDI.swift
dfed and others added 2 commits May 1, 2026 21:08
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>
@dfed dfed changed the title TuistPlugins: add SafeDITuist plugin; rewire Tuist example to consume it TuistPlugins: add SafeDITuist plugin for seamless Tuist integration May 2, 2026
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>
@dfed dfed merged commit 3441f81 into claude/tuist-example-project-VXdUq May 2, 2026
16 checks passed
@dfed dfed deleted the claude/tuist-plugin-helpers branch May 2, 2026 04:43
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.

1 participant