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:
- 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.
- 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.
ForeignBuildHasherfolds aforeignBuild’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.
ForeignBuildSideEffectGraphMapperruns after cache substitution and tree-shaking, so a pruned or cache-hit target never executes its script. - Aggregate targets, generated as
PBXAggregateTarget.isAggregateisforeignBuild != niltoday, 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.
TargetTypedistinguishes.localfrom.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 theirDerived/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
.scriptinputs,.fileinputs, andenvironment:. - 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 generateon 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 isforeignBuild-{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:
- Add
codeGenerators:and.script(name:script:inputs:environment:)toProjectDescription, along with the environment contract and implicit attachment of the output folder. - A codegen input model retaining each input’s declared root and keeping
.globpatterns symbolic rather than expanding them at manifest-map time, plus aCodeGeneratorActionHasherover the components under Hashing. - The generated marker on
BuildableFolder, plus the skip for it in target content hashing. - Reading that marker in place of the disk when deciding whether to attach a sources build phase, per Project generation before materialization.
- Preserve
Derived/CodeGenacross 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. - The staging, swap, and state-file lifecycle under Incrementality and output lifecycle.
- A materialization phase invoked by
generate,build, andtest, for the reason under Execution model. - 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
.globmatching 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
- RFC: External Build System Dependencies: the
foreignBuildmechanism this extends - SE-0303: Package Manager Extensible Build Tools
- Bazel
genrule - Tuist synthesized files