RFC: Custom Code Generators for Tuist Targets

Summary

This RFC proposes codeGenerators: on a Tuist target: a declaration that a command produces some of the target’s sources from a declared set of inputs. Tuist runs the command, attaches its output directory to the target as a buildable folder, and folds the declaration into the target’s cache key.

The decision everything else follows from is that generated output is never content-hashed. An action hash over one generator’s declaration contributes instead. That lets tuist hash, tuist graph, and tuist inspect be correct without running a single generator, and lets generation be deferred until after focus, tree-shaking, and cache substitution. The foreignBuild mechanism, from the External Build System Dependencies RFC, already hashes a script’s inputs rather than its output; codegen goes further and hashes input paths as well as contents.

A second primitive covers generators whose output spans several targets and so cannot be attached to only one.

Motivation

Developers who generate Swift sources today have two options.

An Xcode build-phase script. The generator runs at build time, so before the first build there are no symbols; autocomplete, jump-to-definition, and diagnostics are unavailable on any line touching generated code. This is the main cost. Tuist does hash a script’s declared inputPaths by content, so caching is not broken, and a tool’s version can be approximated by listing a lockfile.

A bespoke CLI or script run before tuist generate. This is the shape I see at scale. A large app I work with, roughly 800 targets, has a Swift CLI generating resource accessors, localized strings, analytics events, snapshot-test scaffolding, deeplink routes, and GraphQL types via Apollo codegen. Two costs follow from Tuist not knowing about it:

  1. It has to roll its own caching. Every generator carries a hand-written hash file so it can skip work, duplicating machinery Tuist already has and keeping it correct independently.
  2. It has to model the project itself. Several generators need to know which targets exist and where their sources are. Either the tool derives its own graph from directory layout, in parallel with Tuist’s, or it asks Tuist for the graph and generation becomes two passes.

There is also a smaller ergonomic cost: tuist has to be wrapped in another command that runs first, which is a step every engineer and CI job has to know about.

The generators themselves are fine. Both workarounds exist because Tuist has no way to say: these files are produced by this command, from these inputs.

Current State

Most of the mechanism already ships, which keeps this proposal small. RFC: External Build System Dependencies landed foreignBuild, and with it:

  • Target.ForeignBuild.Input: .file, .folder, .glob, and .script, whose stdout is captured. The four cases carry over, though not the type itself: it resolves inputs to absolute paths and expands globs at manifest-map time, discarding the path identity a code generator depends on.
  • Not hashing the output. ForeignBuildHasher folds a foreignBuild’s inputs and the script text into the target hash and never touches the produced artifact, because the artifact does not exist on a fresh clone and its bytes are a function of the inputs anyway. Codegen adopts the principle with a stricter hash.
  • Selection gating. ForeignBuildSideEffectGraphMapper runs after cache substitution and tree-shaking, so a pruned or cache-hit target never executes its script.
  • Aggregate targets, generated as PBXAggregateTarget. isAggregate is foreignBuild != nil today, so this does not yet extend to a codegen target; see Rollout.

Tuist also already attaches generated sources with nothing declared in any manifest: SynthesizedResourceInterfaceProjectMapper renders resource accessors into Derived/Sources and appends them to target.sources. Resource synthesizers are the existing answer to this problem class. Their templates are already overridable, per-project or from a plugin. The set of parsers is not, so they cover the file types SwiftGen handles and nothing else. What follows is a generalization of that mechanism to an open class of generators, and it is close enough that .assets() and its siblings could plausibly be reimplemented on top of it.

Prior Art

SwiftPM Build Tool Plugins (SE-0303)

A plugin is declared as a package target of type plugin, containing Swift source with a @main type conforming to BuildToolPlugin. The plugin does not do the work; it returns Command values naming a tool resolved via context.tool(named:), where the tool is an executableTarget or a prebuilt binaryTarget. Arbitrary shell scripts were considered and rejected as “more subtle and less clear” than a Swift API, though a plugin can wrap one.

The directly relevant part is prebuildCommand: instead of enumerating outputs it takes an outputFilesDirectory, and SwiftPM treats whatever files are present afterward as sources. That exists precisely because tools like SwiftGen produce a file set determined by input contents rather than input names. The tradeoff SE-0303 accepts is that prebuild commands run on every build and are expected to cache aggressively themselves. What does not transfer is requiring the generator to be a SwiftPM target at all, since teams already own SwiftGen, Apollo, and Sourcery invocations wired into mise and shell. Reference: SE-0303.

Bazel genrule

The genrule rule takes srcs, outs, and cmd. The action’s cache key comes from srcs, cmd, and the resolved toolchain, never from outs. That makes remote caching sound and lets Bazel decide an action is up to date without running it. In exchange outs must be declared exactly, and writing an undeclared file fails the action. Reference: Bazel genrule.

The inputs-over-outputs hashing principle is worth adopting wholesale, because it decouples “compute the hash” from “run the tool.” The rest of Bazel’s action key does not come along, and that is worth being clear about: Bazel also folds in the resolved toolchain and a controlled environment, and enforces the declaration with a sandbox that fails an action for reading or writing outside it. This proposal has none of that, so its keys are sound only to the extent that a generator’s declaration is honest, where Bazel’s are sound by construction.

The exhaustive outs is the other part that does not transfer, because it buys something this design does not need: Bazel enumerates outputs so other actions can depend on individual generated files, and so the sandbox can catch undeclared writes. Here the unit of consumption is a whole directory owned by one consumer, so a declared output directory carries the same information. SE-0303 reached the same conclusion with prebuildCommand.

Proposed Solution

Per-target generators

A target declares generators that produce its own sources:

.target(
    name: "AccountUI",
    destinations: .iOS,
    product: .staticFramework,
    bundleId: "dev.tuist.example.accountui",
    buildableFolders: ["Sources/AccountUI"],
    codeGenerators: [
        .script(
            name: "assets",
            script: """
            mise x -- swiftgen run xcassets \
              --templatePath "$TUIST_ROOT_DIR/templates/Assets.stencil" \
              --output "$TUIST_OUTPUT_SOURCES_DIR/TuistAssets+$TUIST_TARGET_NAME.swift" \
              "$TUIST_PROJECT_DIR/Sources/AccountUI/Resources"
            """,
            inputs: [
                .folder("Sources/AccountUI/Resources"),
                .file("//templates/Assets.stencil"),
                .script("mise x -- swiftgen --version"),
            ]
        ),
    ]
)

Output goes to Derived/CodeGen/Targets/{target-name}/{generator-name}/Sources/, which Tuist attaches as a buildable folder. A buildable folder is a PBXFileSystemSynchronizedRootGroup holding only a path, with contents resolved by Xcode at build time, so the .xcodeproj never has to enumerate what the generator produced. That allows generation to happen after the graph is mapped, and leaves room to run generators concurrently with writing the project rather than strictly before it. sources: would require the file list at project-write time and reintroduce the enumeration problem. No output path appears in any manifest. It is fully determined by the target name and the generator’s name:, so there is nothing to declare and nothing that can drift. name: therefore doubles as a label and a path component, which keeps two generators on one target from colliding.

A // prefix on an input path anchors it to the repository root; without it a path resolves against the directory holding Project.swift. Both forms appear above deliberately, since the template is shared and the resources belong to this target.

Those path components need only be unique within a project, not across the workspace, because Derived/ is rooted at the project directory rather than the repository root. Two projects may each define a Utilities target, and their output lands in Projects/Foo/Derived/… and Projects/Bar/Derived/… respectively. Target names are unique within a project, so {target-name}/{generator-name} is unambiguous.

Promoting a name to a path component needs a rule, and it covers target names as well as generator names. Either is rejected when it contains a path separator or a . or .. component. Two names that would share a directory are rejected when they differ only by case or by Unicode normalization, since APFS collapses both: generator names within one target, and target names within one project. Rejecting beats encoding, because these are names the author picked and can change.

The environment provides TUIST_OUTPUT_SOURCES_DIR, TUIST_TARGET_NAME, TUIST_PROJECT_DIR, and TUIST_ROOT_DIR. The last two are both needed because a manifest can name paths relative to either. Notably Derived/ appears in none of those values, which leaves Tuist free to change the layout later. Both the variable and the Sources/ path component name what the directory holds, so a second kind of output can be added later without renaming either.

TUIST_OUTPUT_SOURCES_DIR names a staging directory that Tuist moves into place once the generator succeeds, for reasons under Incrementality and output lifecycle. A generator writes files into the directory it is given, but must not hard-code that directory’s path inside them, since the path it sees is not where those files end up.

Resolved inputs are passed as a file, not as an environment variable: TUIST_INPUTS_FILE points at JSON describing each declared input.

[
  { "type": "folder", "path": "/…/Sources/AccountUI/Resources" },
  { "type": "file",   "path": "/…/templates/Assets.stencil" }
]

A single variable holding hundreds of newline-separated paths would be fragile: it competes with the process argument and environment limit, and it forces a quoting convention on paths that may contain spaces. A file also lets each entry keep its type, which matters because .script inputs are not paths at all. They contribute stdout to the hash and are therefore absent from this list.

Note the example does not feed this file to swiftgen, because inputs and command arguments serve different purposes: inputs declare what the generator depends on, for hashing, while the command names what it reads. TUIST_INPUTS_FILE is there for generators that want to operate on exactly the declared set, which is the only way to guarantee the files hashed and the files read are the same.

Hashing

A codegen output directory never contributes its file contents to any hash. An action hash over one generator’s declaration contributes instead.

A logical path is the path as the manifest names it, relative to its declared root. The action hash covers, per input, the kind plus:

  • .file: logical path and contents.
  • .folder: logical path, and the relative paths and contents of the whole tree.
  • .glob: the unexpanded pattern, and the logical path and contents of each match in canonical order.
  • .script: stdout.

and per action: the command, the generator’s name:, the identity of the target that declares it, and the values declared in environment:. A multi-target generator also folds in the consumer mapping it declares, described under Multi-target generators.

Paths contribute and not only contents, because generators derive type names and output filenames from input filenames: renaming AccountQuery.graphql changes every Apollo type it produces. Absolute checkout paths must not contribute, though, or two clones disagree and remote cache hits disappear. //templates/Assets.stencil therefore stays repository-relative and Sources/AccountUI/Resources stays project-relative.

Retaining the .glob pattern also means one matching nothing still contributes itself, so the hash moves when the first match appears. Zero matches is therefore a warning and not an error: a project may legitimately have no inputs of an optional kind yet.

environment: is where a generator names the variables its output depends on, as environment: ["STRINGS_ACCESS_LEVEL": "public"]. Declared values are added to the inherited environment rather than replacing it, so the parameter promotes a variable into the cache key without fencing off the rest. PATH stays ambient and unhashed, since hashing it would break cache hits between machines; a .script("… --version") input is how a generator captures which build of a tool actually ran.

The hash is folded into the owning target’s content hash, and persisted as the skip key under Incrementality and output lifecycle.

This carries a contract, and it is the one thing a generator author has to internalize: Tuist assumes a generator’s output is a deterministic function of its command, its declared inputs, and its declared environment. Undeclared dependencies violate that contract and may produce incorrect cache hits. Nothing enforces it in the first version, which the sandbox under Future Directions would change.

One consequence is enforceable now, and cheaply: a resolved codegen input and output may not overlap, in either direction, after canonical path resolution. Input resolution never descends into Derived/, so a wide pattern such as //Projects/**/*.graphql cannot reach generated output even though its root contains every project’s Derived/. What the check catches is a declaration that names an output directory outright. Input inside output is the chaining case discussed under Future Directions. Output inside input is the subtler one, and just as broken: each run would fold the previous run’s generated files into the hash of the run that produces them, so the hash never settles and no source change reliably invalidates.

A generated folder is excluded from content hashing. The mapper that attaches the output folder marks it as generated, a flag on BuildableFolder, and a marked folder contributes no file contents to a target hash. Buildable folders are otherwise content-hashed by reading every resolved file from disk, so on a command that does not run generators the hash would be taken over an empty or stale directory and would differ from what tuist generate later computes. The marker removes that dependency on disk state, so the hash is the same whether or not output exists. Which commands materialize is under Execution model. Only that mapper sets the flag, so there is no manifest surface for it and a folder declared in buildableFolders: is unaffected.

Incrementality and output lifecycle

Preserving Derived/CodeGen across generates makes a skip possible. Tuist records each action’s hash under Derived/CodeGen/State/, outside every attached output directory.

A generator is skipped when the recorded hash matches and every output directory the action writes to already exists. No separate success flag is needed, because the recorded hash doubles as one.

Otherwise Tuist hands the generator one fresh empty staging directory per output and runs it. Once the command exits zero, Tuist deletes the recorded hash, moves each staging directory into place, and writes the new hash. The hash is absent for exactly the window in which the output directories are inconsistent, so two conditions cover the third.

Four properties follow:

  • Files it no longer produces disappear, because staging starts empty.
  • A failed run leaves the previous output rather than a partial tree that looks generated.
  • A multi-target action cannot be half-skipped, since one hash covers all of its outputs.
  • A crash during the swap leaves no recorded hash, whichever move it interrupts, so the next run regenerates.

The cost is that any change re-runs the whole action. The generators this targets reparse their entire input set anyway.

Execution model

Materialization is an explicit phase that a command requests, not a step of graph construction. generate, build, and test materialize, after focus, tree-shaking, and cache substitution; hash, graph, inspect, and cache’s hashing pass do not. Building an already-generated workspace requests it explicitly rather than inheriting it from graph loading.

Because no hash depends on generated bytes, materialization need only precede compilation, so a pruned or cache-hit target never runs its generator: tuist generate AccountUI runs that target’s generators rather than every generator in the graph.

Project generation before materialization

Project generation runs before generators do, so a codegen output directory is empty while the project is written. BuildPhaseGenerator asks BuildableFolderChecker.containsSources whether to attach a sources build phase, and an empty answer skips it for .app, .appClip, .appExtension, .commandLineTool, .watch2App, and the remaining app and extension products. Frameworks, libraries, and test bundles attach one unconditionally.

The generated marker settles this too. It tells project generation the folder may contain sources, independently of what is on disk. The decision varies by product type, so it needs test coverage across all of them.

Multi-target generators

The per-target primitive covers a generator whose inputs and output both belong to one target, which is most of them. It does not cover a generator configured to emit into several targets from one invocation. Apollo is the case I hit, since it can be configured so that schema types and operation types land in separate modules. Running it once per module is possible but wasteful: each run reparses the whole schema and rescans every .graphql file.

For that shape, a product-less .codegen target:

.codegen(
    name: "GQLCodegen",
    script: #"exec "$TUIST_ROOT_DIR/Codegen/apollo.sh""#,
    inputs: [
        .file("//Codegen/schema.graphqls"),
        .glob("//Projects/**/*.graphql"),
        .script("mise x -- apollo-ios-cli --version"),
    ],
    outputs: [
        .output(target: "GQLSchema"),
        .output(target: "GQLTypes"),
        // Cross-project consumers name their project:
        // .output(project: "//Projects/Networking", target: "GQLTransport"),
    ]
)

This calls a script rather than the tool directly, and that is inherent rather than incidental: apollo-ios-cli generate takes a single JSON configuration naming its own output paths, so a wrapper has to reconcile that configuration with the directories Tuist assigned. Any multi-output generator needs equivalent glue unless it happens to accept one output path per output.

GQLSchema and GQLTypes are ordinary targets that declare nothing about codegen, neither the output path nor the dependency. Output goes to Derived/CodeGen/Producers/{producer-name}/{consumer-name}/Sources/ inside the consumer’s project, which is both where the buildable folder is attached and what keeps two same-named consumers in different projects apart. Declaring outputs: is the single source of truth, yielding that path, the buildable folder attached to each consumer, and a dependency edge from each consumer to the producer that orders the build. The producer states the mapping because only it knows its fan-out; with several outputs Tuist cannot infer which directory belongs to which consumer.

A bare target name identifies a consumer in the producer’s own project, where names are unique; .output(project:target:) names one elsewhere. The distinction matters because a target name alone is not a workspace-wide identity.

A multi-target generator receives the same environment as a per-target one, including TUIST_INPUTS_FILE, with TUIST_OUTPUT_SOURCES_DIR replaced. Because there are several output directories, it is told about them through a file rather than one variable per consumer: TUIST_OUTPUTS_FILE points at a JSON array.

[
  { "project": "//Projects/Core", "target": "GQLSchema",
    "path": "/…/Derived/CodeGen/Producers/GQLCodegen/GQLSchema/Sources" },
  { "project": "//Projects/Core", "target": "GQLTypes",
    "path": "/…/Derived/CodeGen/Producers/GQLCodegen/GQLTypes/Sources" }
]

An array rather than an object keyed by target, because target names are not unique across projects: a producer can legitimately feed Models in two different projects, and one JSON key cannot hold both. Carrying project and target as separate fields also avoids inventing an encoded-identity grammar that every generator would then have to parse.

Deriving a variable name per consumer, such as TUIST_OUTPUT_SOURCES_DIR_GQLSchema, fails for a related reason and a simpler one. Shell identifiers admit only alphanumerics and underscore and cannot lead with a digit, while target names can contain spaces, hyphens, and dots, and can begin with a number. Any normalization that maps them into identifiers also merges distinct names: Foo-Bar, Foo.Bar, and Foo_Bar all collapse to one variable, and 123Core needs a prefix that could collide with a target actually called that.

The injected edge has to land in target.dependencies on the model, not only in graph.dependencies: the content hasher walks the former, so a graph-only edge would order the build correctly while leaving consumers’ hashes unmoved when the schema changed. This is the reason the primitive touches the dependency model at all, and the cost of doing so is in Trade-offs.

Relationship to .assets()

I reimplemented Tuist’s .assets() synthesizer with an external tool and compared the generated output. The declared shape matches, including per-catalog namespacing when a target has more than one catalog. The stand-in stops short of the built-in’s full surface, which the fixture’s README enumerates. A case Tuist already handles internally is therefore expressible through the public primitive, which is the bar for the mechanism being general rather than shaped around the generators I happened to need. The built-in remains preferable where it fits, since it needs no tool installed and its template is maintained by Tuist.

Non-Goals

  • Not a replacement for resource synthesizers in this RFC. Built-ins stay as they are: they are deterministic, need no external tool, and have established behaviour. Whether they should eventually be reimplemented on this primitive is worth revisiting once this one’s hashing, materialization, and sandboxing semantics have proven themselves.
  • Sources only. A codegen output directory contributes sources. Generated bundle resources are sketched under Future Directions.
  • No codegen on package-resolved targets. TargetType distinguishes .local from .remote, where remote means resolved and pulled by SwiftPM. Nothing would stop a manifest naming such a target today, which is why it needs to be rejected explicitly: Tuist does not own those manifests, their sources live under a checkouts directory, and their Derived/ is not ours to write into.

Trade-offs

Advantages

  • A generator’s inputs become manifest data, so they land in the target hash with no side files and no second project model.
  • A generator’s own version, templates, and switching variables can be hashed via .script inputs, .file inputs, and environment:.
  • Per-target scoping lets one module adopt a generator without the rest of the graph changing.
  • Generators run only for targets surviving focus, tree-shaking, and cache substitution.
  • No wrapper command: tuist generate on a fresh clone produces a complete project in one pass.

Disadvantages

  • A hand-edit of generated code is invisible to the cache, and the next generate overwrites it. This mirrors foreignBuild, whose hash is foreignBuild-{name}-{script}-{inputsHash} and never the artifact, so editing a built xcframework in place is equally invisible. Accepted deliberately, but “do not patch generated output” becomes a documentation obligation rather than an enforced invariant.
  • The purity contract is unenforced. A generator that reads an undeclared file, or is non-deterministic, gets an incorrect cache hit and nothing detects it. Resource synthesizers avoid this by hashing the bytes they just rendered; this design trades it for not shelling out on tuist hash. Passing resolved inputs narrows the gap, since a generator can consume exactly the declared set, but a command is free to ignore them.
  • A target’s file set is no longer readable from its manifest. Tuist generally avoids that implicitness, though it already makes this exception for resource synthesizers.
  • The multi-target primitive creates a dependency edge no manifest declared, which is new behavior and means the dependency graph is no longer fully described by manifests.

Alternatives Considered

A pre-build TargetScript on each consumer

Scripts belong to a single target, so a generator serving several consumers runs once per consumer. And the script runs at build time, so generated symbols are unavailable until after the first build.

Declaring sources: [.generated(...)] on consumers

Tuist already has a source type for files that do not exist at generation time. It is deliberately narrow: a .generated() entry must be a specific path, glob patterns are rejected outright, and each entry maps to exactly one source file, hashed by its path rather than its contents. That works for a generator with a fixed output list, and not for one whose file set depends on input contents. An Apollo run emits one type per schema type and per operation, so the set changes when a .graphql file is added and cannot be enumerated in the manifest. A directory of generated files, with contents resolved later, is the shape that covers both.

A plain buildable folder, with no generated marker

This compiles, since Xcode resolves a synchronized folder’s contents at build time. Without the marker, though, both decisions that depend on the folder fall back to reading the disk, and its resolved files are globbed at manifest-map time. The target hash then reflects whichever run happened last, so tuist hash and tuist generate disagree; and an app or extension target whose sources are all generated gets no sources build phase. Ruled out on cache correctness, and on producing a project that does not build.

Requiring consumers to declare the dependency on a .codegen target

This needs no new machinery, since a manifest-declared edge is in target.dependencies by construction, and it keeps the graph fully described by manifests. Rejected because correctness would then depend on two declarations agreeing, and the two ways they can disagree are not equally safe. If a consumer declares the dependency and the producer omits it from outputs:, no folder is attached and compilation fails on missing symbols. If the producer lists a consumer that omits the dependency, the folder is attached and the sources compile, but nothing orders codegen before that target’s build. Worth keeping as the fallback if an injected edge is unacceptable, since a build-ordering problem surfaces as a failed build rather than a wrong artifact.

Rollout

Reused as-is: the manifest-relative and // path resolution ForeignBuild.Input goes through, though not its eager expansion, the post-tree-shaking selection point, buildable folder generation, and the extensible set of preserved Derived/ subdirectories.

New work for the per-target primitive, which is shippable on its own:

  1. Add codeGenerators: and .script(name:script:inputs:environment:) to ProjectDescription, along with the environment contract and implicit attachment of the output folder.
  2. A codegen input model retaining each input’s declared root and keeping .glob patterns symbolic rather than expanding them at manifest-map time, plus a CodeGeneratorActionHasher over the components under Hashing.
  3. The generated marker on BuildableFolder, plus the skip for it in target content hashing.
  4. Reading that marker in place of the disk when deciding whether to attach a sources build phase, per Project generation before materialization.
  5. Preserve Derived/CodeGen across generates, paired with a prune of subdirectories no longer matching a target and generator. Preservation makes the action-hash skip meaningful; the prune is needed because preserved directories are otherwise never cleaned, so a deleted target’s output would linger and be compiled again if the name were reused.
  6. The staging, swap, and state-file lifecycle under Incrementality and output lifecycle.
  7. A materialization phase invoked by generate, build, and test, for the reason under Execution model.
  8. Validation. Errors: duplicate generator names on one target, a name unusable as a path component, codegen on a remote target, an input and output that overlap, and a non-zero exit. Warnings: an empty output directory, and a .glob matching nothing.

Then the multi-target primitive: Target.codegen(...), .output(project:target:), the mapper that injects the consumer edge, and two more validation errors, an unknown .output(target:) and two producers claiming one consumer. isAggregate lives in XcodeGraph, which is vendored at cli/Sources/XcodeGraph, so that change lands in the same pull request rather than needing a coordinated package release. isAggregate is currently foreignBuild != nil, so a codegen aggregate would take the native-target branch and force-unwrap a product reference that does not exist.

Cycle detection needs no new machinery: CircularDependencyLinter.lintWorkspace(workspace:projects:) is a self-contained pass over the models, so it can run again after the edge-injecting mapper rather than only before it.

Future Directions

Chaining generators. One generator consuming another’s output is not supported initially. This is the reason the overlap rule above rejects an input inside an output. Both primitives run in a stage with no declared ordering between targets, so which generator runs first is unspecified, and a chained pair would race. The hash would look healthy throughout, because the consumer’s declared input is the producer’s output directory, and a cold run hashes it empty. Adding it means deriving an ordering from the outputs: and inputs: already declared, then running generators in dependency order rather than concurrently.

Generated resources. A resources directory alongside Sources/, with a matching TUIST_OUTPUT_RESOURCES_DIR, reaches three further decisions that read resolved files: targetNeedsBundleSynthesis and targetNeedsSwiftAccessor in ResourcesProjectMapper, and folderLevelPartition in BuildableFolderResourcePartitioner. Declaring what each output directory holds fits, since a directory holding only resources can route to the companion bundle as a whole. .xcassets and .xcstrings need one step further, since each has to appear on both the target and its bundle. A generator would declare the catalog’s name, which is enough because a catalog’s contents vary while the catalog itself stays one named artifact.

Sandboxing. A generator can currently do anything, as foreignBuild and TargetScript can today. An opt-in sandbox restricting writes to the output directory and reads to declared inputs would turn “the declared inputs are the real inputs” from a convention into an invariant, which is the main correctness gap in this proposal. SE-0303 sandboxes plugins by default and is a reasonable model.

References

Here’s a fixture that demonstrates how this API might be used in an actual project: custom-code-generators.zip (41.4 KB)

Hey @hiltonc. First of all, thanks for putting this together. I like the overall direction, particularly the per-target primitive and the decision to introduce the multi-target primitive separately. Having Tuist understand that a command produces sources from declared inputs would remove a meaningful amount of custom infrastructure from larger projects.

There are a few parts of the design that I think need to be specified more precisely before implementation.

Hashing

I do not think we should reuse ForeignBuildHasher for this. It hashes file contents, but it does not preserve the identity of the paths being hashed. That is not sufficient for code generators, which commonly derive declarations and output filenames from input filenames.

For example, renaming an operation file without changing its contents can change the generated type while leaving the current foreign-build hash unchanged. Renaming an asset catalog or color set has the same issue.

I would introduce a dedicated code-generator action hash that includes:

  • The input kind
  • The logical path relative to its declared root
  • The file contents
  • For folders, the relative paths and contents of their complete file trees
  • For globs, the original pattern and the matched logical paths and contents
  • The command
  • The generator name and target identity
  • The declared output mapping
  • Any explicitly declared environment values

Absolute checkout paths should not contribute because that would make hashes differ between machines.

Incrementality and output lifecycle

The proposal refers to an input-hash skip, but I think the state and transition need to be part of the design.

Tuist should persist the generator’s action hash outside the attached output directory. A generator should be skipped only when the stored hash matches, every expected output directory exists, and the previous invocation completed successfully.

When the action hash changes, I think Tuist should provide fresh staging directories, run the generator against them, and replace the final output directories only after the command succeeds. The new action hash should be written last.

This gives us a few useful properties:

  • Files that are no longer produced disappear automatically.
  • A failed generator does not leave a partial output that looks valid.
  • Multi-target generation cannot be skipped because only some of its outputs exist.
  • Generators do not have to implement their own cleanup or hash files.

Execution model

I would avoid executing generators directly from a graph mapper. Graph construction is also used by read-only commands, so direct execution makes it difficult to uphold the promise that those commands do not materialize generated sources.

I would instead introduce an explicit materialization phase:

  • generate, build, and test materialize generator outputs after focus, tree-shaking, and cache substitution.
  • hash, graph, and inspect construct and hash the graph without materializing outputs.
  • Building an existing workspace explicitly requests materialization rather than relying on graph loading to perform it as a side effect.

That keeps selection gating while making execution an intentional command-level decision.

Targets containing only generated sources

There is a project-generation edge case for applications, extensions, command-line tools, and similar products. Tuist currently decides whether to add a sources build phase by inspecting files resolved before the deferred generator runs. If generated files are the target’s only sources, that inspection is empty and some product types receive no sources build phase.

The generated buildable-folder marker should therefore also tell project generation that the target contains potential sources. This behavior should be covered across all supported product types.

Output directory identities

Target and generator names become filesystem path components in the proposed layout. Those values need either a strict validation rule or a stable encoding. Separators, .., case-only differences, and Unicode normalization can otherwise escape the intended hierarchy or cause collisions on common Apple filesystems.

Empty globs

I do not think an empty .glob should be an error. An empty match can be legitimate when a project has no inputs of an optional kind yet. A warning is enough to catch likely mistakes without preventing that use case.

The dedicated input model should preserve the symbolic pattern even when it currently matches nothing. When the first matching file is later added, the matched path set changes and therefore changes the action hash. We could consider an explicit way to silence the warning if optional empty globs become common, but I would not make that necessary for the first version.

Other open questions

I would keep the built-in resource synthesizers separate initially. They are deterministic, require no external tools, and already have established behavior. Once the new primitive has proven its hashing, materialization, and sandboxing semantics, we can evaluate whether sharing implementation would simplify the system without changing the user experience.

For the XcodeGraph change, I would prefer a coordinated package release over a command-line workaround. A product-less code-generation target is a real model concept, and representing it directly is safer than teaching individual generation paths to treat an otherwise native target as a special case.

With those details addressed, I think the proposal has a solid shape. The most important part is treating the generator as a complete action with a well-defined identity and lifecycle, rather than extending the foreign-build existence check to generated source directories.

@marekfort it’d be great to get your input on this one.

Thank you for reviewing this @pepicrft. I think I have addressed all of it, and replaced the RFC above rather than reposting it.

Hashing. ForeignBuildHasher is out of the reuse list; only its inputs-over-outputs principle carries over. The action hash covers:

  • per input, the kind, the logical path relative to its declared root, and the contents
  • for a folder, the relative paths and contents of its whole tree
  • for a glob, the unexpanded pattern plus the matched logical paths and contents
  • the command, the generator name, the declaring target’s identity, and the declared output mapping
  • values declared in environment:, a new parameter on the .script generator case

Absolute checkout paths are excluded. Declared environment values are added to the inherited environment rather than replacing it. PATH stays ambient and unhashed, since hashing it would break cache hits between machines.

Incrementality. Adopted: state under Derived/CodeGen/State/, a fresh staging directory per output, and the swap only after a zero exit.

I cut this to two conditions, by making the recorded hash serve as the third. Tuist deletes it before the first move and writes it after the last, so it is absent for exactly the window in which the output directories are inconsistent. A crash anywhere in the swap therefore leaves no hash and the next run regenerates.

Execution model. Agreed. generate, build and test materialize, after focus, tree-shaking and cache substitution. hash, graph, inspect and cache’s hashing pass do not. Building an already-generated workspace invokes materialization specifically rather than inheriting it from graph loading.

Targets with only generated sources. Confirmed. BuildPhaseGenerator asks BuildableFolderChecker.containsSources, which reads files resolved at manifest-map time. A false answer falls through to the product-type switch, where an empty build-file list drops the phase for .app, .appClip, .appExtension, .commandLineTool, .watch2App and the remaining app and extension products. The generated marker now signals potential sources.

Output directory identities. Strict validation, covering target names as well as generator names: a path separator or a . or .. component is rejected, as are two names sharing a directory that differ only by case or Unicode normalization.

Empty globs and synthesizers. Taken as you described. I did not add a way to silence the empty-glob warning, since you would not make it necessary for the first version.

XcodeGraph. Representing the product-less target in the model, as you prefer, rather than special-casing generation paths. It needs no coordinated release though: XcodeGraph is vendored at cli/Sources/XcodeGraph, so isAggregate changes in the same pull request.

Updated fixture attached, with environment: exercised by a second generator on AccountUI.

custom-code-generators.zip (36.4 KB)