// 988 cases, 6 tools
Conformance
One neutral suite of task-runner behaviours. Each case is a small workspace, run sandboxed against each tool.
Only outcomes count: which commands ran, the files left on disk, the exit code, and the data each tool reports about a run.
The cases come from parsing every test in vx, Turbo, Nx, Vite Task, Nix and moon.
- vx this commit
- Nx 24.0.2
- Turborepo 2.11.10
- moon 2.6.5
- Vite Task 1.4.0
- Nix 2.34.6
- How it works
Scores
Cases passed, of those the tool supports. The bar is every case: its faint part is what the tool supports, so a missing feature reads as missing, not as failures.
Open a group to see its cases: ✓ passes, ✗ fails, – the tool cannot express it. Open a ✗ to see what the tool did instead.
Task graph
149 cases100%149 of 149149 supported83%113 of 149136 supported70%91 of 149130 supported69%84 of 149121 supported74%86 of 149115 supported89%57 of 14964 supported
Which tasks run, in what order, and how many at once. Get it wrong and a build reads files its dependency has not written yet.
| Case | vx | Nx | Turborepo | moon | Vite Task | Nix |
|---|---|---|---|---|---|---|
^build builds every dependency before the project that needs itWhy this is rightA project’s build reads what its dependencies built, so each dependency must finish first. Started early, app concatenates a lib or ui output that is missing or left over from an older build. The run can still exit 0, so the bad bundle ships without anyone seeing an error. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Running one project's task runs what it depends on, and nothing elseWhy this is rightAsking for one project’s task means that task done right, which needs what it depends on built first. Skipping the dependencies builds ui on a missing or stale lib. Running unrelated projects as well spends CI minutes on work nobody asked for and can fail the run on code the user did not touch. | ✓ | ✓ | ✓ | ✓ | ✓ | – |
A bare name in dependsOn waits on the same project’s taskWhy this is rightA bare name in dependsOn points at a task in the same project, the usual way to say test needs build. If the runner ignores it or looks elsewhere, test reads a | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
lib#build in dependsOn runs that task first, with no package dependencyWhy this is rightAn edge like | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A dependency two projects share builds once per runWhy this is rightWhen two projects depend on the same library, one run needs that library built only once. Building it twice wastes time, and a second build can rewrite files while the first consumer is reading them. Each task in a run is one unit of work, however many paths lead to it. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
^build reaches a dependency’s dependency when the middle project has no buildWhy this is rightapp reads lib’s output, reached through mid, which has no build of its own. A project without the task does not cut the chain; its own dependencies still have to be built. Stopping at mid leaves app reading a missing or stale lib, and the build of app still exits 0. | ✓ | ✓ | ✗Turborepo Step 1: exited 1, expected success | ✗moon Step 1: exited 1, expected success | ✗Vite Task Step 1: exited 1, expected success | ✓ |
Running a library’s task does not run the projects that depend on itWhy this is rightDependencies run first because the task needs them; dependents do not, because the task does not read them. Building lib should not rebuild ui and app, which only consume lib. Pulling them in turns a quick library build into a workspace build and can fail it on code the user never asked to touch. | ✓ | ✓ | ✓ | ✓ | ✓ | – |
A task only some projects declare runs in exactly thoseWhy this is rightRunning a task by name runs it where it is declared and nowhere else. A project without that task has nothing to run, and should not be swapped for some other task it does have. Running | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A project whose directory holds spaces builds, caches and restoresWhy this is rightA directory name with spaces is a valid path and must work like any other. A runner that splits paths on spaces misses inputs, writes outputs to the wrong place or fails to restore them. The project then looks cached while its | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A task named test:unit runs as its own taskWhy this is rightA colon is a common part of task names such as | ✓ | ✓ | ✓ | ✗moon Step 1: exited 2, expected success | ✓ | ✓ |
^gen runs the dependencies’ gen first, and not their buildWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
^build over a dependency that declares no build runs the project aloneWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A five-project chain builds strictly from the bottom upWhy this is rightEach project in the chain reads the one below it, so the order must hold at every step, not only the first. If any build starts early it reads a missing or older output, and the error carries silently up to the top. A long chain is where a scheduler that only checks direct edges or races ahead shows it. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Two selected projects over one dependency build it once, before bothWhy this is rightTwo selected projects that share a dependency need it built once, and before either of them. Building it twice wastes time and can rewrite its output while one of them reads it. Starting either project before the shared build finishes gives it a missing or stale input. | ✓ | ✓ | ✓ | ✓ | ✓ | – |
test on build and ^build waits for both the project’s and its dependency’s buildWhy this is rightTwo edges on one task both have to hold: the project’s own build and its dependency’s build. Honouring only one lets test start while the other build is still writing, so it checks a missing or half-written output. The test result then says nothing reliable about the code. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Selecting a project and its dependency runs the dependency once, firstWhy this is rightSelecting a dependency by name does not make it independent of the project that needs it. lib must still build once and before app, as it would if only app were asked for. Running it twice or alongside app wastes time and lets app read a missing or stale lib. | ✓ | ✓ | ✓ | ✓ | ✓ | – |
^build follows a dependency by package name when its directory is named otherwiseWhy this is rightProjects depend on each other by package name, and the directory a package lives in can be named anything. Matching dependencies by directory name drops the edge from app to ui. app then builds before ui, or on an old ui, and the run still exits 0. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
@acme/core#build in dependsOn runs that scoped project’s build firstWhy this is rightScoped names such as | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A project nested in another runs its own task in its own directoryWhy this is rightA project inside another project’s directory is still its own project with its own task. Running it from the outer directory, or folding it into the outer project, writes outputs to the wrong place or skips it. Each task must run where its project lives, or its paths mean something else. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Projects that depend on each other run a task that names no dependencyWhy this is rightPackages that depend on each other form a cycle only for tasks that wait on dependencies. A task that names no dependency has no order to keep, so nothing stops it. Refusing the run would block checks like lint in any workspace with a package cycle, for no reason the tasks give. | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✗Vite Task Step 1: exited 1, expected success | ✓ |
^build across projects that depend on each other fails before any build runsWhy this is rightWhen each build waits on the other, no order is right: whichever runs first builds on a dependency not built yet. Running anyway gives an output that depends on timing and is never reproducible. Failing before any build runs names the cycle so the user can break it. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A project listing itself as a dependency is no cycle: ^build runs its buildWhy this is rightA package cannot be its own dependency, so a self entry in its manifest orders nothing. Treating it as a cycle fails a run that has a clear and valid order. The right reading ignores the self edge and runs the build once. A package that lists itself is a common slip in a manifest, and it should not block every build of that package. | ✓ | ✓ | ✗Turborepo Step 1: exited 1, expected success | ✓ | ✓ | ✓ |
Two projects’ tasks naming each other fail the run before either runsWhy this is rightTwo tasks that each wait on the other can never both run after their dependency. Running them anyway breaks the order the user wrote, so one of them reads a missing or stale output. Failing before either runs points at the cycle instead of producing a build that depends on timing. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A task that depends on itself fails the runWhy this is rightA task cannot run after itself, so a self dependency is a mistake in the config. Running the task anyway hides that mistake and ignores an order the user wrote, perhaps meaning another task. Failing the run makes the bad edge visible while it is cheap to fix. | ✓ | ✗Nx Step 1: succeeded, expected a failure | ✓ | ✗moon Step 1: succeeded, expected a failure | ✓ | ✓ |
A workspace package named only as a peer is built first, and an edit to it runs the consumer againWhy this is rightThe workspace links the peer in, so the consumer’s build reads it as it reads any dependency. Naming it as a peer only changes who installs it outside the workspace. Building the consumer first, or replaying it after the peer changed, leaves it built against code that is no longer there. | ✓ | ✓ | ✗Turborepo Step 1: exited 1, expected success | ✓ | ✗Vite Task Step 1: exited 1, expected success | ✗Nix Step 1: exited 100, expected success |
Selecting a project by name runs its task and no other project’sWhy this is rightSelecting a project by name is the most basic way to say what a run should do. Running other projects as well spends time on work nobody asked for and can fail the run on code the user did not touch. Running fewer drops the one task the user wanted checked. | ✓ | ✓ | ✓ | ✓ | ✓ | – |
A * in a name selects every project it matchesWhy this is rightA | ✓ | ✓ | ✓ | ✓ | ✓ | – |
A name matches whole: foo never selects foo-e2eWhy this is rightA name without a wildcard must match a project name in full. Matching it as a prefix makes | ✓ | ✓ | ✓ | ✓ | ✓ | – |
A . in a name is a dot: socket.io never selects socketxioWhy this is rightPackage names often contain dots, and a dot in a selector means a dot. Reading it as a pattern that matches any character selects | ✓ | ✓ | ✓ | ✓ | ✓ | – |
Two selectors run the union of what each selectsWhy this is rightSeveral selectors each add projects to the run, so the result is everything any of them selects. Keeping only the last one, or only the projects all of them match, silently drops work the user asked for. CI then reports green for checks that never ran. | ✓ | ✓ | ✓ | ✓ | ✓ | – |
Selecting a project with its dependencies takes every transitive oneWhy this is rightSelecting a project with its dependencies means the whole chain below it, not only the direct ones. Stopping at ui leaves utils out, though app builds on it through ui. Unrelated projects like lib must stay out, or the selection means nothing. | ✓ | – | ✗Turborepo Step 1: did not run ui#check, utils#check | – | ✓ | – |
Selecting a project with its dependents takes every transitive oneWhy this is rightSelecting a project with its dependents means everything that could break when it changes, at any depth. app uses utils only through ui, but a change to utils still reaches it. Leaving app out lets a breaking change pass the check that was meant to catch it. | ✓ | – | ✗Turborepo Step 1: did not run ui#check, app#check | – | ✓ | – |
Selecting only a project’s dependents leaves the project outWhy this is rightAsking for only the dependents is a way to check what a change could break without rerunning the project itself. Including utils runs work the user excluded on purpose. Dropping the transitive app misses a project the change can still break. | ✓ | – | ✗Turborepo Step 1: did not run ui#check, app#check | – | ✓ | – |
Selecting only a project’s dependencies leaves the project outWhy this is rightAsking for only the dependencies prepares everything a project builds on, without the project itself. This is common before working on app locally. Including app runs what the user left out, and stopping at ui leaves utils, which ui needs, unprepared. | ✓ | – | ✗Turborepo Step 1: did not run ui#check, utils#check | – | ✓ | – |
Dependents with their dependencies takes what each dependent builds onWhy this is rightTo test the dependents of ui, each dependent needs everything it builds on, not only ui. app needs kit as much as ui, so leaving kit out runs app against a missing or stale dependency. lib is unrelated and must stay out, or the selection grows to the whole workspace. | ✓ | – | ✗Turborepo Step 1: did not run app#check, kit#check, utils#check | – | ✓ | – |
An exclude alone selects every project but the one it namesWhy this is rightAn exclude on its own means everything except that project. Treating it as an empty selection runs nothing and lets CI pass having checked nothing. Treating it as a no-op runs the project the user asked to skip, which may be slow or broken for known reasons. | ✓ | ✓ | ✓ | ✓ | ✓ | – |
* and an exclude select every project but that oneWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | – |
An exclude given before an include still removes what the include addsWhy this is rightAn exclude names a project the user does not want run, wherever it appears on the command line. Applying selectors in order, so a later include adds utils back, makes the result depend on argument order. Scripts and CI configs build these lists in any order and expect the same set. | ✓ | – | ✗Turborepo Step 1: did not run ui#check | – | ✓ | – |
Excluding a project with its dependencies removes them allWhy this is rightExcluding a project with its dependencies removes the project and everything below it. Removing only ui leaves utils in the run, though the user excluded the whole subtree. The rest of the workspace, app and lib here, must still run. This is how a user leaves out a whole stack, such as a library and its private helpers. | ✓ | – | ✗Turborepo Step 1: ran utils#check, expected it replayed or left out | – | ✓ | – |
* selects scoped and unscoped projects alikeWhy this is right
| ✓ | ✓ | ✓ | ✗moon Step 1: did not run @acme/core#check, @acme/utils#check | ✗Vite Task Step 1: did not run @acme/core#check, @acme/utils#check | – |
@acme/* selects exactly that scopeWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | – |
A glob matches across the scope’s /: *core* selects @acme/coreWhy this is rightUsers think of a scoped package by its name, and | ✓ | ✗Nx Step 1: did not run @acme/core#check | ✓ | ✗moon Step 1: exited 1, expected success | ✗Vite Task Step 1: did not run @acme/core#check | – |
Excluding *utils takes out the scoped @acme/utilsWhy this is rightAn exclude pattern must match the same names an include would. If | ✓ | ✗Nx Step 1: ran @acme/utils#check, expected it replayed or left out | ✓ | ✗moon Step 1: ran @acme/utils#check, expected it replayed or left out | ✗Vite Task Step 1: ran @acme/utils#check, expected it replayed or left out | – |
A bare core selects @acme/core when only that package carries the nameWhy this is rightWhen only one package is named | ✓ | ✓ | ✗Turborepo Step 1: exited 1, expected success | ✗moon Step 1: exited 1, expected success | ✓ | – |
A directory glob selects the projects directly under itWhy this is rightA directory glob such as | ✓ | ✓ | ✓ | ✓ | ✓ | – |
A ** directory glob reaches a project nested in anotherWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | – |
A directory that is a project selects that project, not one nested in itWhy this is rightNaming a project by its path means that project. The project nested below it has a path of its own and is not part of it. Pulling it in runs work the user did not ask for, and makes a single project impossible to select by directory. Directory selectors exist so a user can point at exactly one folder. | ✓ | ✓ | ✓ | ✓ | ✓ | – |
A directory does not select a sibling that shares its prefixWhy this is rightA directory names a path, and | ✓ | ✓ | ✓ | ✓ | ✓ | – |
A directory glob minus one directory selects every other project thereWhy this is rightA directory exclude must remove a project selected by a directory glob, just as name excludes do. Skipping docs while checking the other apps is a common need. If the exclude is ignored, the project the user wanted left out runs and may fail the run for them. | ✓ | ✓ | ✓ | ✓ | ✓ | – |
A name and a directory select the unionWhy this is rightSelectors by name and by directory may be mixed, and each adds its projects to the run. Keeping only one kind silently drops what the other selected. Projects neither selector names must stay out. Users mix the two when one project is easier to name and another easier to point at by path. | ✓ | ✓ | ✓ | ✓ | ✓ | – |
Selecting by name in a package graph with a cycle runs that project aloneWhy this is rightA cycle among packages does not change what selecting one project by name means. Only that project runs, since its task depends on nothing. Failing the run or pulling in the whole cycle punishes a workspace for a shape that does not affect this task. | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✓ | – |
Walking a package cycle takes each project in it onceWhy this is rightA walk over dependencies in a package cycle reaches each project in the cycle. It must take each one once and stop, not loop or fail. Unrelated projects stay out, so the run is the cycle and nothing more. Cycles do occur in real workspaces, often through test or tooling packages, and must not crash a selection. | ✓ | – | ✗Turborepo Step 1: did not run a#check, c#check | – | ✗Vite Task Step 1: exited 1, expected success | – |
A project whose task names another project’s task counts as its dependentWhy this is rightA task that names another project’s task depends on that project, even if no package manifest says so. e2e needs app’s check, so it is a dependent of app. Leaving it out of the dependents of app skips the project that tests app, and a breaking change passes. | ✓ | – | ✓ | – | ✗Vite Task Step 1: did not run e2e#check | – |
A selector that matches no project fails the runWhy this is rightA selector that matches nothing is almost always a typo or a renamed package. If the run exits 0, CI passes having checked nothing, and no one notices until something broken ships. Failing the run names the selector that went wrong, while it is cheap to fix. | ✓ | ✗Nx Step 1: succeeded, expected a failure | ✓ | ✓ | ✗Vite Task Step 1: succeeded, expected a failure | – |
A tag selects exactly the projects carrying itWhy this is rightA tag is a label the user put on projects so they can be picked as a group, such as every service or every library. Selecting by it must take exactly those projects. Taking more runs work nobody asked for; taking fewer skips a project the user believes was built or tested. | ✓ | ✓ | – | ✗moon Step 1: exited 1, expected success | – | – |
A * in a tag selects the projects carrying any tag it matchesWhy this is rightA pattern in a tag picks every tag it matches, as a pattern in a name picks every name. Users group tags by prefix, such as scope:web and scope:api, to select a family at once. Matching only the literal text selects nothing, so the run passes while it built none of them. | ✓ | ✓ | – | ✗moon Step 1: exited 1, expected success | – | – |
A tag excluded takes the projects carrying it out of the selectionWhy this is rightExcluding a tag says to leave out every project carrying it, such as slow end-to-end suites. The rest of the selection must still run. A tool that ignores the exclusion runs the work the user just opted out of; one that drops too much skips projects the user expects checked. | ✓ | ✓ | – | ✗moon Step 1: exited 1, expected success | – | – |
A tag and its dependencies select the tagged projects and what they depend onWhy this is rightSelecting a tag with its dependencies means the tagged projects and everything they need to build. A tagged app cannot build without its libraries, even untagged ones. Leaving them out fails the run or builds against stale output; adding unrelated projects runs work nobody asked for. | ✓ | – | – | – | – | – |
Skipping dependencies runs the selected task and not what it depends onWhy this is rightSkipping dependencies is an explicit request to run just the selected task against what is already on disk. It saves time when the user knows the dependencies are current, such as rerunning one app after a full build. Running lib anyway ignores the flag, and not running app at all drops the work that was asked for. | ✓ | ✓ | ✓ | ✓ | ✓ | – |
A task the full run built replays when its dependencies are skippedWhy this is rightSkipping a dependency changes the schedule, not the task: app reads the same lib output and sources as in the full run. Its result is already cached, so it should replay. Rebuilding it just because the dependency was skipped wastes the cache exactly when the user is trying to save time. | ✓ | ✓ | ✓ | ✗moon Step 2: ran app#build, expected it replayed or left out | ✓ | – |
With dependencies skipped, a dependency rebuilt since still reruns its dependantWhy this is rightapp reads lib’s output, so a new lib must give app a new cache key even when lib is not scheduled. Here lib was rebuilt on its own since app last ran. A key that leaves out the skipped lib replays app built on the old lib, and the stale bundle ships under a green run. | ✓ | ✓ | ✓ | ✗moon Step 5: did not run app#build | ✗Vite Task Step 5: did not run app#build | – |
With dependencies skipped, a change two dependencies down still reruns the dependantWhy this is rightbase reaches app through lib’s output, so a change to base must change app’s key even with both skipped. The key has to fold the skipped lib and everything lib itself folds. Stopping one level down replays app with the old base inside it, which no error will ever point out. | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 4: did not run app#build | – |
What a run builds on a skipped, out-of-date dependency never replays in a full runWhy this is rightWhen dependencies are skipped, app may build on a lib output older than lib’s sources. That result is valid for this run only and must not be stored under the key a full run will look up. Otherwise the next full build rebuilds lib but replays app with the old lib baked in. | ✓ | ✗Nx Step 4: packages/app/dist/out.txt is "lib\napp\n", expected "lib2\napp\n" | ✗Turborepo Step 3: ran lib#build, expected it replayed or left out | ✓ | ✗Vite Task Step 4: packages/app/dist/out.txt is "lib\napp\n", expected "lib2\napp\n" | – |
Skipping dependencies with a dependency also selected replays bothWhy this is rightSelecting app and lib with dependencies skipped asks for both, and nothing either reads has changed since the full run. Both should replay from cache. Rebuilding either one only because the skipped base was left out of the schedule wastes the time the flag exists to save. | ✓ | ✓ | ✓ | ✗moon Step 2: ran app#build, lib#build, expected it replayed or left out | ✓ | – |
Two selected tasks keep their order when the task between them is skippedWhy this is rightapp builds on base through lib, so when both base and app are selected, base must finish first even though lib between them is skipped. Run side by side, app reads whatever base left on disk last time, or a half-written file. Skipping a task drops it from the run; it does not drop the order between the tasks that remain. | ✓ | ✗Nx Step 3: app#build started before base#build finished | ✗Turborepo Step 3: ran lib#build, expected it replayed or left out | ✗moon Step 3: app#build started before base#build finished | ✗Vite Task Step 3: app#build started before base#build finished | – |
lint.* in dependsOn runs every task it matches and none it does notWhy this is rightA pattern in dependsOn promises that every task it matches runs first, and only those. Missing one match lets the dependant run before a check it was meant to wait for, so a failing lint step can pass unseen. Pulling in a task the pattern does not match, like | ✓ | ✓ | – | – | – | – |
^build.* waits for every matching task of each dependencyWhy this is right
| ✓ | ✓ | – | – | – | – |
A task on build.* reruns when a matched task’s input changesWhy this is rightA task that depends on a pattern depends on what each matched task produced. | ✓ | ✗Nx Step 3: did not run app#top | – | – | – | – |
At a concurrency of 1, independent tasks run one at a timeWhy this is rightA concurrency of 1 is how a user says tasks must not overlap, often because they share a port, a database or a lock file. Running two at once anyway breaks that promise and gives failures that come and go with timing. The limit has to hold exactly, not on average. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
At a concurrency of 2, no more than two tasks run at onceWhy this is rightThe concurrency limit caps how many tasks run at once, so a run fits the machine’s memory and cores. Going over it can run a CI machine out of memory or slow every task down. The cap must hold at every moment, including when one task ends and the next ones start. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Tasks that fail still count against the concurrency limit while they runWhy this is rightA failing task is still a process using its slot until it exits. Freeing the slot when the failure is first noticed, or starting the next task early, lets more tasks run than the limit allows. The limit exists for the shared resources, and those are still in use until the process is gone. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A concurrency far above the number of tasks runs them all and endsWhy this is rightA concurrency higher than the number of tasks simply means no limit in practice. Every task should run and the run should end normally. Hanging while waiting for work that will never come, or refusing the setting, breaks a common choice such as a large fixed value in CI. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A task behind a replayed task still waits for what the replayed one depends onWhy this is rightapp’s test reads lib’s output as well as app’s, so it must wait for lib’s task, not only for app’s build. When app’s build replays from cache it finishes at once, but that says nothing about lib being done. Starting the test then makes it read lib’s old output, and it passes or fails on the wrong code. | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: exited 100, expected success |
A server starts when one of the tasks that need it must run, though another replaysWhy this is rightWhen any task that needs a server has to run, the server must start for it. Here web’s test replays from cache, but docs changed and its test must run against the server. Skipping the server because one dependant replayed leaves docs testing against nothing, so it hangs or fails for no reason in its own code. | ✓ | ✓ | ✗Turborepo Step 1: exited 1, expected success | ✗moon Step 1: did not finish within 30 s | – | – |
A project without the requested task runs none of the tasks it would needWhy this is rightA task’s dependencies run only because that task needs them. docs declares no verify, so nothing in this run needs docs’ build. Running it anyway wastes time and can fail the run on a project that had no part in what was asked. Only tasks reachable from the ones requested belong in the run. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Two tasks in one run each run where declared, though one project lacks oneWhy this is rightAsking for two tasks runs each one where it is declared. b has no lint, which is no reason to skip b’s build or to fail the run. Each requested task is looked up on its own; unrelated tasks such as check must not run. Projects rarely declare the same set of tasks, so this mix is the normal case. | ✓ | ✓ | ✓ | ✓ | – | ✓ |
Two tasks one project declares both run in one runWhy this is rightNaming two tasks in one run is a request for both. Running only the first, or only the last, silently drops work the user asked for. CI scripts often combine tasks this way and read the exit code as covering all of them. Every named task has to be found and run, or the run must fail. | ✓ | ✓ | ✓ | ✓ | – | ✓ |
Excluding one member keeps the workspace-root project’s taskWhy this is rightThe exclude names b alone, and the workspace root is a project like any other. Dropping the root along with the excluded member removes checks the user never asked to skip. Root level tasks often guard shared tooling, so losing them hides real failures. | ✓ | ✓ | ✓ | ✓ | ✓ | – |
Selecting the workspace-root project by name runs its task and not its members’Why this is rightThe members live inside the root’s directory, but each is a project of its own. Selecting the root by name means the root’s task only. Treating the root as containing its members runs every project in the workspace when one was asked for. Scope is by project, not by folder nesting. | ✓ | ✓ | ✗Turborepo Step 1: exited 1, expected success | ✓ | ✓ | – |
A selection its excludes leave empty, beside a selector matching nothing, fails the runWhy this is rightHere nothing is left to run, and one selector matches no project at all. That selector is almost always a typo, and exiting 0 lets CI pass having checked nothing. A selection the user empties on purpose with excludes is different, but the unmatched name is still an error to report. | ✓ | ✗Nx Step 1: succeeded, expected a failure | ✓ | ✓ | ✗Vite Task Step 1: succeeded, expected a failure | – |
Projects under a dot directory, under underscored names, and two of one base name each runWhy this is rightEvery listed project must be found and run, wherever it lives. Skipping directories that start with a dot or look like generated code drops real projects silently. Two projects in folders with the same base name are still two projects, and merging them loses one. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A project whose directory holds spaces and parentheses runs there, caches and restoresWhy this is rightSpaces and parentheses are valid in directory names, and tasks must run in their own directory with their files found and restored. A runner that passes such paths through a shell unquoted breaks the command or writes outputs elsewhere. The cache must also restore into the same odd path, or a hit leaves | ✓ | ✗Nx Step 3: apps/my app (1)/dist/out.txt is missing, expected "v1\n" | ✗Turborepo Step 1: exited 1, expected success | ✗moon Step 1: exited 1, expected success | ✗Vite Task Step 1: exited 1, expected success | ✓ |
Selecting a project’s dependencies follows a task it names in another projectWhy this is righte2e’s test names app’s build, so app is one of e2e’s dependencies even though no manifest says so. lib is app’s dependency, so it counts too. Following only package manifests finds none of these, and the user gets an empty run instead of the projects e2e builds on. | ✓ | – | ✗Turborepo Step 1: did not run app#test, lib#test | – | ✗Vite Task Step 1: did not run app#test, lib#test | – |
A project’s dependents reach through a package dependency and then a task another project namesWhy this is rightA change to lib reaches app through the package graph, and reaches e2e because e2e’s test names app’s build. Dependents must be followed across both kinds of edge, in any order. Stopping at the first kind leaves e2e untested on a change that can break it. | ✓ | – | ✗Turborepo Step 1: did not run app#test, e2e#test, app#build | – | ✗Vite Task Step 1: did not run e2e#test, app#build | – |
With dependencies skipped, a test behind an unchanged server replays and no server startsWhy this is rightNothing the test or its server reads changed since the full run, so the test’s cached result is still valid. With dependencies skipped, it should replay and no server should start. Starting the server anyway costs its whole start up time for a test that will not run. | ✓ | ✗Nx Step 2: ran web#e2e, expected it replayed or left out | ✗Turborepo Step 1: exited 1, expected success | ✗moon Step 1: did not finish within 30 s | – | – |
With dependencies skipped, each selected task reruns on any of its rebuilt dependenciesWhy this is rightA task’s key must cover every dependency it reads, skipped or not. app reads both lib and base, and top reads tool, so a new base or tool must rerun them. Keying on the first dependency only replays app or top built on the old one, and nothing in the run shows it. | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 4: did not run app#build, top#build | – |
Args forwarded to a run reach a requested server’s commandWhy this is rightArguments after | ✓ | ✓ | ✗Turborepo Step 1: did not run app#dev, app#smoke | ✓ | – | – |
Args forwarded to a run reach a requested server that prints a ready lineWhy this is rightA server the run waits on until it prints a ready line is started like any other task. Its forwarded arguments must reach it, so | ✓ | ✓ | – | – | – | – |
Args forwarded to a server reach it as typed, never expanded by a shellWhy this is rightThe user’s own shell already parsed and quoted the arguments, so each must arrive as one literal string. Passing them through a second shell expands | ✓ | ✗Nx Step 1: packages/app/seen.txt is "<0><0></tmp/conf-nx-DfF0vG/.home><a b>", expected "<$(id -u)><`id -u`><$HOME><a b>" | ✗Turborepo Step 1: did not run app#dev, app#smoke | ✓ | – | – |
A dependsOn edge added to a task orders and keys it: an edit to the dependency then runs it againWhy this is rightAdding a dependsOn edge says the task now reads what the dependency produces. From then on the dependency must run first, and its changes must reach the task’s key. A tool that orders the new edge but keys only on the old config replays a stale result after the dependency changes. | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 5: did not run app#build | ✓ |
A member’s task that depends on a workspace-root task runs after itWhy this is rightA member’s task may depend on a task of the workspace-root project, such as code generation over shared tools. That dependency must be honoured like any other edge, with the root task finishing first. Running the member first reads a missing or stale generated file, or races the generator. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Selecting a project without the task, with its dependencies, runs the task in those dependenciesWhy this is rightThe selection is mid and everything below it, and the task exists in lib, so lib’s build is what was asked for. mid lacking the task removes only mid from the run, not the projects reached through it. Failing, or running nothing, leaves lib unbuilt for the user who wanted mid’s dependencies ready, and app above the selection must stay out. | ✓ | – | ✗Turborepo Step 1: did not run lib#build | – | ✓ | – |
A dependency named in both dependencies and devDependencies is built once, before its dependantWhy this is rightListing a package in two dependency fields of one manifest still makes it one dependency. | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: exited 100, expected success |
Run from inside a project, its dependencies’ tasks still run firstWhy this is rightA task’s dependencies are part of the task, wherever the run starts. Developers often run a task from inside the project they work on. If the runner skipped the dependencies there, the task would build against a missing or old dependency output and fail or come out wrong, while the same command from the root works. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A workspace package linked under another name is a dependency: its build runs firstWhy this is right
| ✓ | ✗Nx Step 1: exited 1, expected success | ✗Turborepo Step 1: a#build started before b#build finished | ✗moon Step 1: exited 1, expected success | ✗Vite Task Step 1: a#build started before b#build finished | ✗Nix Step 1: exited 100, expected success |
A workspace package the root depends on builds before a project that reads it through the rootWhy this is rightThe root installs a workspace package where every project resolves it, whatever that project’s own manifest says. Here | ✓ | ✗Nx Step 1: exited 1, expected success | ✗Turborepo Step 1: exited 1, expected success | ✗moon Step 1: exited 1, expected success | ✗Vite Task Step 1: exited 1, expected success | ✗Nix Step 1: exited 100, expected success |
A package the root depends on that itself depends on another workspace forms no cycleWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: did not run util#build |
^build runs the dependency’s build, not the builds that build itself never namesWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
^build reaching one build through two projects without a build runs it onceWhy this is right
| ✓ | ✓ | ✗Turborepo Step 1: did not run shared#build | ✗moon Step 1: did not run shared#build | ✗Vite Task Step 1: did not run shared#build | ✓ |
Two tasks of one project on ^build each wait for the dependency’s buildWhy this is rightEach task that lists | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Each entry of a dependsOn list runs first: three of the project’s, two ^ namesWhy this is rightEvery entry in a dependsOn list is a separate requirement. A runner that honours only the first entry, or lets one | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
^test and ^lint through a project with no tasks reach each project beyond that declares themWhy this is rightA project that declares no tasks still sits in the package graph. | ✓ | ✓ | ✗Turborepo Step 1: did not run lib2#test, lib2#lint, lib3#lint | ✗moon Step 1: did not run lib2#test, lib2#lint, lib3#lint | ✗Vite Task Step 1: did not run lib2#test, lib2#lint, lib3#lint | ✓ |
^build where only a project off the dependency path declares build runs the task aloneWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A task that depends on the requested builds is not run with themWhy this is rightA run builds what was asked for and what that needs, not what happens to depend on it. A task that depends on the build tree is a consumer of the builds, not part of them. Running it too adds work, and possibly side effects such as a deploy, that the user did not request. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
dependsOn naming another project’s task that does not exist fails before any runsWhy this is rightA dependsOn entry is a promise that the named task runs first. If that task does not exist, the promise cannot be kept, and dropping the edge in silence runs the task without what it said it needs. Failing before anything runs points at the typo or the renamed task. | ✓ | ✗Nx Step 1: succeeded, expected a failure | ✓ | ✓ | ✓ | ✓ |
dependsOn naming a task its own project lacks fails before any runsWhy this is rightA same-project name the project does not declare, such as | ✓ | ✗Nx Step 1: succeeded, expected a failure | ✓ | ✓ | ✓ | ✓ |
^biuld, a name no project declares, fails the run instead of passing with no edgeWhy this is rightA | ✓ | ✓ | ✓ | ✗moon Step 1: succeeded, expected a failure | ✗Vite Task Step 1: succeeded, expected a failure | ✓ |
^build around a ring of four projects fails before any build runsWhy this is rightA ring of | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A ^build ring that passes through a project without a build still failsWhy this is right
| ✓ | ✗Nx Step 1: succeeded, expected a failure | ✗Turborepo Step 1: succeeded, expected a failure | ✓ | ✗Vite Task Step 1: succeeded, expected a failure | ✓ |
A package ring whose other projects declare no build is no task cycle: ^build runs the buildWhy this is rightA cycle in the package graph is not a cycle in the task graph unless tasks close it. Here walking past the projects without a build leads back to the project that asked, and a task is never its own dependency. Refusing would break a valid repo over a package loop that holds no task cycle. | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✗Vite Task Step 1: exited 1, expected success | ✓ |
A package cycle between a project with a build and one without orders and runsWhy this is rightapp1’s build reaches app3’s build through app2, which has none. app3’s own walk back through app2 meets only itself, so there is no task cycle. The builds have a valid order and run; refusing them would fail a repo over a package loop its tasks never close. | ✓ | ✓ | ✗Turborepo Step 1: did not run app3#build | ✗moon Step 1: exited 1, expected success | ✗Vite Task Step 1: did not run app3#build | ✓ |
A cycle the requested task only leads into fails the run before any task runsWhy this is rightA cycle anywhere below the requested task makes its graph impossible to order, even if the task itself is not in the cycle. Starting what can start would leave the run stuck, or run tasks before their inputs exist. Failing before any task runs names the cycle so it can be fixed. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
build.* on a task named build.all runs the other matches, not itselfWhy this is rightA pattern like | ✓ | ✓ | – | – | – | – |
A dependsOn pattern that matches no task runs the task aloneWhy this is rightA pattern names a family of tasks that may be empty in a given project. A shared preset can list | ✓ | ✓ | – | – | – | – |
A ^ pattern no project’s task matches runs the task aloneWhy this is rightA | ✓ | ✓ | – | – | – | – |
^build.* runs every match of the nearest dependency holding one, and stops thereWhy this is right
| ✓ | ✓ | – | – | – | – |
^build.* passes through a dependency with no match to the one beyond itWhy this is rightA dependency with no matching task is a step on the way, not the end of the walk. | ✓ | ✓ | – | – | – | – |
^build.* across a package cycle does not wrap back to the asking project’s own tasksWhy this is right
| ✓ | ✗Nx Step 1: ran a#build.inner, expected it replayed or left out | – | – | – | – |
The . in build.x* is a dot: it never matches buildAx1Why this is rightIn a task pattern the | ✓ | ✓ | – | – | – | – |
A task named both exactly and by a pattern in one dependsOn runs onceWhy this is rightA task named twice in one dependsOn, once exactly and once by a pattern, is still one task. It runs once, before the task that needs it. Running it twice wastes time and lets two copies race on the same outputs, so the result depends on which copy finishes last. | ✓ | ✓ | – | – | – | – |
A task named like a route, t--src/app/[id]/page, is one name in dependsOnWhy this is rightAn exact name in dependsOn is matched as written, whatever characters it holds. Generated task names often follow file routes and contain | ✓ | ✗Nx Step 1: did not run app#t--src/app/[id]/page | ✓ | ✗moon Step 1: exited 1, expected success | ✓ | ✓ |
In t--src/app/[id]/* the brackets are characters, not a classWhy this is rightOnly | ✓ | ✗Nx Step 1: did not run app#t--src/app/[id]/page | – | – | – | – |
A task the config names constructor runs like any otherWhy this is rightA task name is just a string, and | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
^build:prod runs the dependency’s build:prod first and reruns on its changeWhy this is rightNames with colons, like | ✓ | ✓ | ✓ | ✗moon Step 1: exited 2, expected success | ✗Vite Task Step 3: did not run a#build:prod | ✓ |
A task named a:b:c runs as one name, not as a with more after itWhy this is rightSome runners read a colon as a separator, such as between project and target. A task named | ✓ | ✓ | ✓ | ✗moon Step 1: exited 2, expected success | ✓ | ✓ |
A git worktree checked out inside the workspace adds no project to a runWhy this is rightA git worktree checked out inside the workspace holds copies of the same projects under the same names. Those copies are another checkout, not part of this workspace. Read as projects, they clash on names or build everything twice, and files from the other branch mix into this run. | ✓ | ✗Nx Step 2: exited 1, expected success | ✓ | ✓ | ✓ | ✓ |
A concurrency of 1 runs independent tasks one at a timeWhy this is rightA concurrency of 1 is how a user says tasks must not overlap, often because they share a port, a database or a lot of memory. Independent tasks then run one at a time. Running two at once breaks that promise and causes failures that depend on timing. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A directory selector selects the same projects from a subdirectory as from the rootWhy this is rightA directory selector names paths in the workspace, so it means the same thing wherever the command runs. A runner that reads the path from the current directory, or adds the project it was started in, selects different projects by cwd. The same command then gives different results in a script and in a terminal. | ✓ | ✓ | ✗Turborepo Step 2: exited 1, expected success | ✓ | ✗Vite Task Step 1: ran apps#lint, expected it replayed or left out | – |
A dev server starts after its dependencies’ builds and runs until Ctrl-C, which ends itWhy this is rightA dev server serves what its dependencies built, so it starts only after those builds finish. It then runs until the user stops it, because a server has no natural end. Ctrl-C ends it, and the run with it, so the port is free for the next start. | ✓ | ✓ | ✓ | ✓ | – | – |
A task that needs a server runs while the server is up, and the run ends when the task doesWhy this is rightDepending on a server means the task needs it running, as an e2e suite needs a dev server. The server stays up while the task runs, and the run ends when the task does, since nothing else needs the server. Killing the server when the dependant starts, or refusing the edge, leaves no way to express the most common use of a server. | ✓ | ✓ | ✗Turborepo Step 1: exited 1, expected success | ✗moon Step 1: did not finish within 30 s | – | – |
A server the run no longer needs is asked to stop politely, so its cleanup runsWhy this is rightWhen a run no longer needs a server, the server should be asked to stop with a catchable signal before anything harsher. That gives it the chance to remove a lock file, flush its log and release its port. A server killed outright with SIGKILL leaves that state behind, and the next start may refuse to run. | ✓ | ✗Nx Step 1: packages/app/cleaned.txt is missing, expected "cleaned\n" | ✗Turborepo Step 1: exited 1, expected success | ✗moon Step 1: did not finish within 30 s | – | – |
A server that ignores SIGTERM does not keep a finished run from exitingWhy this is rightA finished run must exit even if a server it started will not stop politely. A server that ignores SIGTERM would otherwise hold the run open after its work is done. After a grace period the runner kills it, so CI moves on and no server is left behind. | ✓ | ✓ | ✗Turborepo Step 1: exited 1, expected success | ✗moon Step 1: did not finish within 30 s | – | – |
A server’s own child that ignores SIGTERM does not keep a finished run from exitingWhy this is rightA run is not over until every process it started has ended. With | ✓ | ✓ | ✗Turborepo Step 1: exited 1, expected success | ✗moon Step 1: did not finish within 30 s | – | – |
At a concurrency of 1 a server’s dependant still runsWhy this is rightA server never exits on its own, so it cannot hold a concurrency slot the way a build does. A scheduler that keeps the only slot for the server until it ends never starts the task that needs it, and the run hangs. Servers run beside the limit so their dependants can still run within it. | ✓ | ✓ | ✗Turborepo Step 1: exited 1, expected success | ✗moon Step 1: did not finish within 30 s | – | – |
At a concurrency of 1 the tasks behind a running server still run one at a timeWhy this is rightA concurrency limit is often set to protect a machine or a shared resource, such as a database the tests use. Once a server is up, its dependants must still run within that limit, one at a time at a concurrency of 1. Releasing them all together breaks the limit and can make tests that share state fail. | ✓ | ✓ | ✗Turborepo Step 1: exited 1, expected success | ✗moon Step 1: did not finish within 30 s | – | – |
A server that depends on another server starts after it, and both stay up while a task needs themWhy this is rightAn app’s dev server on top of a library’s watcher is a common setup. The app’s server must start after the server it depends on, and both must stay up while a task uses them. Refusing the edge, or starting the app before its upstream, leaves the developer starting the two by hand. | ✓ | ✓ | ✗Turborepo Step 1: exited 1, expected success | ✗moon Step 1: did not finish within 30 s | – | – |
Every server a run selects starts, at a concurrency below their countWhy this is rightServers never finish, so they cannot wait in line for a concurrency slot that will never free up. If they count against the limit, some of the servers a filter selects never start, and the app that needs them waits forever. Every selected server starts, whatever the limit. | ✓ | ✓ | ✗Turborepo Step 1: did not run p1#dev, p2#dev, p3#dev, p4#dev, p5#dev, front#dev | ✓ | – | – |
A task that needs a server starts only once the server prints its ready lineWhy this is rightA server is useful to its dependants only once it accepts connections, and the ready line is how it says so. Starting the dependant as soon as the process spawns sends e2e tests to a port that is not open yet. That gives failures that come and go with machine speed, the worst kind to debug in CI. | ✓ | ✓ | – | – | – | – |
A server-style task that finishes with exit 0 before its ready line lets its dependants runWhy this is rightA task that exits 0 before its ready line did its work and ended cleanly. Some setup steps, like seeding a database, are declared like a server but finish on their own. There is no server left to wait for and nothing failed, so its dependants run; failing or hanging would punish a task that succeeded. | ✓ | ✗Nx Step 1: exited 1, expected success | – | – | – | – |
An exclude alone keeps the workspace-root project, as it keeps every other memberWhy this is rightAn exclude filter removes what it names and keeps everything else. The workspace-root project is a project like any other, so excluding | ✓ | ✓ | ✓ | ✓ | ✓ | – |
Running a task in a named project that does not declare it failsWhy this is rightNaming a project and a task is an explicit request. If that project does not declare the task, nothing can satisfy it, and the run must fail. Exiting 0 after running nothing hides a typo in the task or project name, and CI reports a check that never ran. | ✓ | ✗Nx Step 1: succeeded, expected a failure | ✗Turborepo Step 1: succeeded, expected a failure | ✓ | ✓ | – |
Running a task in named projects runs it where declared; one that lacks it is no errorWhy this is rightAcross several named projects, the task runs where it is declared; one project lacking it is normal in a mixed workspace. Failing the whole run for that forces every project to declare every task. The run fails only when the task exists nowhere in scope. | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✓ | – |
Two tasks with no edge between them run at the same timeWhy this is rightTasks with no dependency between them can run side by side, and a run that has cores to spare should use them. Here each task waits a while for the other to start, so run one after the other they each wait out the full delay. Running independent work in turn makes a monorepo’s wall time the sum of its tasks instead of its longest path. | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: packages/p/dist/out.txt is "alone\n", expected "together\n" |
Forwarded args such as --help, --version and -v reach the task, not the runnerWhy this is rightArgs after the task belong to the task’s command, whatever they look like. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A dependency reached by ^build and by b#build builds once, firstWhy this is rightNaming one task twice, once through the package graph and once by its full name, asks for the same work twice. It must run once and finish before the task that needs it. Two runs of it waste time and can rewrite its output while the dependant reads it; dropping either edge can start the dependant early. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Explicit project#task edges chain: each one’s own edges run first, and nothing elseWhy this is rightAn explicit edge names a task, and that task brings its own dependsOn with it. deploy needs utils#lint, which needs core#build and utils#build, so all of them run first, in that order. A runner that follows only the first hop lints against a core that was never built; one that pulls in core#clean or the tests runs work nobody named. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Two excludes alone each take their project out of every projectWhy this is rightAn exclude given with no include takes from the whole workspace, and a second exclude takes from that same set. A runner that lets the second exclude replace the first, or reads it as an include, runs a project the user ruled out. That can be the slow or broken project the excludes were there to skip. | ✓ | ✓ | ✓ | ✓ | ✓ | – |
An exclude alone that names no project runs every project, or refusesWhy this is rightExcluding a project that does not exist leaves the whole workspace: nothing was taken out. Refusing the run as a likely typo is also fair, before anything starts. Running nothing and exiting 0 is not: CI goes green having checked no project at all. | ✓ | ✓ | ✓ | ✓ | ✓ | – |
The three tasks one task releases run at the same time, and the task over them reads all threeWhy this is rightWhen a shared dependency finishes, every task waiting only on it is ready at that moment and can start together. Here each of the three waits a while for the other two to start, so run one at a time they each wait out the full delay. A runner that releases ready tasks one by one turns a wide graph into a chain, and the wall time becomes the sum of the branches instead of the longest one. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Config
8 cases100%8 of 88 supported14%1 of 87 supported100%4 of 84 supported60%3 of 85 supported100%4 of 84 supported0%0 of 81 supported
Mistakes in the config, like depending on a task that does not exist. The tool must refuse them up front, not quietly skip the task.
| Case | vx | Nx | Turborepo | moon | Vite Task | Nix |
|---|---|---|---|---|---|---|
* alone in dependsOn fails the runWhy this is rightA dependency pattern that matches every task, the task itself included, is almost always a typo for a name. Accepted, it makes the task wait on itself, a cycle, or quietly runs every task in the project before it. Failing the run costs one edit; guessing what was meant hides a config mistake that later shows up only as slow or odd builds. | ✓ | ✗Nx Step 1: succeeded, expected a failure | – | – | – | – |
^* alone in dependsOn fails the runWhy this is rightA dependency pattern that matches every task, the task itself included, is almost always a typo for a name. Accepted, it makes the task wait on itself, a cycle, or quietly runs every task in the project before it. Failing the run costs one edit; guessing what was meant hides a config mistake that later shows up only as slow or odd builds. | ✓ | ✗Nx Step 1: succeeded, expected a failure | – | – | – | – |
** alone in dependsOn fails the runWhy this is rightA dependency pattern that matches every task, the task itself included, is almost always a typo for a name. Accepted, it makes the task wait on itself, a cycle, or quietly runs every task in the project before it. Failing the run costs one edit; guessing what was meant hides a config mistake that later shows up only as slow or odd builds. | ✓ | ✗Nx Step 1: succeeded, expected a failure | – | – | – | – |
dependsOn naming a task the project lacks fails the runWhy this is rightA dependency the user wrote is a promise that something runs first. If the named task does not exist and the run goes on, the task runs without the input it waits on and may read a missing or stale file. The typo never surfaces, so a green run hides that the order was never kept. | ✓ | ✗Nx Step 1: succeeded, expected a failure | ✓ | ✓ | ✓ | – |
dependsOn naming a project that does not exist fails the runWhy this is rightA dependency on another project names work that must finish first. If that project does not exist, perhaps renamed or misspelt, running anyway builds on whatever its old output left behind. Failing the run points at the bad name at once, instead of letting CI pass on an order nobody enforced. | ✓ | ✗Nx Step 1: succeeded, expected a failure | ✓ | ✓ | ✓ | – |
dependsOn naming a task another project lacks fails the runWhy this is rightNaming | ✓ | ✗Nx Step 1: succeeded, expected a failure | ✓ | ✓ | ✓ | – |
A time limit no timer can hold is refused, not turned into an instant killWhy this is rightA limit the runner cannot honour should be refused, not silently changed. Timers have a maximum, and a value past it often fires at once, so a limit of hundreds of years kills the task within milliseconds. That is the opposite of what the user asked for, and the cause is hard to see from the failure. | ✓ | – | – | ✗moon Step 1: succeeded, expected a failure | – | – |
A project whose package.json does not parse fails the runWhy this is rightA manifest that does not parse leaves the project’s name, dependencies and tasks unknown. Skipping the project lets the run go green with its tasks never run and its dependants built in the wrong order. Failing at once names the broken file while the mistake is fresh. | ✓ | ✓ | ✓ | ✗moon Step 2: succeeded, expected a failure | ✓ | ✗Nix Step 2: succeeded, expected a failure |
Failures
73 cases100%73 of 7373 supported64%35 of 7354 supported52%24 of 7346 supported68%40 of 7358 supported82%28 of 7334 supported48%17 of 7335 supported
What a failing task does to the rest of the run. Nothing that needs it may run, and the run must say it failed.
| Case | vx | Nx | Turborepo | moon | Vite Task | Nix |
|---|---|---|---|---|---|---|
A failure at the bottom of a chain runs nothing above itWhy this is rightWhen a dependency fails, everything that builds on it would read a missing or broken output. Running lib and app anyway wastes time and can produce output that looks fine but is built on nothing. Skipping them keeps the failure in one place, where the user can see its real cause. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A failed dependency stops the task behind a replayed one from runningWhy this is rightapp’s test reads lib’s output, and this time lib failed, so that output is missing or old. A replay of app’s build does not make lib’s failure go away. Running the test anyway checks stale code and can report a pass for a tree that does not build. | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: exited 100, expected success |
A server whose own build fails never starts, nor does what needs itWhy this is rightA server that depends on a failed build would serve a missing or old output. Starting it anyway, and the tests behind it, checks the wrong code and can pass. Stopping both keeps the failure visible and avoids a long running process nobody can use. | ✓ | ✓ | ✓ | ✓ | – | – |
Asking for two tasks, one of which no project declares, fails the runWhy this is rightA task name that no project declares is a typo or a removed task. If it is dropped silently because the other name was found, the run exits 0 having skipped part of what was asked. CI then reports a check as passing that never ran. Failing names the unknown task, so the fix is a one word change. | ✓ | ✗Nx Step 1: succeeded, expected a failure | ✓ | ✗moon Step 1: succeeded, expected a failure | – | ✓ |
Asking for a task in a project that does not declare it fails the runWhy this is rightThe user asked for typecheck in a, which does not declare it; only b does. If the run exits 0, it reports a type check that never happened. Running b’s typecheck in its place is also wrong, since b was not selected. Failing points the user at the missing task before anyone relies on the result. | ✓ | ✗Nx Step 1: succeeded, expected a failure | ✗Turborepo Step 1: succeeded, expected a failure | ✓ | ✓ | ✓ |
A closed terminal lets a process a task backgrounded run its cleanupWhy this is rightClosing a terminal sends a hang-up, and processes a task started in the background often trap it to clean up, such as removing a lock or stopping a container. The run must pass the signal on and give them time to finish. Killing them outright, as a run that dies of the hang-up does, skips that cleanup and leaves the machine in a broken state. | ✓ | ✗Nx Step 1: packages/a/cleanup.txt is missing, expected "done\n" | ✗Turborepo Step 1: left running: /home/user/vx/.claude/worktrees/cfm/node_modules/.bun/@turbo+linux-64@2.11.10/node_modules/@turbo/linux-64/bin/turbo run t --ui=stream --summarize; sh -c trap "sleep 0.3; echo done > cleanup.txt; exit 0" TERM HUP; touch started; while :; do sleep 0.05; done; sleep 0.05 | ✓ | ✓ | ✗Nix Step 1: did not finish within 20 s |
Running a task no project declares fails instead of passing with nothing runWhy this is rightRunning a task that no project declares is almost always a typo or a missing config. A runner that exits 0 with nothing run reports a green build or test suite that never happened. In CI that hides a broken pipeline for as long as nobody reads the log. A clear failure points at the mistake at once. | ✓ | ✗Nx Step 1: succeeded, expected a failure | ✓ | ✓ | ✓ | ✓ |
A task stopped by SIGTERM mid-run fails the run and is run again next time, never replayedWhy this is rightA task stopped part way never finished its outputs, so there is no result to keep. An entry saved for the stopped run would replay a partial tree as a success: a half-written bundle, a missing file, under a green run. The stop must also fail the run, so CI that cancels a job does not report it as passed. The next run must do the work again. | ✓ | ✓ | ✗Turborepo Step 1: succeeded, expected a failure | ✓ | ✓ | ✗Nix Step 1: did not finish within 20 s |
A task that exits non-zero fails the runWhy this is rightA non-zero exit is how a command says it failed, and the run’s exit code is what CI and scripts read. A runner that reports success over a failed task lets a broken build merge and deploy. The task’s own output is shown so the developer can see what failed without running it again. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A failed task is not replayed as a success next timeWhy this is rightOnly a successful run is a result worth keeping. A stored failure would replay the same error without trying again, so a flaky test or a fixed environment could never pass. A failure saved as a pass would be worse: a broken build hidden behind a green run. So a failed task runs again every time. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A failed dependency stops the projects that need it from runningWhy this is rightA task that depends on another reads what that one produced. If the dependency failed, its output is missing or half written, and a dependant built on it either fails with a confusing second error or passes on stale files. Skipping the dependants keeps the first real error in front of the developer and saves the time spent building on a broken base. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Fixing a failed task’s input makes the next run pass and buildWhy this is rightA fix to an input is a new input, so the task must run again on it and succeed. A runner that remembered the failure by task name, not by input, would keep failing after the fix and push the user to wipe the cache. The output on disk is the one the passing run wrote. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A task killed by a signal fails the run and is not cachedWhy this is rightA task killed by a signal never finished, so whatever it wrote is partial. Treating the death as success, or caching it, would replay half-written output as a green result on every later run. The run fails so CI sees it, and the next run tries the task again from scratch. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A task that writes to stderr and exits 0 succeedsWhy this is rightMany tools print warnings, progress and deprecation notes to stderr while succeeding. The exit code is the only contract a command makes about failure. A runner that failed on stderr output would break builds over harmless warnings and push users to silence useful diagnostics. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Asking for a task no project declares fails the runWhy this is rightA task name no project declares is almost always a typo or a stale script. If the run exits 0 having run nothing, CI reports green while no test or build ran at all. Failing loudly turns the typo into a message the developer sees at once, instead of a gap found weeks later. | ✓ | ✗Nx Step 1: succeeded, expected a failure | ✓ | ✓ | ✓ | ✓ |
Tasks that depend on each other in a cycle fail the run before any runsWhy this is rightA cycle has no valid order: each task waits on the other, so neither can correctly go first. Starting one anyway means it runs without the input it declared it needs. Refusing before anything runs points at the config mistake, rather than leaving a half-run graph or a run that waits forever. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Ctrl-C (SIGINT) ends the run, its task, and what the task backgroundedWhy this is rightCtrl-C means stop everything this run started, and start nothing new. A non-interactive shell runs a backgrounded job with SIGINT ignored, so the signal alone does not end it. Unless the runner takes down the whole process tree, the job runs on under init, holding ports and files after the prompt is back. | ✓ | ✓ | ✗Turborepo Step 1: succeeded, expected a failure | ✗moon Step 1: did not finish within 20 s | ✗Vite Task Step 1: did not finish within 20 s | ✗Nix Step 1: did not finish within 20 s |
SIGTERM ends the run, its task, and what the task backgroundedWhy this is rightSIGTERM is how a CI system, a container runtime or a process supervisor asks a run to end. Unless the runner ends the whole task tree, a job the task backgrounded keeps running after the run has exited. On a shared CI machine that orphan can hold a port, keep writing files or use CPU into the next job. | ✓ | ✓ | ✗Turborepo Step 1: succeeded, expected a failure | ✓ | ✓ | ✗Nix Step 1: did not finish within 20 s |
A hang-up (SIGHUP) ends the run, its task, and what the task backgroundedWhy this is rightA closed terminal or a dropped SSH session sends SIGHUP, and the work started there should end with it. A runner that passes it only to the task’s shell leaves what the task backgrounded running, unseen, with no terminal to show it. Such orphans pile up and hold the ports and locks the next run needs. | ✓ | ✓ | ✗Turborepo Step 1: left running: /home/user/vx/.claude/worktrees/cfm/node_modules/.bun/@turbo+linux-64@2.11.10/node_modules/@turbo/linux-64/bin/turbo run after --ui=stream --summarize; sleep 60 | ✓ | ✓ | ✗Nix Step 1: did not finish within 20 s |
Ctrl-\ (SIGQUIT) ends the run, its task, and what the task backgroundedWhy this is rightThe quit key is a harder stop than Ctrl-C, and a user who presses it wants everything gone. A shell runs a backgrounded job with SIGQUIT ignored, so the signal alone does not end it. The runner must end the whole tree, or the job runs on under init with nothing left that will stop it. | ✓ | ✗Nx Step 1: left running: /bin/sh -c printf '+%s\n' 'a#slow' >> ../../.ran && (sh -c 'exec sleep 60' & touch started; sleep 60) && printf -- '-%s\n' 'a#slow' >> ../../.ran; sleep 60; sleep 60 | ✗Turborepo Step 1: left running: /root/.bun/bin/bun run slow; /usr/bin/bash -c printf '+%s\n' 'a#slow' >> ../../.ran && (sh -c 'exec sleep 60' & touch started; sleep 60) && printf -- '-%s\n' 'a#slow' >> ../../.ran; sleep 60; sleep 60 | ✗moon Step 1: did not finish within 20 s | ✗Vite Task Step 1: left running: sleep 60 | ✗Nix Step 1: did not finish within 20 s |
SIGINT to the run reaches a running task as SIGINTWhy this is rightA task should receive the signal the user sent, unchanged. Cleanup code is often bound to one signal, such as a Node | ✓ | ✗Nx Step 1: packages/a/got.txt is missing, expected "SIGINT\n" | ✗Turborepo Step 1: succeeded, expected a failure | ✓ | ✓ | ✗Nix Step 1: did not finish within 20 s |
SIGTERM to the run reaches a running task as SIGTERMWhy this is rightA supervisor’s SIGTERM should reach the task as SIGTERM. Containers and CI agents send it to ask for a graceful shutdown, and programs bind their shutdown code to it. Forwarded as SIGINT or SIGKILL, that code is skipped, so connections are not drained and partial state is left on disk. | ✓ | ✗Nx Step 1: packages/a/got.txt is missing, expected "SIGTERM\n" | ✗Turborepo Step 1: succeeded, expected a failure | ✓ | ✓ | ✗Nix Step 1: did not finish within 20 s |
A task that exits 0 on Ctrl-C does not start its dependantsWhy this is rightOnce the user asks a run to stop, nothing new should start. A task that handles Ctrl-C by exiting 0 is shutting down cleanly, not reporting that its work is done. Starting its dependants would launch new work after the stop, on output that may be incomplete, and keep the terminal busy after Ctrl-C. | ✓ | ✓ | ✗Turborepo Step 1: succeeded, expected a failure | ✓ | ✓ | ✗Nix Step 1: did not finish within 20 s |
Ctrl-C ends a run whose task ignores SIGINT and SIGTERMWhy this is rightA stop must end the run even when a task ignores the polite signals. Some programs trap or ignore SIGINT and SIGTERM, by design or by bug. Without escalating to SIGKILL after a grace period, the run hangs at Ctrl-C, or exits and leaves the task running in the background. | ✓ | ✓ | ✗Turborepo Step 1: succeeded, expected a failure | ✗moon Step 1: did not finish within 20 s | ✗Vite Task Step 1: did not finish within 20 s | ✗Nix Step 1: did not finish within 20 s |
A run stopped by SIGTERM exits only once its tasks haveWhy this is rightA run should exit only after its tasks have. If the runner returns the prompt while tasks are still running their shutdown code, they keep printing over the next command and writing files the user thinks are settled. A script that starts the next step once the runner exits would race those late writes. | ✓ | ✓ | ✗Turborepo Step 1: succeeded, expected a failure | ✓ | ✗Vite Task Step 1: left running: /bin/sh -c printf '+%s\n' 'a#slow' >> ../../.ran && (trap 'sleep 3; echo late > late.txt; exit 0' TERM; touch started; while :; do sleep 0.05; done) && printf -- '-%s\n' 'a#slow' >> ../../.ran; sleep 3 | ✗Nix Step 1: did not finish within 20 s |
One Ctrl-C lets a task finish its cleanupWhy this is rightOne Ctrl-C should reach a task once. Many cleanup handlers run once and then reset, like | ✓ | ✗Nx Step 1: packages/a/clean.txt is missing, expected "done\n" | ✗Turborepo Step 1: succeeded, expected a failure | ✓ | ✓ | ✗Nix Step 1: did not finish within 20 s |
A process a task backgrounded gets to clean up on SIGTERM after the task’s shell has diedWhy this is rightEvery process in a task’s tree deserves the same chance to shut down cleanly. A task’s shell often dies on SIGTERM at once, while a process it backgrounded is still running its handler. A runner that sends SIGKILL to the rest of the tree as soon as the shell exits cuts that cleanup short and leaves lock files or temp state behind. | ✓ | ✗Nx Step 1: packages/a/cleanup.txt is missing, expected "done\n" | ✗Turborepo Step 1: succeeded, expected a failure | ✓ | ✓ | ✗Nix Step 1: did not finish within 20 s |
A task that traps Ctrl-C and exits 0 is not cached with its partial outputsWhy this is rightAn exit 0 after a stop does not mean the work finished. A program that traps Ctrl-C and exits 0 to shut down gracefully may have written only part of its outputs. Cached, that partial tree would replay as a success on every later run with the same inputs, so the next run must run the task again. | ✓ | ✓ | ✗Turborepo Step 1: succeeded, expected a failure | ✓ | ✓ | ✗Nix Step 1: did not finish within 20 s |
A task that finished before Ctrl-C is replayed by the next runWhy this is rightA task that finished before the stop produced a complete, valid result. Throwing it away because a later task was interrupted makes the next run redo work that was already done. Only the interrupted tasks need to run again; the finished one replays from the cache. | ✓ | ✓ | ✗Turborepo Step 1: succeeded, expected a failure | ✓ | ✓ | ✗Nix Step 1: did not finish within 20 s |
A run started with SIGHUP ignored (nohup) runs on when the terminal closesWhy this is right
| ✓ | ✗Nx Step 1: exited 130, expected success | ✗Turborepo Step 1: exited 129, expected success | ✗moon Step 1: exited 129, expected success | ✗Vite Task Step 1: exited 129, expected success | ✓ |
A dependency killed by a signal mid-write leaves no dependant cached from its partial outputWhy this is rightA dependency killed mid-write leaves partial output, and nothing built from it may be kept. The kill came from something that is not an input, so a later healthy run derives the same cache keys. If a dependant had been cached from the partial file, that run would replay the wrong bytes under a green result. | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 2: succeeded, expected a failure |
A concurrency of 0 is refused or read as a limit, never a run that waits for goodWhy this is rightA concurrency of 0 lets no task start, so a run that takes it literally waits forever. In CI that is a job that hangs until its timeout, with no error to explain it. The runner may refuse the value with a clear message or read it as some valid limit, but it must never sit idle. | ✓ | ✓ | ✓ | ✗moon Step 1: did not finish within 30 s | ✓ | ✓ |
A second Ctrl-C kills a task still in its cleanup at once, and leaves nothing runningWhy this is rightThe first Ctrl-C asks tasks to clean up and stop. Pressing it again says the user will not wait for that cleanup. A runner that keeps waiting ignores the user, and one that exits while leaving the task running hides a process that may still hold a port or write files. | ✓ | ✓ | ✗Turborepo Step 1: succeeded, expected a failure | ✗moon Step 1: did not finish within 15 s | ✗Vite Task Step 1: did not finish within 15 s | ✗Nix Step 1: did not finish within 15 s |
A task that reads its input stream sees it end, though the runner’s own stays open, and the run finishesWhy this is rightA task that reads its input stream must see it end, or it waits forever for input nobody will type. The runner’s own input may stay open, as a terminal or a supervisor leaves it. Handing that stream on to the task turns a finished build into a run that never ends. | ✓ | ✗Nx Step 1: did not finish within 20 s | ✓ | ✗moon Step 1: did not finish within 20 s | ✓ | ✓ |
A task past its time limit is killed, fails the run, and is not cachedWhy this is rightA time limit exists so a hung task cannot hold a run, or a CI job, forever. A task that hits it did not finish, so it fails the run and its partial output is never cached. If it were cached, the next run would replay a half-written result instead of trying again. | ✓ | – | – | ✗moon Step 1: left running: bash -c printf '+%s\n' 'a#slow' >> ../../.ran && (echo started > out.txt; sleep 60; echo done > out.txt) && printf -- '-%s\n' 'a#slow' >> ../../.ran; sleep 60 | – | – |
A task that finishes inside its time limit succeedsWhy this is rightA time limit is an upper bound, not a schedule. A task that finishes inside it must succeed exactly as it would with no limit. A runner that killed it early, or read the limit in the wrong unit, would fail healthy builds at random and teach users to remove the limit. | ✓ | – | – | ✓ | – | – |
A timed-out task that traps SIGTERM and exits 0 still fails, and its partial output is not cachedWhy this is rightA timeout is a failure, whatever exit code the task gives back. Many programs trap SIGTERM and exit 0 as their graceful shutdown, so the exit code after the kill says nothing about the work. Read as success, the partial output would be cached and replayed as a finished build on later runs. | ✓ | – | – | ✗moon Step 1: packages/a/out.txt is "PARTIAL\n", expected "PARTIAL\nTRAPPED\n" | – | – |
A timed-out task that ignores SIGTERM is killed anyway, and the run does not hangWhy this is rightA time limit only bounds a run if the task actually dies. A task that ignores SIGTERM would keep the run, and the CI job, waiting well past the limit. After a short grace period the runner must send SIGKILL, so the limit means what it says. | ✓ | – | – | ✗moon Step 1: left running: sleep 60 | – | – |
A timeout kills what the task backgrounded, not only its shellWhy this is rightA timeout ends the task, and the task is its whole process tree, not only its shell. A command like | ✓ | – | – | ✗moon Step 1: left running: bash -c printf '+%s\n' 'a#t' >> ../../.ran && (sh -c 'exec sleep 60' & sleep 60) && printf -- '-%s\n' 'a#t' >> ../../.ran; sleep 60; sleep 60 | – | – |
A timeout kills a backgrounded child that ignores SIGTERM after its shell has diedWhy this is rightA timeout must end every process the task started, even one that ignores SIGTERM. The shell dies on SIGTERM at once, so a runner that stops escalating when the shell exits never sends SIGKILL to the child. The child then runs on with no parent, holding resources after the run has failed. | ✓ | – | – | ✗moon Step 1: left running: bash -c printf '+%s\n' 'a#t' >> ../../.ran && (sh -c 'trap "" TERM; exec sleep 60' & sleep 60) && printf -- '-%s\n' 'a#t' >> ../../.ran; sleep 60; sleep 60 | – | – |
A task that fails once and passes on its retry passes the run, and the passing attempt is what is cachedWhy this is rightA retry exists to absorb a transient failure, so a task that passes on its retry passes the run. What is cached is the output of the passing attempt, because that is the result the run reported. Caching the failed attempt’s files would replay the wrong bytes; caching nothing would redo the work next time. | ✓ | – | – | ✓ | – | – |
A task that fails every attempt runs once plus its retries, then fails the runWhy this is rightRetries are a bounded number of extra tries, not a way to hide a real failure. The task runs once plus each retry, and if every attempt fails, the run fails. Running fewer attempts ignores the config, and running more stretches a broken CI job for nothing. | ✓ | – | – | ✓ | – | – |
A task with retries that passes first time runs onceWhy this is rightA retry is for a failure. A task that passes on its first attempt is done, and running it again would spend CI time and could repeat side effects such as a publish, an upload or a database migration. The retry count bounds attempts after failures; it is never a number of runs. | ✓ | – | – | ✓ | – | – |
A task killed by a signal the run did not send is retried like any failureWhy this is rightA task killed by a signal the user did not send has failed like any other. The out-of-memory killer, a crashed tool or the task’s own script can kill it, and that is the kind of transient failure retries exist for. Only a stop the user asked for should end the retries. | ✓ | – | – | ✓ | – | – |
Ctrl-C during a task with retries does not attempt it againWhy this is rightWhen the user stops the run, the task’s failure is the stop itself. Retrying it would start the work again right after the user asked it to end, and Ctrl-C would seem not to work. The run ends after the one attempt, as asked, and reports the stop rather than a run of failed retries. | ✓ | – | – | ✓ | – | – |
Asking for constructor, a task no project declares, fails the runWhy this is rightA task exists only if a config declares it. A name every JavaScript object carries, like | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
SIGINT to the run reaches a server past its ready line as SIGINTWhy this is rightA server past its ready line should still receive Ctrl-C as SIGINT. Its shutdown hook is often bound to that one signal, to close connections and remove temp files. Forwarded as SIGTERM or SIGKILL, the hook never runs, and the server’s state is left behind while its dependants are torn down. | ✓ | ✗Nx Step 1: packages/app/got.txt is missing, expected "SIGINT\n" | – | – | – | – |
SIGTERM to the run reaches a server past its ready line as SIGTERMWhy this is rightA ready server should receive a supervisor’s SIGTERM as SIGTERM. Servers bind their graceful shutdown, such as draining open connections, to that signal. Forwarded as another signal, that code is skipped, and requests in flight are cut off instead of drained while the run shuts down. | ✓ | ✗Nx Step 1: packages/app/got.txt is missing, expected "SIGTERM\n" | – | – | – | – |
SIGTERM ends a run whose ready server and the task using it are both runningWhy this is rightSIGTERM ends the whole run: every task in it, not only the one that started first. Here a ready server and the task using it are both running, and both must end. A run that stops one and waits on the other hangs the supervisor that asked it to stop. | ✓ | ✓ | – | – | – | – |
SIGTERM ends the child of a ready server that traps TERM, INT and HUPWhy this is rightA stop must end every process the run started, even a server’s child that traps TERM, INT and HUP. Without escalating to SIGKILL, that child outlives the run and keeps its port and its output pipe. The next run then fails to bind, or the current one never exits. | ✓ | ✓ | – | – | – | – |
One Ctrl-C lets a ready server finish its cleanup while its dependant is stoppedWhy this is rightOne Ctrl-C should reach a ready server once. Many servers read a second signal as a request to force quit and skip their cleanup. A runner that sends the stop twice, once to the group and once to the server, leaves lock files and temp state behind. | ✓ | ✗Nx Step 1: packages/app/clean.txt is missing, expected "done\n" | – | – | – | – |
Ctrl-C while a command that keys a task is still running ends the run and the commandWhy this is rightA command that computes part of a task’s key is part of the run and must end with it. If Ctrl-C leaves that probe running, the stopped run stays open until the probe finishes on its own. To the user, the run seems to ignore Ctrl-C. | ✓ | ✓ | – | ✓ | – | – |
A run whose task fails beside a server fails promptly and stops the serverWhy this is rightOnce the task that needs a server fails, the server has nothing left to serve. The run should fail at once and stop the server, not keep going until someone notices. In CI, a server left up after a failure holds the job open until its timeout and buries the real error under a cancelled job. | ✓ | ✓ | ✗Turborepo Step 1: did not run app#e2e | ✗moon Step 1: left running: sleep 60 | – | – |
A server that exits non-zero as it starts fails the run instead of hanging itWhy this is rightA server that exits non-zero as it starts has failed, and the run should say so. Waiting for a server that is already gone hangs the terminal or the CI job with no visible cause. Failing at once shows the server’s own error output while it is still on screen. | ✓ | ✗Nx Step 1: succeeded, expected a failure | ✓ | ✓ | – | – |
A server whose shell exits leaves nothing it started holding its portWhy this is rightWhen a server’s shell exits, everything it started must end with it. A process left behind keeps the port bound, so the next | ✓ | ✗Nx Step 1: left running: sleep 60 | ✗Turborepo Step 1: did not finish within 30 s | ✗moon Step 1: did not finish within 30 s | – | – |
A server that dies mid-run keeps the tasks that need it from starting, and fails the runWhy this is rightA task that needs a server cannot give a real result once the server is gone. Run anyway, it either fails with an unrelated connection error or passes against nothing. The run should skip what still needs the server and fail, so the server’s death is the error the developer sees. | ✓ | ✓ | ✓ | ✓ | – | – |
Ctrl-C before a server’s turn leaves it unstartedWhy this is rightAfter Ctrl-C, no new task should start, servers included. A server spawned after the stop comes up after the teardown has run, so nothing ends it. It then holds the run open, or keeps running in the background with its port taken. | ✓ | ✓ | ✗Turborepo Step 1: succeeded, expected a failure | ✓ | – | – |
SIGINT to the run reaches a running server as SIGINTWhy this is rightA server should receive the signal the user sent, unchanged. Dev servers often bind their shutdown to SIGINT, the Ctrl-C signal, to close connections and remove temp files. If the runner forwards it as SIGTERM or SIGKILL, that hook never runs and the server’s state is left behind for the next start. | ✓ | ✗Nx Step 1: packages/app/got.txt is "SIGTERM\n", expected "SIGINT\n" | ✓ | ✓ | – | – |
SIGTERM to the run reaches a running server as SIGTERMWhy this is rightA server should receive SIGTERM as SIGTERM. Containers and CI agents send it to ask for a graceful shutdown, and servers bind their drain and cleanup code to it. Forwarded as another signal, that code is skipped, and open connections, lock files or temp files are left as they were. | ✓ | ✗Nx Step 1: packages/app/got.txt is missing, expected "SIGTERM\n" | ✗Turborepo Step 1: packages/app/got.txt is "SIGINT\n", expected "SIGTERM\n" | ✓ | – | – |
Ctrl-C ends a server’s own child that traps every signal it canWhy this is rightCtrl-C must end everything a server started, even a child that traps every signal it can. Such a child would otherwise outlive the run, keep its port and keep printing into the terminal. After a grace period the runner kills it with SIGKILL, which cannot be trapped. | ✓ | ✓ | ✓ | ✗moon Step 1: did not finish within 30 s | – | – |
A server that exits non-zero before its ready line fails the run and starts no dependantWhy this is rightA server that exits non-zero before it is ready has failed to start. Its dependants need it, so they must not start, and the run must fail rather than wait for a line that can never come. Otherwise CI either hangs until its own timeout or runs tests against nothing. | ✓ | ✓ | – | – | – | – |
A server whose command does not exist fails the run at once, not after waiting for its ready lineWhy this is rightA command that does not exist fails at once, and the run should report it at once. Waiting for a ready line from a process that never started hides a clear error, like a missing install or a typo, behind a hang or a timeout. The developer should see the real cause in seconds. | ✓ | ✓ | – | – | – | – |
Ctrl-C while a server is still starting ends it and starts none of its dependantsWhy this is rightCtrl-C during a server’s start-up means the user no longer wants the run. The starting server must be ended, not left booting in the background, and none of its dependants may start. Otherwise the stop launches the very tests it was meant to cancel. | ✓ | ✓ | – | – | – | – |
A server that prints its ready line as Ctrl-C stops it does not start its dependantsWhy this is rightReadiness only counts while the run is still going. A server may print its ready line from its shutdown handler, after the user pressed Ctrl-C. Starting dependants on that line would launch new work after the stop, against a server that is about to exit. | ✓ | ✓ | – | – | – | – |
A server that never prints its ready line fails at its time limit, and is killedWhy this is rightA server that is alive but never prints its ready line will never be useful to its dependants. Without a limit it holds the run open for good, and in CI that is a job that hangs until it is cancelled. At its time limit the run fails with a clear reason, and the server is killed, not left running. | ✓ | – | – | – | – | – |
A server that is ready inside its time limit keeps running past itWhy this is rightFor a server, the time limit bounds the wait for its ready line, not its life. A dev server is meant to keep running while the tasks that use it do. Killing it once the limit passes would pull it from under the tests mid-run and fail them for no fault of their own. | ✓ | – | – | – | – | – |
A never-ready server that ignores SIGTERM is killed at its time limit anywayWhy this is rightA time limit must hold even for a server that ignores SIGTERM. Without escalating to SIGKILL, the server stays up and the run waits on it past its limit, which is the hang the limit was set to prevent. The run fails at the limit and leaves nothing running. | ✓ | – | – | – | – | – |
A never-ready server that exits 0 on its time limit’s SIGTERM still failsWhy this is rightA server that never became ready has failed, however it exits. Many servers exit 0 on SIGTERM as their graceful shutdown, so the exit code after the time limit’s kill says nothing about readiness. Read as success, the run would go green with its dependants never run and nothing tested. | ✓ | – | – | – | – | – |
A never-ready server’s backgrounded child that ignores SIGTERM dies with it at its time limitWhy this is rightAt its time limit a never-ready server must die with everything it started. Its shell exits on SIGTERM at once, while a child that ignores SIGTERM keeps running unless the runner kills the whole tree. That child would hold the server’s port into the next run, which then fails to bind. | ✓ | – | – | – | – | – |
A never-ready server’s SIGTERM cleanup runs to its end at the time limitWhy this is rightKilling a server at its time limit should still let its cleanup finish. Many handlers run once and reset, so a second SIGTERM arriving during cleanup kills the server half way. A runner should send the signal once, wait a grace period, and only then use SIGKILL. | ✓ | – | – | – | – | – |
Asked to continue, a run goes on past a failed task and still failsWhy this is rightAsked to continue, the run should go on with every task the failure does not block, so the user sees every error at once. The run as a whole still failed, though. A green exit would let a pipeline merge or deploy with a broken task the log mentions only in passing. | ✓ | – | ✓ | ✗moon Step 1: did not run app#build | – | – |
A task that ran past its dependency’s failure is not replayed once the dependency is fixedWhy this is rightWhen the run continued past a failed dependency, the task after it ran on whatever the failure left, which is not a valid build. Saving that result lets it replay after the dependency is fixed. The task must run again on the fixed dependency, or the stale output outlives the fix. | ✓ | – | ✓ | ✗moon Step 1: did not run app#build | – | – |
A task that closes its output and keeps working is waited for until it exitsWhy this is rightA process can close its output streams and go on working, as a daemonizing tool or a quiet build step does. The task ends when its process exits, not when its output ends. A runner that takes the closed stream as the end starts the dependant on a half-written output, or ends the run with the task still running behind it. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Affected
97 cases100%97 of 9797 supported58%57 of 9797 supported70%68 of 9797 supported71%69 of 9797 supported–0 of 970 supported–0 of 970 supported
Running only what a change touches. Miss a project and a broken change merges untested; pick too many and CI wastes time.
| Case | vx | Nx | Turborepo | moon | Vite Task | Nix |
|---|---|---|---|---|---|---|
A change selects a project whose task names the changed project’s taskWhy this is rightapp declares that its build needs lib’s build, so a change to lib affects app even without a package dependency. An affected run that follows only package manifests leaves app out. app then goes untested on the new lib, and the breakage shows up later, after merge. | ✓ | ✗Nx Step 4: did not run app#build | ✓ | ✓ | – | – |
A changed root file no member owns selects the workspace-root projectWhy this is rightA file at the workspace root that no member owns belongs to the workspace-root project. A change to it must select that project’s tasks, like a change in any member. Treating root files as nobody’s skips root tasks such as code generation that read them. | ✓ | ✓ | ✓ | ✓ | – | – |
A changed member file selects the member, not the workspace-root project holding itWhy this is rightA member file lies under the workspace root, but the member owns it, not the root project. An edit there selects the member alone. Also selecting the root project would rerun root tasks on nearly every change, since every member file is under the root. | ✓ | ✓ | ✗Turborepo Step 2: ran root#build, expected it replayed or left out | ✗moon Step 2: ran root#build, expected it replayed or left out | – | – |
Changed files named in CJK, Cyrillic or with an emoji select their projectsWhy this is rightChanged files with CJK, Cyrillic or emoji names are ordinary changes. git escapes such names in its plain output; a tool that reads that output without undoing the escaping maps the path to no project. Those projects are then left out of an affected run, and CI skips the very code that changed. | ✓ | ✓ | ✓ | ✓ | – | – |
A committed change in a project whose directory is named café selects itWhy this is rightA project’s directory may have a non-ASCII name like | ✓ | ✓ | ✓ | ✓ | – | – |
A committed dependency change selects the projects whose dependencies hold it, through a linked workspace too, and no otherWhy this is rightThe changed project edited no file of its own; only the lockfile records that a dependency it uses moved. That dependency is what it now imports, so its result can change. Selecting by changed files alone misses it, and selecting every project on any lockfile change runs far too much. | ✓ | ✗Nx Step 4: ran a#build, expected it replayed or left out | ✗Turborepo Step 4: did not run b#build | ✗moon Step 4: did not run b#build, c#build | – | – |
A committed edit inside a local directory dependency selects the project depending on itWhy this is rightA changed-projects check must follow the same edges a key does. The edit lies under | ✓ | ✗Nx Step 4: did not run lib#build | ✗Turborepo Step 4: did not run lib#build | ✗moon Step 4: did not run lib#build | – | – |
A committed edit to a local dependency kept inside another project’s directory selects the project depending on itWhy this is rightThe edit is under | ✓ | ✗Nx Step 4: did not run lib#build | ✗Turborepo Step 4: did not run lib#build | ✗moon Step 4: did not run lib#build | – | – |
A committed edit beside a local dependency, in the project that holds it, selects only that projectWhy this is rightInstalling a local dependency from another project’s directory claims that directory, not the whole project. An edit to | ✓ | ✓ | ✓ | ✓ | – | – |
A committed change to a workspace package the root depends on selects a project reading it through the rootWhy this is rightA changed-projects check must see the same dependencies a build does. | ✓ | ✗Nx Step 3: did not run app#build | ✓ | ✗moon Step 3: did not run app#build | – | – |
A committed change selects its project and every project that depends on itWhy this is rightA change can break any project built on top of it, so an affected run must reach every dependent, not only the project that was edited. If dependents are left out, a pull request that breaks | ✓ | ✗Nx Step 3: did not run app#test | ✓ | ✓ | – | – |
A change in a project nothing depends on selects only that projectWhy this is rightSelecting by change exists to run only what the change can reach. A project nothing depends on cannot pass its edit to anyone else, so only its own tasks need to run. Running its siblings or its dependencies as well spends CI minutes on work whose result is already known. | ✓ | ✓ | ✓ | ✓ | – | – |
With nothing changed, an affected run runs nothing and succeedsWhy this is rightWhen no file changed, no task’s result can differ from last time, so the right answer is an empty run that succeeds. Failing would turn a no-op pull request, such as a merge of an empty commit, into a red build. Running everything would waste the whole CI budget on work nothing asked for. | ✓ | ✓ | ✓ | ✓ | – | – |
An uncommitted edit counts as a changeWhy this is rightA developer runs the affected tasks before committing, to check the edit they are making. The working tree is what the tasks will read, so an edit on disk is a change whether or not it is committed yet. Comparing only commits would report nothing to run and let the developer commit untested work. | ✓ | ✓ | ✓ | ✓ | – | – |
A new file git does not track yet counts as a changeWhy this is rightA new file is read by any task whose inputs match it, even before git tracks it. Ignoring untracked files would skip the project where a developer just added a source or a test. The run would look clean, and the new file would only be checked after it is committed and pushed. | ✓ | ✓ | ✓ | ✓ | – | – |
A deleted file counts as a change to its projectWhy this is rightRemoving a file changes what a task reads as surely as editing it: an import can now fail, a test can vanish. The project that lost the file and every project built on it must be selected. Treating a delete as no change lets a pull request that breaks the build by removing a file pass CI. | ✓ | ✗Nx Step 3: did not run app#test | ✓ | ✓ | – | – |
A change to a root file no task reads selects nothingWhy this is rightA file at the root that no task names as an input cannot change any task’s result. Selecting every project on such an edit, for example a notes file or a README, makes a one-line docs change cost a full CI run. The tool has the inputs declared, so it can tell that nothing reads the file. | ✓ | ✓ | ✓ | ✓ | – | – |
An uncommitted edit selects its project and every project that depends on itWhy this is rightAn uncommitted edit changes what dependents build against just as a commit does. A local affected run must reach every dependent, not only the edited project. Otherwise the developer sees a green check while | ✓ | ✗Nx Step 2: did not run app#test | ✓ | ✓ | – | – |
Changing a project’s task definition selects it and its dependentsWhy this is rightA task’s definition is what it runs: a new command, flag or dependency is a change as much as a new source. Its project and every dependent must be selected. Comparing only source files misses a config change that breaks the build, and CI stays green. | ✓ | ✗Nx Step 3: did not run app#test | ✓ | ✗moon Step 3: did not run lib#test, app#test | – | – |
Removing every task of a dependency selects the projects depending on itWhy this is rightWhen a dependency loses its tasks, what its dependents built against has changed: the build they waited on is gone. The dependents must run to show whether they still work. Treating the removed tasks as nothing to select hides a broken dependency chain. | ✓ | ✓ | ✓ | ✗moon Step 3: did not run app#build | – | – |
A base several commits back selects what every commit since changedWhy this is rightA base several commits back must cover every commit since then, not only the latest. Each commit may touch a different project. Comparing only HEAD with its parent leaves earlier changes on the branch untested, so a pull request with many commits passes with gaps. | ✓ | ✗Nx Step 5: did not run app#test | ✓ | ✓ | – | – |
A branch base selects what this branch changed, not what the base branch did sinceWhy this is rightComparing against a branch means comparing against where this branch left it, the merge base, as a pull request does. Commits main gained since then are not this branch’s change. Comparing directly with main’s tip selects projects only main touched and reruns their tests on every pull request. | ✓ | ✓ | ✓ | ✓ | – | – |
On a merge commit, the first parent as base selects what the merge brought inWhy this is rightCI often checks out the merge of a pull request into its base. On that merge commit, the first parent is the base branch, so comparing with | ✓ | ✓ | ✓ | ✓ | – | – |
A tag works as a base, as a branch doesWhy this is rightReleases are often compared against the last tag, so a tag must work as a base just as a branch does. Rejecting it, or resolving it to the wrong commit, forces users to look up commit hashes by hand. Selecting everything instead makes every release run a full rebuild. | ✓ | ✓ | ✓ | ✓ | – | – |
An annotated tag works as a base: the commit it points at is comparedWhy this is rightAn annotated tag points at a tag object, which in turn points at a commit. The comparison must use that commit. Diffing against the tag object fails or resolves to nothing, and the release check either errors or reruns every project. | ✓ | ✓ | ✓ | ✓ | – | – |
In a depth-2 clone, the previous commit as base selects the change and its dependentsWhy this is rightCI often fetches only the last few commits. When the base is within that history, the tool can compute the diff normally and must do so. Bailing out because the clone is shallow, or selecting everything, throws away the affected run in exactly the place it saves the most time. | ✓ | ✗Nx Step 6: did not run app#test | ✓ | ✓ | – | – |
In a depth-1 clone, the previous commit as base is fetched and selects the changeWhy this is rightCI checks out a single commit by default, so the previous commit is not on disk but is on origin. The tool should fetch what it needs and select only the change. Failing breaks the default CI setup; selecting everything makes affected runs cost a full run in CI. | ✓ | ✗Nx Step 4: exited 1, expected success | ✗Turborepo Step 4: exited 1, expected success | ✗moon Step 4: exited 1, expected success | – | – |
In a depth-1 clone of a branch, the base branch on origin selects what the branch changedWhy this is rightA pull request job usually checks out one commit, with the base branch and merge base only on origin. Comparing with | ✓ | ✗Nx Step 9: exited 1, expected success | ✗Turborepo Step 9: exited 1, expected success | ✗moon Step 9: exited 1, expected success | – | – |
In a depth-1 merge checkout, the first parent as base selects what the merge brought inWhy this is rightA common pull request checkout is the merge commit fetched alone, with neither parent present. Its first parent is the base, so comparing with | ✓ | ✗Nx Step 9: exited 1, expected success | ✗Turborepo Step 9: exited 1, expected success | ✗moon Step 9: exited 1, expected success | – | – |
An edit to a file marked assume-unchanged in git’s index selects its projectWhy this is rightMarking a file assume-unchanged tells | ✓ | ✗Nx Step 3: did not run lib#test | ✗Turborepo Step 3: did not run lib#test | ✗moon Step 3: did not run lib#test | – | – |
An edit to a file marked skip-worktree in git’s index selects its projectWhy this is rightSkip-worktree hides a file from git’s view of the working tree, often to keep a local config edit. The task still reads the edited file, so the project must be selected. Trusting the flag means the edit is never tested, though it changes what the task produces. | ✓ | ✗Nx Step 3: did not run lib#test | ✗Turborepo Step 3: did not run lib#test | ✗moon Step 3: did not run lib#test | – | – |
Deleting a file marked assume-unchanged selects its projectWhy this is rightA file marked assume-unchanged can still be deleted from disk, and the task then cannot read it. git’s status does not report the delete, so the tool must check the disk. Missing it leaves a project untested after a source it needs is gone. | ✓ | ✗Nx Step 3: did not run lib#test | ✓ | ✗moon Step 3: did not run lib#test | – | – |
A skip-worktree file absent from disk, as a sparse checkout leaves it, selects nothingWhy this is rightA sparse checkout leaves files out of the working tree on purpose and marks them skip-worktree. A file absent for that reason was not deleted by anyone. Counting it as a change would select every project with unmaterialised files on each run in a sparse checkout. | ✓ | ✓ | ✓ | ✓ | – | – |
An edit to the file an input link points at selects the link’s projectWhy this is rightA task reads the content behind a file link, wherever in the workspace that content lives. git reports the edit at the target’s path, outside the project holding the link. The link must be followed, or the project that reads the file is not selected. | ✓ | ✗Nx Step 4: did not run a#test | ✗Turborepo Step 4: did not run a#test | ✗moon Step 4: did not run a#test | – | – |
An edit inside the directory an input link points at selects the link’s projectWhy this is rightA directory link makes the target directory part of the project that holds the link. git reports an edit there at the target’s path, which belongs to no project. Without following the link, the project that reads the file is skipped and its tests do not run on the new content. | ✓ | ✗Nx Step 4: did not run a#test | ✗Turborepo Step 4: did not run a#test | ✗moon Step 4: did not run a#test | – | – |
A new file in the directory an input link points at selects the link’s projectWhy this is rightA new file in a linked directory is a new input for the project holding the link, as an input glob through the link now matches it. git reports it at the target’s path, not the project’s. Not following the link skips the project that now reads one more file. | ✓ | ✗Nx Step 4: did not run a#test | ✗Turborepo Step 4: did not run a#test | ✗moon Step 4: did not run a#test | – | – |
An edit to a git-ignored file an input link points at selects the link’s projectWhy this is rightA link may point at a file git ignores, such as a local config. git’s diff cannot see that file, so it cannot say the file did not change. The tool must check what the link reads, or the project is skipped after an edit that changes its result. | ✓ | ✗Nx Step 4: did not run b#test | ✗Turborepo Step 4: did not run b#test | ✗moon Step 4: did not run b#test | – | – |
A dangling link, or a link whose target did not change, selects nothingWhy this is rightFollowing links must stay precise. A dangling link reads nothing, and a link to an unchanged file reads the same bytes as before. Selecting every project that holds a link whenever anything nearby changes reruns work whose result cannot have changed. | ✓ | ✓ | ✓ | ✓ | – | – |
When only a project without the task changed, the run succeeds and runs nothingWhy this is rightIn an affected run, which projects are in scope depends on what changed. If only a project without the requested task changed, the correct result is an empty run that succeeds. The task exists elsewhere, so this is not a typo, and failing would turn a harmless change into a red build. | ✓ | ✓ | ✓ | ✓ | – | – |
A changed project lacking the task, depending on one that has it, runs nothing and succeedsWhy this is rightA changed project that lacks the requested task has nothing to run, and the change cannot flow back into a project it depends on. The run should succeed and run nothing. Failing makes a change in a project without tests break a test job; running the dependency’s task wastes time. | ✓ | ✓ | ✓ | ✓ | – | – |
When only a project declaring no tasks at all changed, the run succeeds and runs nothingWhy this is rightA project may declare no tasks at all, for example a folder of shared assets. A change there selects nothing for a task no project in scope has. The run should succeed with nothing to do; failing turns an edit in a task-less project into a red build. | ✓ | ✓ | ✓ | ✓ | – | – |
An affected run of a task no project declares fails, whatever changedWhy this is rightA task no project declares can never run, whatever changed. The run must fail, so a typo like | ✓ | ✗Nx Step 3: succeeded, expected a failure | ✓ | ✗moon Step 3: succeeded, expected a failure | – | – |
A project that is a clean submodule is not selectedWhy this is rightA project can be a git submodule. When the submodule is clean and still on the recorded commit, nothing in it changed. Selecting it just because it is a submodule, or because git reports it as a special entry, reruns its tasks on every affected run for no change. | ✓ | ✓ | ✓ | ✓ | – | – |
An edit inside a submodule project selects itWhy this is rightA submodule’s files are read by its project’s tasks like any others. git’s diff of the outer repository reports a dirty submodule, not the file inside it. The tool must still map the edit to the project, or the submodule’s tests do not run on the change. | ✓ | ✗Nx Step 3: did not run sub#test | ✗Turborepo Step 3: did not run sub#test | ✓ | – | – |
A new untracked file inside a submodule project selects itWhy this is rightA new file inside a submodule is a new input for the tasks that read that project, even before it is tracked. The outer repository sees only that the submodule is dirty. Not looking inside skips the project and lets the new file go untested. | ✓ | ✗Nx Step 3: did not run sub#test | ✗Turborepo Step 3: did not run sub#test | ✓ | – | – |
Moving a submodule to another commit selects it, committed or notWhy this is rightMoving a submodule to another commit swaps every file its project reads. The project must be selected whether the move is still in the working tree or already committed in the outer repository. Once compared against the commit that already holds the move, it must stop being selected. | ✓ | ✗Nx Step 4: did not run sub#test | ✗Turborepo Step 4: did not run sub#test | ✗moon Step 4: did not run sub#test | – | – |
A submodule .gitmodules tells git to ignore still selects its project when it changesWhy this is rightSetting | ✓ | ✗Nx Step 4: did not run sub#test | ✗Turborepo Step 4: did not run sub#test | ✓ | – | – |
An edit inside a submodule nested in a project selects that projectWhy this is rightA submodule can sit inside a project, for example a vendored library. Its files belong to the project that holds it, and an edit there changes what that project’s tasks read. The outer diff only marks the submodule dirty, so the tool must map it to the holding project. | ✓ | ✓ | ✗Turborepo Step 4: did not run a#test | ✓ | – | – |
A new git repository created inside a project selects that projectWhy this is rightA git repository created inside a project, for example by a tool or a clone, is untracked content of that project. Its files are on disk and an input glob over the project reads them. git may hide its contents behind one path, so the project must be selected all the same. | ✓ | ✓ | ✓ | ✓ | – | – |
Edits in two projects select bothWhy this is rightA change set is the union of every file it touched, so each project with an edited file must be selected. Stopping at the first project found, or picking one, leaves the other untested. The project left out is then free to ship broken from the same pull request. | ✓ | ✓ | ✓ | ✓ | – | – |
A change staged in git’s index but not committed counts as a changeWhy this is rightFiles staged with | ✓ | ✓ | ✓ | ✓ | – | – |
A file deleted from the working tree, not committed, selects its projectWhy this is rightA file removed from the working tree is gone for every task that reads the project, even before the delete is committed. Its project must be selected, since an import or fixture it held may now be missing. Seeing only edits and new files would let a breaking delete pass a local check. | ✓ | ✓ | ✓ | ✓ | – | – |
A file renamed inside a project selects that project aloneWhy this is rightA rename changes the project’s file set: a task over | ✓ | ✓ | ✓ | ✓ | – | – |
A file moved from one project to another, not yet committed, selects bothWhy this is rightMoving a file out of one project and into another changes both: one lost an input, the other gained one. git may report the move as a single rename, but its two paths belong to two projects. Selecting only the target leaves the source untested after it lost a file it may still need. | ✓ | ✓ | ✗Turborepo Step 2: did not run c#test | ✓ | – | – |
A committed move of a file from one project to another selects bothWhy this is rightOnce committed, a move between projects still changes both: one lost an input, the other gained one. git’s diff may fold the move into a single rename entry, but both of its paths must be checked against projects. Counting only the new path leaves the project that lost the file untested. | ✓ | ✓ | ✓ | ✓ | – | – |
A new file with a non-ASCII name selects its projectWhy this is rightA file name with accented or other non-ASCII letters is as much a source as any other. By default git quotes and escapes such names in its plain output, so a reader that takes them literally gets a path that matches no project. The change is then dropped and the project’s tests do not run. | ✓ | ✓ | ✓ | ✓ | – | – |
A changed file whose name holds a quote or a backslash selects its projectWhy this is rightA quote or a backslash in a file name is legal and does occur, for example in generated test fixtures. git escapes these characters in its plain output, so a naive reader sees a path no project owns. The edit is then silently dropped, and a broken project passes as unaffected. | ✓ | ✓ | ✓ | ✓ | – | – |
New files git ignores, an install among them, select nothingWhy this is rightFiles git ignores, such as an installed | ✓ | ✓ | ✓ | ✓ | – | – |
An edit to a file committed under a node_modules directory selects its projectWhy this is rightA file committed to the repository is a source, even if it sits under a directory named | ✓ | ✗Nx Step 4: did not run b#test | ✓ | ✓ | – | – |
A change in app-e2e selects it, not app or app-e2e-utils beside itWhy this is rightA project owns a path only when the path continues with a | ✓ | ✓ | ✓ | ✓ | – | – |
A project nested in another owns its files: an edit there selects it, not its parentWhy this is rightWhen one project sits inside another, each file belongs to the innermost project that holds it. An edit in the inner project should not select the outer one, and the reverse holds too. Otherwise every change to the inner project reruns the outer project’s tasks, which never read those files. | ✓ | ✓ | ✗Turborepo Step 3: ran outer#test, expected it replayed or left out | ✗moon Step 3: ran outer#test, expected it replayed or left out | – | – |
A base fifty commits back selects what those commits changed, and nothing elseWhy this is rightA base far back, as on a long-lived branch, must select exactly what the commits since then changed. A tool that gives up past some depth and selects everything turns long branches into full runs. One that reads only the last commit misses earlier changes and leaves them untested. | ✓ | ✓ | ✓ | ✓ | – | – |
A commit of six thousand long-named files, over a megabyte of paths, selects its projectWhy this is rightLarge commits happen: generated code, vendored packages, a mass rename. A diff whose list of paths outgrows a fixed output buffer must still be read in full. Failing the run blocks the merge for no real reason, and falling back to everything wastes the run on projects the commit never touched. | ✓ | ✓ | ✓ | ✓ | – | – |
Deleting a whole project selects none of the projects that remainWhy this is rightWhen a whole project is deleted, the projects that remain read none of its files, so nothing they depend on changed. The deleted project has no task left to run. Selecting every project because the workspace list changed makes a cleanup pull request cost a full CI run. | ✓ | ✗Nx Step 6: ran b#test, expected it replayed or left out | ✗Turborepo Step 6: ran b#test, expected it replayed or left out | ✓ | – | – |
A project moved to another directory is selected where it now livesWhy this is rightA project that moves to another directory is still that project, and its tasks now run from its new place. Its files all changed paths, so it must be selected. Matching the old path finds nothing and skips the project, leaving the move, and any path it broke, untested. | ✓ | ✗Nx Step 6: ran a#test, b#test, expected it replayed or left out | ✗Turborepo Step 6: ran a#test, b#test, expected it replayed or left out | ✓ | – | – |
Making a directory inside a project a project of its own selects bothWhy this is rightWhen a directory inside a project becomes a project of its own, the outer project stops owning those files. A task over the outer project’s | ✓ | ✗Nx Step 6: ran b#test, expected it replayed or left out | ✗Turborepo Step 6: ran b#test, expected it replayed or left out | ✓ | – | – |
A base that names no commit fails the run or runs everything, never nothingWhy this is rightA base that names no commit, often a typo or a branch the CI checkout did not fetch, means the tool cannot know what changed. It must fail the run or run everything, never treat the unknown diff as empty. Running nothing turns the job green while it checked nothing at all. | ✓ | ✓ | ✓ | ✓ | – | – |
A base spelled like a git option is never passed to git as oneWhy this is rightThe base often comes from a CI variable or a pull request field, so it is input the tool must not trust. A base spelled like | ✓ | ✓ | ✓ | ✓ | – | – |
A base naming a directory inside a commit (HEAD:packages) selects nothing unchangedWhy this is rightA base like | ✓ | ✗Nx Step 1: ran a#test, b#test, expected it replayed or left out | ✗Turborepo Step 1: ran a#test, b#test, expected it replayed or left out | ✗moon Step 1: ran a#test, b#test, expected it replayed or left out | – | – |
A base written as an exclusion (^main) selects nothing only the base branch changedWhy this is rightA base written as | ✓ | ✗Nx Step 8: ran b#test, expected it replayed or left out | ✓ | ✗moon Step 8: ran b#test, expected it replayed or left out | – | – |
When git cannot diff the base, the run fails or runs everything, never nothingWhy this is rightIf git cannot read the diff, for example because an object is missing from a damaged clone, the tool does not know what changed. An unreadable diff is not an empty one. It must fail or run everything; running nothing turns a broken checkout into a green job that checked nothing. | ✓ | ✓ | ✓ | ✓ | – | – |
An edit or a bump in a submodule selects its project though git is set to ignore itWhy this is rightA user may set | ✓ | ✗Nx Step 4: did not run sub#test | ✗Turborepo Step 4: did not run sub#test | ✓ | – | – |
An edit to a file only the project’s own test reads leaves its dependents’ tests outWhy this is rightDependents reach a project only through the tasks they wait on, here its | ✓ | ✓ | ✓ | ✓ | – | – |
A change no requested task reads runs nothing and succeedsWhy this is rightAn affected run answers which of the requested tasks the change can reach. If the change only touches files those tasks do not read, the right answer is an empty run that succeeds. Running the tasks anyway wastes time; failing turns a harmless change into a red build. | ✓ | ✓ | ✓ | ✗moon Step 3: ran lib#build, expected it replayed or left out | – | – |
An affected project’s build gets its unchanged dependency’s build first, from coldWhy this is rightSelection decides which tasks the user asked for; it does not remove the tasks they need first. An affected build reads its dependency’s output, so that dependency must be built, or restored, before it even though it did not change. Leaving it out fails the build on a clean machine, or reads a stale output left on disk. | ✓ | ✓ | ✓ | ✓ | – | – |
An input glob spelled ./src/** selects on an edit under src/Why this is rightUsers write input globs as | ✓ | ✗Nx Step 3: did not run lib#build, app#build | ✓ | ✗moon Step 3: exited 1, expected success | – | – |
A file an input’s !./src//** exclude leaves out selects nothingWhy this is rightAn exclude is part of what a task declares it reads, whatever its spelling. | ✓ | ✓ | ✓ | ✗moon Step 3: exited 1, expected success | – | – |
An input naming a bare directory (src) selects on an edit under itWhy this is rightNaming a directory such as | ✓ | ✓ | ✓ | ✓ | – | – |
A task that only aggregates others runs when a task it depends on is affectedWhy this is rightA task with no command of its own often groups others, like an | ✓ | ✗Nx Step 3: did not run app#all | ✓ | ✓ | – | – |
A task that only aggregates others is left out when nothing it depends on changedWhy this is rightA task that only groups others is affected only through them. When nothing it waits on can see the change, it has nothing to do. Selecting it anyway, because it reads no files of its own, runs all the grouped work on changes that never reach it. | ✓ | ✓ | ✓ | ✓ | – | – |
A task named toString is declared only where it is writtenWhy this is rightA task can be named anything, including | ✓ | ✗Nx Step 3: exited 1, expected success | ✓ | ✓ | – | – |
Any change in a project selects its task that is never cachedWhy this is rightA task that is never cached declares no inputs, so the tool cannot know which of its project’s files it reads. Any change in its project must select it, since any file may matter. Guessing a narrower set would skip a lint or check that the edit can make fail. | ✓ | ✓ | ✓ | ✓ | – | – |
A dependent whose task waits on nothing of the change is left outWhy this is rightA project dependency alone does not make every task depend on the other project. If the dependent’s build declares no edge to the dependency’s build and reads none of its files, the change cannot reach it. Selecting it anyway reruns work whose result is already known. | ✓ | ✓ | ✓ | ✓ | – | – |
A task with no edge to other projects runs only where the change isWhy this is rightA task with no edge to other projects reads only its own project’s files. A change in | ✓ | ✓ | ✓ | ✓ | – | – |
A task reaches a change through either arm of a diamond of task edgesWhy this is rightTask edges can form a diamond, where a task reaches a changed one through only one of two paths. The task must be selected if any path leads to the change. Following just one arm, or stopping at the first visited node, misses the task and leaves its result unchecked. | ✓ | ✗Nx Step 3: did not run app#top, app#seed | ✓ | ✓ | – | – |
A task whose edges never reach the changed task is left outWhy this is rightA task is affected only if one of its edges leads to a changed task. Tasks that share a project or a parent with the change are not enough. Selecting all of a project’s tasks when one of them changed reruns work that cannot see the edit, here | ✓ | ✓ | ✓ | ✓ | – | – |
A change to a root file a task names as an input selects that taskWhy this is rightA file at the workspace root belongs to no project, but a task that names it as an input depends on it. Shared config and schemas often live there. If a change to it selects nothing, the tasks that read it are skipped and their output stays built against the old file. | ✓ | ✓ | ✓ | ✓ | – | – |
A file in one project that another names as a root input selects bothWhy this is rightA task may read a file inside another project, declared as a root input. A change to that file affects both the project that owns it and the task that reads it. Selecting only the owner leaves the reading task built against the old content. | ✓ | ✓ | ✓ | ✓ | – | – |
Editing the root package.json in a way no task reads selects nothingWhy this is rightAn edit to the root | ✓ | ✓ | ✓ | ✓ | – | – |
A root file one task reads does not select its project’s other, never-cached taskWhy this is rightA root input belongs to the task that names it, not to every task in that task’s project. A never-cached | ✓ | ✓ | ✓ | ✓ | – | – |
Each task’s own root config file selects that task, not its siblingWhy this is rightEach task declares its own root inputs, so a root config file selects only the task that names it. Lumping all of a project’s root inputs together reruns | ✓ | ✓ | ✓ | ✓ | – | – |
An edit inside a nested repository a root input reaches selects the task naming itWhy this is rightA nested git repository appears in the outer one as a single path, or not at all, so its diff never lists the files inside. A task whose root input reaches into it still reads those files. Trusting the outer diff alone misses the edit and leaves the task built against the old content. | ✓ | ✗Nx Step 4: did not run tool#build, gen#build | ✗Turborepo Step 4: did not run tool#build, gen#build | ✗moon Step 4: did not run tool#build, gen#build | – | – |
An edit behind a directory link selects a task whose src/**/*.txt reaches through itWhy this is rightA directory link inside a project makes the files it points at part of what an input glob like | ✓ | ✗Nx Step 5: did not run app#txt | ✗Turborepo Step 5: did not run app#txt | ✗moon Step 5: did not run app#txt | – | – |
An edit behind a directory link leaves out a task whose glob stops above itWhy this is rightFollowing links must stay as precise as the glob itself. | ✓ | ✓ | ✓ | ✓ | – | – |
An edit behind a root directory link selects a task whose root input reaches through itWhy this is rightA root input such as | ✓ | ✗Nx Step 5: did not run app#txt | ✗Turborepo Step 5: did not run app#txt | ✗moon Step 5: did not run app#txt | – | – |
A change to a file another task of the project reads does not select its task that reads no filesWhy this is rightA task that declares empty inputs says no file in the project changes its result, as a task that only prints a version does. Here the changed file is one the project’s | ✓ | ✓ | ✗Turborepo Step 3: ran a#build, expected it replayed or left out | ✓ | – | – |
Outputs a task wrote and git does not track do not make its project affectedWhy this is rightRunning a build leaves its outputs in the tree, often without a | ✓ | ✓ | ✓ | ✓ | – | – |
Cache
42 cases100%42 of 4242 supported92%37 of 4240 supported80%34 of 4242 supported92%38 of 4241 supported97%34 of 4235 supported80%28 of 4235 supported
When a task is replayed instead of run. Replay too often and results are wrong; too rarely and every run pays the full time again.
| Case | vx | Nx | Turborepo | moon | Vite Task | Nix |
|---|---|---|---|---|---|---|
A cached test behind a dev server runs again when the server’s sources changeWhy this is rightA test behind a server checks the server as much as its own code. When the server’s sources change, the old pass says nothing about the new server. Replaying it vouches for a server the test never saw, and a broken server ships under a green run. | ✓ | ✓ | ✗Turborepo Step 1: exited 1, expected success | ✗moon Step 1: did not finish within 30 s | – | – |
A cached task depending on an uncached one that writes into its own inputs replays by the third runWhy this is rightAn uncached task that writes a file inside its own inputs changes those inputs the first time it runs. Its dependants may then miss once more, since their keys follow the dependency’s inputs. After that the file is stable, so by the third run the cached task must replay. A tool that never settles reruns the dependant on every build. | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 2: exited 100, expected success |
A cached task depending on an uncached one whose written file git ignores replays on the second runWhy this is rightA file git ignores is local state, not a declared input, so an uncached task writing one changes no key. The cached task that depends on it should replay on the second run, its output still holding what the dependency wrote. A tool that counts the ignored file, or treats every uncached dependency as changed, reruns the dependant on every build. | ✓ | ✓ | ✗Turborepo Step 2: ran web#build, expected it replayed or left out | ✓ | ✓ | ✗Nix Step 2: exited 100, expected success |
A run from inside a project replays what a run from the root storedWhy this is rightA task’s key comes from its inputs, not from where the run started. A run from inside a project asks for the same work as a run from the root, so it should replay what the root run stored. If the current directory leaked into the key, every developer who changes directory would rebuild the same tasks and store the same result twice. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A task with caching off runs on every runWhy this is rightTurning caching off is an explicit request to run the command every time. Such tasks often have effects beyond their outputs, such as a deploy, a migration or a fetch of fresh data. Replaying one would skip that effect while reporting success. The cost of running again is the price the user chose. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Changing a task’s command runs it againWhy this is rightThe command produces the output as much as the source files do, so it is part of the key. Changing a flag or a script line and getting the old result back makes the edit look like it did nothing. A developer then debugs a config that is already right while the cache serves output from the old one. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Changing a dependency’s command rebuilds the projects that read its outputWhy this is rightA dependency’s new command produces new output, and projects built from that output must rebuild. If only the dependency reruns, its dependants keep the output they built from its previous result. The tree then holds a library built one way and an app built against the other, under a green run. | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 3: did not run ui#build, app#build | ✓ |
Declaring a new output runs the task again so the cache holds itWhy this is rightAn entry saved before an output was declared does not hold that output, so it cannot be replayed as if it did. A hit here would restore a tree without the newly declared files, and anything that reads them fails or reads leftovers. Running once more stores the full set of outputs for later hits. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Committing a change keeps the entry the uncommitted run savedWhy this is rightA cache entry belongs to the content a task reads, not to whether that content is committed. Running a build and then committing the same files changes nothing the task reads. Missing here would make every commit pay again for work done minutes before, which is most of the time a developer spends waiting. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An edit that keeps a source’s length, right after a run, runs the task againWhy this is rightA cache key must come from a file’s bytes, not its size or timestamp. An edit that keeps the length, right after a run, can leave both the size and a coarse mtime unchanged. A runner that trusts them replays the old output, and the developer tests or ships a build that does not match the source. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Writing a source again with the same bytes replays the taskWhy this is rightA key built from content does not change when a file is written again with the same bytes. Formatters, branch switches and code generators often touch files without changing them. If a moved mtime alone reran the task, the cache would miss after every checkout or save, and CI would redo work whose result is already stored. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Adding an empty file under the inputs runs the task againWhy this is rightWhich files exist under the inputs is input too, even when a file is empty. A command such as | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Changing one byte in the middle of a binary input runs the task againWhy this is rightInputs are bytes, and a binary file must be hashed as bytes. A runner that reads it as text, stops at a zero byte or normalises its encoding can see two different files as one. A changed image, font or compiled asset would then replay the old output, and the shipped build would carry the old file. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Changing the last byte of a 2 MB input runs the task againWhy this is rightA key must cover the whole of every input, not a prefix or a sample. Hashing only the start of a large file is cheaper, but a change at its end, such as an appended record or a data table’s last row, goes unseen. The runner replays output built from the old file, and the change never reaches the build. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A task reading every project file replays though a sibling task wrote its gitignored output: ignored in the workspace’s .gitignoreWhy this is rightA gitignored file is local state, not source, so it is not an input even of a task that reads every project file. Here the build writes | ✓ | ✓ | ✗Turborepo Step 2: ran a#test, expected it replayed or left out | ✓ | ✓ | ✗Nix Step 1: report/a.txt is missing, expected "A1\n" |
A task reading every project file replays though a sibling task wrote its gitignored output: ignored in a .gitignore between the root and the projectWhy this is rightA gitignored file is local state, not source, so it is not an input even of a task that reads every project file. Here the build writes | ✓ | ✓ | ✗Turborepo Step 2: ran a#test, expected it replayed or left out | ✓ | ✓ | ✗Nix Step 1: report/a.txt is missing, expected "A1\n" |
A task reading every project file replays though a sibling task wrote its gitignored output: ignored in the project’s own .gitignoreWhy this is rightA gitignored file is local state, not source, so it is not an input even of a task that reads every project file. Here the build writes | ✓ | ✓ | ✗Turborepo Step 2: ran a#test, expected it replayed or left out | ✓ | ✓ | ✗Nix Step 1: report/a.txt is missing, expected "A1\n" |
A task reading every project file replays though a sibling task wrote its gitignored output: ignored in .git/info/excludeWhy this is rightA gitignored file is local state, not source, so it is not an input even of a task that reads every project file. Here the build writes | ✓ | ✓ | ✗Turborepo Step 2: ran a#test, expected it replayed or left out | ✓ | ✓ | ✗Nix Step 1: report/a.txt is missing, expected "A1\n" |
A task reading every project file runs again when the build it depends on changesWhy this is rightLeaving an ignored output out of the key must not hide a change behind it. The reader depends on the build, so its key folds in the build’s own inputs. When | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 3: report/a.txt is missing, expected "A2\n" |
Staging and then committing an edit keeps the entry the unstaged run savedWhy this is rightStaging and committing change git’s view of a file, not its bytes. The run before staging already stored a result for those bytes. A runner that keys a dirty file differently from a clean one misses after | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A same-size edit right after a commit runs the task againWhy this is rightRight after a commit, git cannot always tell a same-size edit from its stat, since both can fall in the same second (racy git). A runner that trusts git’s index there keys the new bytes as the committed blob, and replays the old build. The bytes on disk must be read whenever the stat cannot prove them unchanged. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Staging and committing a deleted input replays what the run after the deletion storedWhy this is rightDeleting a file, then staging and committing the deletion, leaves the same files on disk at each step. The run after the deletion stored a result for that tree. A runner that keys on git’s state, not the files, misses at each git step and rebuilds the same tree, which costs CI time on every pull request that deletes a file. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
In a repository with no commit yet the run caches, and the first commit keeps that entryWhy this is rightA new repository with no commit is a normal place to start a project, and its tasks still have inputs. A runner that needs a HEAD should not refuse to cache there, and the first commit changes no file a task reads. If the commit made every key change, the first push would rebuild the whole workspace for nothing. | ✓ | ✗Nx Step 4: ran a#build, expected it replayed or left out | ✓ | ✓ | ✓ | ✓ |
A task stored by a run of every project replays when the run names its project, or excludes anotherWhy this is rightWhich projects a run selects is not an input of a task: the same task on the same tree is one entry. A developer who builds everything, then only | ✓ | ✓ | ✓ | ✓ | ✓ | – |
A task an affected run selects replays what a run of every project storedWhy this is rightA run limited to changed projects asks for the same tasks on the same files as a full run. The full run already stored them, so the affected run should replay. A runner that keys on how a task was selected misses here, and CI rebuilds work that a full run on the same commit has already done. | ✓ | ✓ | ✓ | ✓ | – | – |
Other forwarded args run a cached task again, and the first args again leave their outputWhy this is rightForwarded args change what a task does, so they are part of its key. A run with new args must run the task again, or it replays output made for other args, such as tests for another filter. Going back to the first args must leave their own output, whether run again or replayed, never the output of the args in between. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A bare -- with no args replays what a run without it storedWhy this is rightA bare | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
New forwarded args for a task replay its dependency, which never sees themWhy this is rightArgs go to the task that was asked for; a dependency never sees them. A dependency keyed on them would miss on every new argument, though its command and output are the same. Running a test task with a new filter would then rebuild every library it depends on, for no change in what they produce. | ✓ | ✓ | ✗Turborepo Step 2: ran lib#build, expected it replayed or left out | ✓ | ✓ | ✓ |
A task switched to never cached runs, instead of replaying what it saved while cachedWhy this is rightWhen the user turns caching off for a task, the task must run every time. The entry saved while it was cached is exactly what they no longer want replayed. Replaying it would skip a task the user has marked as needing to run, such as one with side effects. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A cached task run with other forwarded args runs again instead of replayingWhy this is rightArguments passed through to a command change what it does. The output saved for | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A server only cached tasks need is not started when they all replayWhy this is rightA server is started only to serve the tasks that need it. When all of those tasks replay from the cache, nothing runs against it, so booting it wastes its whole start-up time. Here the server declares it reads no files, so its dependant can be keyed without it, and an edit to the dependant brings the server back. | ✓ | ✗Nx Step 2: ran api#dev, expected it replayed or left out | ✗Turborepo Step 1: exited 1, expected success | ✗moon Step 1: did not finish within 30 s | – | – |
A forced run runs every task past its cache entry, and the next run replays what it savedWhy this is rightForcing a run asks for every task to run regardless of what the cache holds, often because the user suspects an entry. The fresh results must then replace the saved ones. A force that saves nothing leaves the doubted entry in place for the next run to replay. | ✓ | ✓ | ✓ | ✓ | – | – |
A run that only reads the cache replays a hit, and saves nothing for a missWhy this is rightA read-only cache lets a run use what others built without writing, as an untrusted branch build should. A hit must still replay, or the mode saves nothing. A miss must run and leave the cache as it was, so the next normal run builds it again instead of replaying it. | ✓ | – | ✓ | ✓ | – | – |
A planned run runs no task, writes no output, and saves nothingWhy this is rightA planned run shows what would run without running it. No command may start, no output may be written and no cache entry may be saved. A plan that runs tasks surprises the user with side effects, and one that saves entries can later replay results nothing ever built. | ✓ | – | ✓ | – | – | ✓ |
A linked git worktree of the same commit replays what the main checkout builtWhy this is rightA linked git worktree at the same commit holds the same files as the main checkout. The task’s inputs are identical, so the result already built is right here too. Building again because the path differs makes every extra worktree start cold, which is the cost a shared cache should remove. | ✓ | ✓ | ✓ | ✗moon Step 3: wt/.ran exists, expected none | ✓ | ✓ |
A run started through a link to the workspace replays what the real path builtWhy this is rightOpening the workspace through a link leaves every file and the commit the same; only the path the shell shows differs. The task’s inputs are identical, so the saved result is right. Building again because of the path makes the same tree cost twice depending on how the user reached it. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Putting back the outputs a task first declared replays the entry saved for themWhy this is rightAn entry is saved under the task’s config as it was, so going back to that config finds it again. A team that tries a wider output set and reverts it should get its old hits back, not rebuild. The entry still holds every output the restored config names, so replaying it leaves the right tree. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Running a task with one set of args, then another, then each again replays bothWhy this is rightForwarded args are part of the key, so each set of args has its own entry. Running tests for one file, then another, then the first again is ordinary work. A cache that keeps only the last args reruns work whose result it already had, while keeping both costs nothing in correctness. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A command of multi-byte characters runs byte-exact, and changing its last character runs it againWhy this is rightA command is text of any script, and | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A forwarded arg of multi-byte characters reaches the command byte-exact and keys the taskWhy this is rightArgs forwarded to a task are its input as much as its files are. An emoji must reach the command as the same bytes the user typed, and a different emoji is a different run. A runner that mangles or truncates the arg feeds the command something else, or replays the result of one arg for another. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A forced run after an input edit saves under the new inputs, and the old inputs never replay its resultWhy this is rightForcing a run after editing a source must file the fresh result under the edited inputs, so the next run with those inputs replays it. Filing it under the entry the last run used means that putting the source back replays the result of the edit: a stale hit under a green run. A force that saves nothing leaves the next run to build the same thing again. | ✓ | ✗Nx Step 7: ran a#build, expected it replayed or left out | ✓ | ✓ | – | – |
An input built during a merge conflict replays once the conflict is resolved with the same bytesWhy this is rightA key is about the bytes a task reads. During a merge conflict git lists the file once per side, but the file on disk is one file. A tool that hashes each listing keys the same bytes differently before and after | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Inputs
303 cases100%303 of 303303 supported61%186 of 303303 supported63%187 of 303293 supported63%192 of 303303 supported60%177 of 303294 supported60%176 of 303293 supported
Which file changes make a task run again. Miss one and the cache replays an old result for new code: a green build that ships the wrong thing.
| Case | vx | Nx | Turborepo | moon | Vite Task | Nix |
|---|---|---|---|---|---|---|
Editing a root file a workspace-root task reads runs it againWhy this is rightThe workspace root can be a project with its own tasks, reading files no member owns. When such a file changes, the root task’s key changes and the task must run again. Replaying the cached result would ship output generated from the old file. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Editing a member’s file leaves a workspace-root task over ** replayedWhy this is rightA project owns the files under it except those inside a nested project. For the workspace-root project, every member is nested, so a root task over | ✓ | ✓ | ✗Turborepo Step 3: ran root#build, expected it replayed or left out | ✗moon Step 3: ran root#build, expected it replayed or left out | ✗Vite Task Step 3: ran root#build, expected it replayed or left out | ✗Nix Step 3: ran root#build, expected it replayed or left out |
An edit inside a nested repository a workspace-level input glob covers runs the task againWhy this is rightThe outer git records a nested repository as one path, or not at all, so it never lists the files inside. A task whose input glob covers them still reads those files. A key built only from the outer repository misses the edit and replays output built from the old content. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A second run over an unchanged tree replays instead of runningWhy this is rightWhen nothing a task reads has changed, its last result is still right, so replaying it is safe. Running it again spends CI minutes and local time on work whose answer is already known. Across a monorepo of many tasks, this is what lets a run with no changes finish in seconds instead of taking the full build time. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Editing an input runs the task againWhy this is rightA changed input means the stored result was computed from source that no longer exists. Replaying it would ship output built from the old code under a green run. Seeing every edit to a declared input is the most basic promise a cache makes, and every other saving depends on it. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A same-size edit right after a commit runs the task againWhy this is rightChange detection must rest on content, not on size and timestamps alone. An edit that keeps the file’s length and lands in the same second as a commit can look untouched to a check based on file stats. A tool that trusts such a check replays the old output, and the edit never reaches the build. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A new file under an input glob runs the task againWhy this is rightA glob names a set of files, and adding a file changes that set. A build that concatenates, bundles or tests everything under | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Deleting an input runs the task againWhy this is rightRemoving a file changes the input set just as editing one does. A task that bundles a directory must stop including the deleted file. Replaying the old result would ship code that is gone from the tree, such as a removed module or a test that should no longer run. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Renaming an input runs the task againWhy this is rightA file’s path is part of what a task reads, not only its bytes. A rename can change order, module names or import paths, so the output can differ even when no content changed. A key built only from the set of content hashes sees the same bytes and replays output that still names the old file. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An edit deep in a nested input directory runs the task againWhy this is rightA recursive glob covers every depth, and a change at any depth counts. A tool that stops walking early, or trusts a directory’s own timestamp, can miss an edit several levels down. Real sources nest deeply, so missing such an edit replays stale output for ordinary work. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An edit to an input named with spaces, $ and quotes runs the task againWhy this is rightFile names with spaces, quotes or | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An edit to an input with a non-ASCII name runs the task againWhy this is rightNon-ASCII file names are ordinary on every current file system and must map to the same path every time. git escapes such names in its plain output; a reader that does not undo the escaping loses the file from the key. Edits to it then replay the old output. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Undoing an edit replays the first run’s outputs, not the edited onesWhy this is rightA key must depend only on the current inputs, so going back to old inputs must give back the old outputs. After an undo, the tree must hold what the first run produced, not the edited run’s output. A tool that keeps whatever it wrote last leaves output from code that is no longer in the tree, under a green run. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A file outside the task’s inputs changes nothingWhy this is rightDeclared inputs are the contract: only those files can change the result. Rerunning a build because a README or another unrelated file changed wastes time on every docs edit. Explicit inputs are what let a cache stay warm across changes that cannot matter to the task. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A file a ! input pattern takes back changes nothingWhy this is rightA | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A gitignored file under an input glob changes nothingWhy this is rightFiles git ignores are local state such as logs, editor files and caches, not part of the source. They differ between machines and change on their own. Keying on them gives every machine a different key, so the cache misses on every CI runner and laptop even when the committed source is identical. | ✓ | ✓ | ✗Turborepo Step 5: ran a#build, expected it replayed or left out | ✓ | ✗Vite Task Step 5: ran a#build, expected it replayed or left out | ✗Nix Step 5: ran a#build, expected it replayed or left out |
A new file git does not track yet is an inputWhy this is rightA task reads what is on disk, not what git has recorded. A developer adds a file and builds before committing, and the new file belongs in the output. A tool that keys only on tracked files replays the old result until the next commit, hiding the new file from local builds. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Making an input executable runs the task againWhy this is rightThe file mode is part of what a task reads: a build that copies a script ships it runnable or not. A key that hashes content alone sees no change after | ✓ | ✗Nx Step 3: packages/a/dist/out.txt is "plain\n", expected "exec\n" | ✗Turborepo Step 3: packages/a/dist/out.txt is "plain\n", expected "exec\n" | ✗moon Step 3: packages/a/dist/out.txt is "plain\n", expected "exec\n" | ✗Vite Task Step 3: packages/a/dist/out.txt is "plain\n", expected "exec\n" | ✓ |
Making the file an input link points at executable runs the task againWhy this is rightThe link itself is unchanged; only the mode of the file it points to moved. The task reads that mode, so its output can change, as a script that checks whether a file is runnable does. A key that looks only at the link, or ignores modes, replays the old result under the new tree. | ✓ | ✗Nx Step 4: packages/a/dist/out.txt is "plain\n", expected "exec\n" | ✗Turborepo Step 4: packages/a/dist/out.txt is "plain\n", expected "exec\n" | ✗moon Step 4: packages/a/dist/out.txt is "plain\n", expected "exec\n" | ✗Vite Task Step 4: packages/a/dist/out.txt is "plain\n", expected "exec\n" | ✗Nix Step 4: packages/a/dist/out.txt is "plain\n", expected "exec\n" |
Two dependencies that swap contents, at the same output path, run the project built on them againWhy this is rightAfter the swap each dependency holds what the other had, so the set of files is the same but which name holds which is not. The consumer reads them by name and builds a different result. A key that folds the dependencies without their order sees nothing new and replays the old output. | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 3: packages/app/dist/out.txt is "x\ny\napp\n", expected "y\nx\napp\n" | ✓ |
Editing a dependency rebuilds the projects that read its outputWhy this is rightA project built from a dependency’s output must rebuild when that dependency changes. Otherwise the app keeps bundling the old library while the library itself is new, and the two disagree inside one green build. The mismatch is hard to spot because each project looks up to date on its own. | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 3: did not run ui#build, app#build | ✓ |
Editing one project does not rerun a project that does not depend on itWhy this is rightA change can only affect projects that read it, directly or through their dependencies. Rebuilding unrelated projects on every edit makes CI time grow with the size of the repo rather than the size of the change. In a large monorepo that is the difference between checking one package and rebuilding everything. | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 3: did not run app#build | ✓ |
An edit to a file outside a dependency’s build inputs does not rebuild its dependentsWhy this is rightA dependant is affected only when something its dependency’s build reads has changed. A test file in a library does not change the built library, so the app built from it stays the same. Rebuilding the whole chain on every test edit throws away cached work whose result is already known. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A project nested inside another keeps its files out of the outer project’s inputsWhy this is rightA project nested in another’s folder owns its own files. If the outer project’s | ✓ | ✓ | ✗Turborepo Step 3: ran outer#build, expected it replayed or left out | ✗moon Step 3: ran outer#build, expected it replayed or left out | ✗Vite Task Step 3: ran outer#build, expected it replayed or left out | ✗Nix Step 3: ran outer#build, expected it replayed or left out |
Pointing an input symlink at another file runs the task againWhy this is rightA task reads the file behind a link, so pointing the link somewhere else changes the input. A key built from the link’s own path, or from what it pointed at once, misses the change. The cache then replays output built from the old target while the tree points at the new one. | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 4: packages/a/dist/out.txt is "a\n1\n", expected "a\n2\n" | ✗Nix Step 2: exited 100, expected success |
A task that lists no inputs never replays a stale result when a file it reads changesWhy this is rightWhen a task lists no inputs, any default is a guess at what it reads. Here the command reads a file outside its project; when that file changes, a guessed key does not, and the cache replays the old bytes under a green run. Not caching such a task, or rerunning it, is right. Declaring inputs is how a task earns caching. | ✓ | ✗Nx Step 3: packages/a/dist/out.txt is "one\n", expected "two\n" | ✗Turborepo Step 3: packages/a/dist/out.txt is "one\n", expected "two\n" | ✗moon Step 3: packages/a/dist/out.txt is "one\n", expected "two\n" | ✓ | ✗Nix Step 1: exited 100, expected success |
A task that lists no inputs never replays a stale result when a variable it reads changesWhy this is rightA task that lists no inputs gives the tool no way to know which variables it reads. A cache keyed on files alone replays the output of a value the command no longer sees: | ✓ | ✗Nx Step 2: packages/a/dist/out.txt is "dev\n", a stale result | ✓ | ✗moon Step 2: packages/a/dist/out.txt is "dev\n", a stale result | ✓ | ✓ |
A task that lists no inputs runs again when its project’s sources changeWhy this is rightWhatever a tool does with a task that declares no inputs, it must not miss an edit to the project’s own sources. Running such a task every time is correct, and so is keying it on a default set that covers the edit. Replaying the result from before the edit would ship old code after a plain source change. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An edit deep under a brace alternative ending in ** runs the task againWhy this is rightA brace stands for its alternatives, so | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An input {src,lib} keys every file under both directoriesWhy this is rightEach brace alternative here names a directory, and a directory input keys its whole tree. | ✓ | ✗Nx Step 3: did not run a#build | ✗Turborepo Step 3: did not run a#build | ✗moon Step 3: did not run a#build | ✗Vite Task Step 3: did not run a#build | ✓ |
An input {./src,lib}/** keys files under srcWhy this is right
| ✓ | ✗Nx Step 3: did not run a#build | ✗Turborepo Step 3: did not run a#build | ✗moon Step 3: did not run a#build | ✗Vite Task Step 3: did not run a#build | ✓ |
An input {.,src}/*.txt keys a .txt file at the project’s topWhy this is right
| ✓ | ✗Nx Step 3: did not run a#build | ✗Turborepo Step 3: did not run a#build | ✗moon Step 3: did not run a#build | ✗Vite Task Step 3: did not run a#build | ✗Nix Step 1: exited 100, expected success |
A file no brace alternative names changes nothingWhy this is rightA brace limits the inputs to the paths its alternatives name. A file in a directory no alternative names is not what the task reads. Keying it anyway reruns the task on unrelated edits, wasting work on every developer machine and in CI. The brace is how the user narrows the set. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A , inside a class in a brace alternative is part of the name, not a cutWhy this is rightInside a character class, a | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A { inside a class in a brace alternative is part of the nameWhy this is rightInside a character class, | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A ! brace alternative with a , in a class takes that file backWhy this is rightA negation is parsed the same way as a positive glob, so | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An input src/\{b\}.txt keys the file named with the bracesWhy this is rightA backslash escapes a brace, so | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✓ | ✓ |
An input src/\{b\}.txt does not key src/b.txtWhy this is rightAn escaped brace is literal text, not an alternative, so | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✓ | ✓ |
An input written ./src/** keys the files under srcWhy this is right
| ✓ | ✗Nx Step 3: did not run a#build | ✓ | ✓ | ✓ | ✓ |
A negation written !./gen/** takes gen back out of the inputsWhy this is rightA negation written | ✓ | ✗Nx Step 3: ran a#build, expected it replayed or left out | ✗Turborepo Step 3: ran a#build, expected it replayed or left out | ✓ | ✓ | ✓ |
An input */. names each top directory’s whole treeWhy this is right
| ✓ | ✗Nx Step 3: did not run a#build | ✗Turborepo Step 3: did not run a#build | ✗moon Step 3: did not run a#build | ✗Vite Task Step 3: did not run a#build | ✗Nix Step 1: exited 100, expected success |
A negation !gen/. takes the whole gen tree backWhy this is rightA negation | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: exited 100, expected success |
An input src/**/ (a trailing slash) keys the files under srcWhy this is rightA trailing slash marks a pattern as a directory, so | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 1: exited 1, expected success | ✓ |
An input naming a directory, src, keys every file in its treeWhy this is rightA user who names a directory, | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 3: did not run a#build | ✓ |
An input naming a directory with a trailing slash, src/, keys its treeWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A negation naming a directory, !gen, takes its whole tree backWhy this is rightA negation naming a directory, | ✓ | ✓ | ✗Turborepo Step 3: ran a#build, expected it replayed or left out | ✗moon Step 3: ran a#build, expected it replayed or left out | ✗Vite Task Step 3: ran a#build, expected it replayed or left out | ✓ |
An input app/[ab]/*.js keys the directories the class namesWhy this is rightA character class | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An input app/[ab]/*.js does not key a directory the class leaves outWhy this is rightA character class matches only the characters it lists, so | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An input app/\[id\]/** keys the route directory named [id]Why this is rightFile-system routers name directories with brackets, such as | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✓ | ✓ |
An input app/\[id\]/*.js does not key the sibling app/i a class would matchWhy this is rightAn escaped | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✓ | ✓ |
A ! input listed before the glob it narrows still takes its file backWhy this is rightInputs are a set, not an ordered ignore file: the position of a negation in the list carries no meaning. A tool that applies a negation only to globs listed before it ignores this one. The excluded file then keys the task, and edits to it rerun the task for nothing. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A task whose inputs cover its own outputs still replays on the next runWhy this is rightA broad input such as | ✓ | ✓ | ✗Turborepo Step 2: ran a#build, expected it replayed or left out | ✓ | ✗Vite Task Step 2: ran a#build, expected it replayed or left out | ✗Nix Step 2: ran a#build, expected it replayed or left out |
A committed file under a nested node_modules is an input like any otherWhy this is rightIgnored files are local state, but a file committed to the repository is source, whatever directory it sits in. An install under | ✓ | ✗Nx Step 4: did not run a#build | ✓ | ✓ | ✓ | ✗Nix Step 1: exited 100, expected success |
A committed file under a dot directory (.github) is an input of **Why this is rightA committed file under a dot directory like | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A dotfile under an input glob (src/.env) is an inputWhy this is rightA dotfile such as | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A committed tsconfig.tsbuildinfo is an input of **Why this is rightA build-state file like | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A file whose name sorts after every other is still an inputWhy this is rightA file name can be any valid string, including one that sorts after every other. A scan that lists a directory as a sorted range with a fixed upper bound can stop just before it. The file drops out of the key, and its edits replay stale output. Names like that are rare, but a key must cover every name. | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✓ | ✓ |
Projects named like pkg-b, pkg.c and pkg0 keep their files out of pkg’s inputsWhy this is rightA project’s inputs stop at its own directory, and | ✓ | ✓ | ✗Turborepo Step 3: ran pkg#build, expected it replayed or left out | ✓ | ✗Vite Task Step 3: ran pkg#build, expected it replayed or left out | ✗Nix Step 3: ran pkg#build, expected it replayed or left out |
A directory beside a nested project whose name extends the nested one’s stays the outer project’s inputWhy this is rightOnly a nested project’s own directory is cut out of the outer project. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
The outer project’s own directory beside a nested project stays its inputWhy this is rightCutting a nested project out of its parent must remove only that directory. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A gitignored file named outright in the inputs is an inputWhy this is rightA broad glob skips ignored files, but an entry that names one asks for it outright, and explicit config beats the default. Generated files are often both ignored and read by a build. Skipping a named file replays a result built from its old content. The user said the task reads it. | ✓ | ✗Nx Step 3: did not run a#build | ✓ | ✓ | ✓ | ✓ |
A gitignored directory named outright in the inputs keys every file under itWhy this is rightA broad glob skips ignored files, but an entry that names an ignored directory asks for it and its whole tree. Generated output is often both ignored and read by the next task. Skipping it replays a result built from the old generated files. Explicit config beats the default. | ✓ | ✗Nx Step 3: did not run a#build | ✓ | ✗moon Step 3: did not run a#build | ✗Vite Task Step 3: did not run a#build | ✓ |
A file landing in a named input directory that did not exist runs the task againWhy this is rightA named input directory that does not exist yet is keyed as absent. A file landing in it changes what the task reads. A tool that keys a missing directory as nothing at all, the same before and after, replays output built without the new file. Absence is a fact the key must hold. | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 3: did not run a#build | ✓ |
Creating a named input file that did not exist runs the task againWhy this is rightA named input file that does not exist is keyed as absent, not left out. Creating it changes what the task reads. If a missing file left no trace in the key, the new file would change nothing and the old output would replay. Optional config files, such as a local override, start absent like this. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Input globs into directories that do not exist are no errorWhy this is rightInputs describe what a task may read; a directory that does not exist yet, or a glob through a file, simply matches nothing. Optional and generated paths are common in real configs. Failing the run over them would break builds on a clean checkout for no gain. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An input fix* keys the files it matches, not the tree of a directory it matchesWhy this is rightA glob like | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 3: ran a#build, expected it replayed or left out |
Each ** in src/**/x/** spans any depthWhy this is rightEach | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An input src/**/x/** does not key a file with no x directory above itWhy this is rightA pattern | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Two tasks of one project with different negations each key their own setWhy this is rightInputs belong to a task, not a project: two tasks of one project can read different files. Here | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A sibling task keying a file does not pull it into a task that takes it backWhy this is rightInputs belong to a task, so a file one task reads does not become another’s input. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Editing a declared file at the workspace root runs the task againWhy this is rightShared files at the workspace root, such as a base config, often shape a task as much as its own sources. A root input says the task reads them. If an edit there kept the old key, every project built from that config would replay output made under the old one. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A root file no root input names changes nothingWhy this is rightA task is keyed on what it declares, nothing more. A root file no root input names, or another project’s file, is not what this task reads. Keying the whole workspace would rerun every task on any edit anywhere, and the cache would rarely hit. Explicit inputs are what make a hit trustworthy. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A root input reaching into another project’s directory keys that fileWhy this is rightA root input is a workspace-relative path, and it can point into another project’s directory on purpose, for shared data. The declaration is explicit, so the file is an input. Ignoring it replays output built from the old file, though the user said the task reads it. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A gitignored file under a root input glob changes nothingWhy this is rightIgnored files are local state, and a broad root glob such as | ✓ | ✓ | ✗Turborepo Step 3: ran a#build, expected it replayed or left out | ✗moon Step 3: ran a#build, expected it replayed or left out | ✗Vite Task Step 3: ran a#build, expected it replayed or left out | ✗Nix Step 3: ran a#build, expected it replayed or left out |
A ! root input takes a root file backWhy this is rightA negated root input takes files back from the root globs, just as a negation does for project inputs. The user said this file does not affect the task. Keying it anyway reruns the task, and every task sharing the glob, whenever the file changes. A negation means the same at the root as anywhere else. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A root input naming a directory keys every file in its treeWhy this is rightA root input naming a directory means every file in its tree, as a project input does. A tool that matches only the directory entry keys nothing inside. Edits deep in the shared directory then replay output built from the old files. Naming a shared directory is the plainest way to say a task reads it. | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 3: did not run a#build | ✓ |
A gitignored root file named outright in the root inputs is an inputWhy this is rightA broad glob skips ignored files, but a root input that names one asks for it. A generated file at the workspace root, ignored by git, can be exactly what the task reads. Skipping it replays a result built from the old content. Explicit config beats the default skip rules. | ✓ | ✗Nx Step 3: did not run a#build | ✓ | ✓ | ✓ | ✓ |
A root file the task writes is not its own input, though a root input glob covers itWhy this is rightA root input glob can cover a file the same task writes at the root. Keying a task on what it wrote moves its key after every run, so it never replays. Declared outputs must be cut out of the inputs, at the root as in the project. An output is never its own task’s input. | ✓ | ✗Nx Step 2: ran a#build, expected it replayed or left out | ✗Turborepo Step 2: ran a#build, expected it replayed or left out | ✓ | ✗Vite Task Step 2: ran a#build, expected it replayed or left out | ✗Nix Step 2: ran a#build, expected it replayed or left out |
A root input glob over the task’s own project does not key its own outputsWhy this is rightA root input over the task’s own project also covers its outputs, here a timestamp it writes. If that file keyed the task, every run would change the key and nothing would ever replay. The task’s declared outputs stay out of its key, whichever glob reaches them. | ✓ | ✗Nx Step 2: ran a#build, expected it replayed or left out | ✗Turborepo Step 2: ran a#build, expected it replayed or left out | ✓ | ✗Vite Task Step 2: ran a#build, expected it replayed or left out | ✗Nix Step 2: ran a#build, expected it replayed or left out |
A root input packages/a/app/\[id\]/** keys the route directory named [id]Why this is rightEscaped brackets in a root input name a literal directory such as | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✓ | ✓ |
A variable keying an uncached dependency reruns its cached dependant when it changesWhy this is rightA task is keyed on its inputs whether it caches or not, and a dependant folds that key into its own. Here the dependency writes a file from | ✓ | ✗Nx Step 2: did not run b#build | ✓ | ✓ | ✓ | ✗Nix Step 1: exited 100, expected success |
An uncached dependency keyed on an unchanged variable lets its dependant replayWhy this is rightAn uncached dependency always runs, but its key is still defined by what it declares. With | ✓ | ✓ | ✗Turborepo Step 2: ran b#build, expected it replayed or left out | ✓ | ✓ | ✗Nix Step 1: exited 100, expected success |
A root file keying an uncached dependency reruns its cached dependant when editedWhy this is rightAn uncached dependency still has a key made of its declared inputs, root files included, and the cached task after it folds that key. Here the dependency copies a shared schema. If its root input were ignored because it does not cache, the dependant would replay output built from the old schema. | ✓ | ✗Nx Step 4: did not run b#build | ✓ | ✓ | ✓ | ✗Nix Step 1: exited 100, expected success |
An uncached dependency that lists no inputs never lets its dependant replay a stale resultWhy this is rightA task that lists no inputs is not cached: nothing says what it read, so its key is unknown, not unchanged. A default guess misses the root file this dependency copies. A dependant that folds the guess replays output built from the old schema; running it again is the safe answer. | ✓ | ✗Nx Step 4: packages/b/out.txt is "SCHEMA1\n", expected "SCHEMA2\n" | ✓ | ✗moon Step 4: packages/b/out.txt is "SCHEMA1\n", expected "SCHEMA2\n" | ✓ | ✗Nix Step 1: exited 100, expected success |
An uncached dependency declaring empty inputs lets its dependant replayWhy this is rightEmpty inputs is a declaration: the dependency is keyed on its command and config alone. Unlike a task that lists nothing, it states that no file affects it. The dependant can then replay, rather than rerun on every run only because its dependency never caches. | ✓ | ✓ | ✗Turborepo Step 2: ran b#build, expected it replayed or left out | ✓ | ✓ | ✗Nix Step 1: exited 100, expected success |
A file keying a same-project dependency reruns the task that depends on itWhy this is rightA dependency’s key carries its inputs to the tasks after it, inside one project as across projects. Here | ✓ | ✗Nx Step 3: did not run a#test | ✓ | ✓ | ✗Vite Task Step 3: did not run a#test | ✓ |
A file outside a same-project dependency’s inputs does not rerun its dependantWhy this is rightA file outside the dependency’s inputs, like | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A committed file another task declares as its output stays readable while that task runsWhy this is rightA tool that clears a task’s outputs before running it must not delete a committed file another task reads in parallel. Here | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: exited 100, expected success |
A task rewriting its own input ahead of a reader leaves the reader’s output currentWhy this is rightThe reader’s key must see the bytes the formatter wrote in this run, not the ones it found before. A runner that hashes every input up front keys the reader on the old | ✓ | ✗Nx Step 8: packages/a/dist/out.txt is "B", expected "A" | ✓ | ✓ | ✓ | ✗Nix Step 2: packages/a/dist/out.txt is "X", expected "A" |
A task depending on another project’s named task rebuilds when that task’s output changesWhy this is rightA task that depends on another task builds on what that task produced. When the upstream output changes, the dependant must run again, even though none of its own files changed. A runner that only looks at the dependant’s own inputs replays its old output, and the app ships with a generated file from an earlier seed. | ✓ | ✗Nx Step 4: packages/app/dist/out.txt is "A", expected "B" | ✓ | ✓ | ✗Vite Task Step 4: packages/app/dist/out.txt is "A", expected "B" | ✗Nix Step 2: exited 100, expected success |
A result built from an input another task edited mid-run is not replayed once the input is put backWhy this is rightA key taken over the old bytes must not be stored with an output built from the new ones. Here another task edits | ✓ | ✗Nx Step 3: packages/app/out.txt is "one\nmore\n", expected "one\n" | ✗Turborepo Step 3: packages/app/out.txt is "one\nmore\n", expected "one\n" | ✗moon Step 3: packages/app/out.txt is "one\nmore\n", expected "one\n" | ✗Vite Task Step 3: packages/app/out.txt is "one\nmore\n", expected "one\n" | ✗Nix Step 1: exited 100, expected success |
Tasks built over a dependency whose input was edited mid-run are not replayed once it is put backWhy this is rightA stale key does not stay in one task: it flows down the chain. Each dependant folds the dependency’s key from before the edit, while its output carries the edit itself. If the dependency’s entry is not dropped, every task built on it is stored wrong too, and putting the file back replays the whole chain from a state that no longer exists. | ✓ | ✗Nx Step 3: packages/app/pkg/p.txt is "two\n", expected "one\n" | ✗Turborepo Step 3: packages/app/pkg/p.txt is "two\n", expected "one\n" | ✗moon Step 3: packages/app/pkg/p.txt is "two\n", expected "one\n" | ✗Vite Task Step 3: packages/app/pkg/p.txt is "two\n", expected "one\n" | ✗Nix Step 1: exited 100, expected success |
A change in what a key command prints runs the task againWhy this is rightA key command is how a task declares an input that is not a file, such as a tool’s version or a generated value. Its printed answer is part of the key, so a new answer must run the task again. Otherwise the runner replays output built for the old answer, for example a build made with a compiler version that is no longer installed. | ✓ | ✓ | – | ✓ | – | – |
A key command printing the same as before lets the task replayWhy this is rightA key command makes its answer part of the key, not a reason to always run. If it prints the same as before, nothing the task depends on has changed, and the stored result is still right. A runner that treats any key command as uncacheable rebuilds on every run, and the declared input buys only wasted time. | ✓ | ✓ | – | ✓ | – | – |
A key command that exits non-zero fails the run without running the taskWhy this is rightWithout its answer the key is unknown, so the task can neither replay nor run safely. Running it and storing the result under a guessed key, or replaying an old entry, is how stale bytes reach a green build. A command that fails also often means a tool is missing or broken. The run must stop and say so. | ✓ | ✗Nx Step 1: succeeded, expected a failure | – | ✓ | – | – |
A key command two projects share is run once per runWhy this is rightA key command reads the state of the machine, which does not change during a run. When two projects declare the same command, its answer is the same for both, so running it twice only costs time. A slow command, such as asking a toolchain for its version, would otherwise be paid once per project on every run. | ✓ | ✗Nx Step 1: dist/count is "xx", expected "x" | – | ✗moon Step 1: dist/count is "xx", expected "x" | – | – |
A gitignored .env.local keyed through a command’s output reruns the task when edited or removedWhy this is rightA gitignored file is local state and is no input by default, which is right. A user who wants such a file to count can say so through a key command, and that explicit choice must hold. Editing or removing | ✓ | ✓ | – | ✓ | – | – |
A key command reading a dependency’s output answers after that dependency ranWhy this is rightA key command must answer for the state the task will run in. If it reads a dependency’s output before that dependency has run, it answers for the previous run. The task then builds from the new output but is stored under the old answer, and a later run replays the wrong result. The command must wait for the dependencies. | ✓ | ✗Nx Step 8: packages/app/dist/out.txt is "B", expected "A" | – | ✓ | – | – |
A task reading a same-project dependency’s output, its inputs excluding it, rebuilds when the source changesWhy this is rightA task may leave its dependency’s output out of its own inputs, since that output follows from the dependency’s inputs. Its key must then fold in the dependency’s key. When | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A task reading a same-project dependency’s output replays when nothing changedWhy this is rightWhen nothing changed, both the build and the task that reads its output have results already stored. Folding the dependency’s key, rather than hashing its output after each run, lets the reader replay. A runner that cannot replay here reruns a test suite on every run though no source moved. | ✓ | ✓ | ✗Turborepo Step 2: ran a#test, expected it replayed or left out | ✓ | ✗Vite Task Step 2: ran a#test, expected it replayed or left out | ✗Nix Step 2: ran a#test, expected it replayed or left out |
An untracked directory under an input glob that the user may not enter is no errorWhy this is rightA directory the user may not enter holds nothing git or the task can read, so it is no input. It is often left by another tool, a container or a mounted volume. Failing the run there blocks every build over a folder unrelated to the source. The case runs as a normal user, since root can enter every directory. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An input list holding only a ! pattern never replays a stale resultWhy this is rightAn input list of only | ✓ | ✓ | ✗Turborepo Step 3: packages/a/dist/out.txt is "a\n", expected "a2\n" | ✗moon Step 3: packages/a/dist/out.txt is "a\n", expected "a2\n" | ✗Vite Task Step 3: packages/a/dist/out.txt is "a\n", expected "a2\n" | ✓ |
An input !!vendor/** never turns the rest of the inputs offWhy this is rightTwo | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A directory named like an ignored one, my_node_modules, is an inputWhy this is rightSkipping directories such as | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A project nested several directories down keeps its files out of the outer project’s inputsWhy this is rightA nested project owns its files at any depth, not only directly under the outer project. If the outer project’s | ✓ | ✓ | ✗Turborepo Step 3: ran a#build, expected it replayed or left out | ✗moon Step 3: ran a#build, expected it replayed or left out | ✗Vite Task Step 3: ran a#build, expected it replayed or left out | ✗Nix Step 3: ran a#build, expected it replayed or left out |
A gitignored directory named in the inputs keys its own files, not a project nested in itWhy this is rightNaming a gitignored directory in the inputs opts its files back in, but only the outer project’s own files. A project nested inside that directory still owns its files, and the outer task never reads them. Keying on them reruns the outer task on every edit in the nested project. | ✓ | ✓ | ✗Turborepo Step 3: ran a#build, expected it replayed or left out | ✓ | ✗Vite Task Step 3: ran a#build, expected it replayed or left out | ✗Nix Step 3: ran a#build, expected it replayed or left out |
A glob over the sibling of a nested project, genx beside gen, keys itWhy this is rightDirectory names are compared whole, not as text prefixes: | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A glob over the whole project with the nested project taken back keys the restWhy this is rightTaking a nested directory out with | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A nested project named pkg* does not take its sibling pkg-b out of the outer inputsWhy this is rightA nested project’s directory is a path, not a glob, even when its name holds | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✓ | ✓ |
A root output inside the task’s own project is not its own input, though its inputs cover itWhy this is rightA file the task writes is its output, never its input, even when an input glob such as | ✓ | ✗Nx Step 2: ran a#build, expected it replayed or left out | ✗Turborepo Step 2: ran a#build, expected it replayed or left out | ✓ | ✗Vite Task Step 2: ran a#build, expected it replayed or left out | ✗Nix Step 2: ran a#build, expected it replayed or left out |
A task naming its own output file among its inputs still replaysWhy this is rightA task’s own output is never its input, even when the user lists it among the inputs. Keying on it means each run changes the key of the next, so the task never replays. Refusing the overlap as a config error is fine; keying the task on what it writes is not. | ✓ | ✓ | ✗Turborepo Step 2: ran a#build, expected it replayed or left out | ✓ | ✗Vite Task Step 2: ran a#build, expected it replayed or left out | ✗Nix Step 2: ran a#build, expected it replayed or left out |
An input naming the directory gen does not key the file gen-a.txt beside itWhy this is rightAn input naming the directory | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 5: did not run a#build | ✓ |
A gitignored file gen named in the inputs is keyed beside a tracked gen-notes.txtWhy this is rightNaming a gitignored file in the inputs is an explicit request to key on it, and explicit config beats the ignore file. Here the task reads the ignored | ✓ | ✗Nx Step 3: did not run a#build | ✓ | ✓ | ✓ | ✓ |
A key command reading a file an uncached dependency rewrites answers after it ranWhy this is rightA key command must be asked after the tasks the build depends on have run, because they may change what it reads. Asked up front, it answers for the bytes before the rewrite, and the build is filed under that answer. A later run that starts from those bytes then replays a build for another version. | ✓ | ✗Nx Step 3: packages/app/dist/out.txt is "2", expected "1" | – | ✓ | – | – |
A key command printing a different byte that is not UTF-8 runs the task againWhy this is rightA key command’s output is bytes, and every byte must count. Decoded as text, | ✓ | ✓ | – | ✗moon Step 4: packages/a/out.txt is "e9", expected "e8" | – | – |
Colour, short status and quoted paths in the repository’s git config still let an edit rerunWhy this is rightRepository git settings are the user’s to choose and must not change what a task runner sees. A tool that parses the text of | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An edit to a committed file inside the task’s own output directory runs it againWhy this is rightA committed file the task reads is an input, even when it sits inside a directory declared as output. Left out of the key as an output, an edit to it changes nothing the tool sees. The run then replays the old build and writes the old generated file over the new data. | ✓ | ✓ | ✓ | ✗moon Step 4: packages/a/data/gen.json is "[\"a\"]", expected "[\"b\"]" | ✓ | ✓ |
A skip-worktree input absent from disk, as a sparse checkout leaves it, reruns the task once it appearsWhy this is rightA sparse checkout leaves some tracked files off disk; that is not a change by anyone. When the file appears, the task reads new content and must rerun. A tool that trusts git’s skip-worktree flag never looks at the file and replays the build made without it. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Under * text=auto, changing an input from CRLF to LF runs the task againWhy this is rightUnder | ✓ | ✓ | ✗Turborepo Step 4: packages/a/dist/out.txt is "10\n", expected "8\n" | ✓ | ✓ | ✓ |
A root .gitattributes text rule reaches a run of one project: CRLF to LF runs it againWhy this is rightA root | ✓ | ✓ | ✗Turborepo Step 4: packages/a/dist/out.txt is "10\n", expected "8\n" | ✓ | ✓ | ✓ |
A text rule in the user’s global git attributes file: CRLF to LF runs the task againWhy this is rightText rules can also come from the user’s global git attributes file, outside the repo. Under such a rule git sees CRLF and LF as one blob, while the task reads different bytes. A tool that trusts git’s view replays the old build, and only on machines with that setting, which makes the stale result hard to reproduce. | ✓ | ✓ | ✗Turborepo Step 4: packages/a/dist/out.txt is "10\n", expected "8\n" | ✓ | ✓ | ✓ |
With core.autocrlf set, changing an input from CRLF to LF runs the task againWhy this is right
| ✓ | ✓ | ✗Turborepo Step 4: packages/a/dist/out.txt is "10\n", expected "8\n" | ✓ | ✓ | ✓ |
With git's core.trustctime=false, a same-size edit that keeps its mtime runs the task againWhy this is rightWith | ✓ | ✗Nx Step 4: packages/a/dist/out.txt is "AAAA", expected "BBBB" | ✓ | ✓ | ✓ | ✓ |
With git's core.checkStat=minimal, a same-size edit that keeps its mtime runs the task againWhy this is rightWith | ✓ | ✗Nx Step 4: packages/a/dist/out.txt is "AAAA", expected "BBBB" | ✓ | ✓ | ✓ | ✓ |
A consumer reading what its dependency writes into it as a root output builds each versionWhy this is rightA file one task writes into another project mid-run must be keyed as it is when the reader runs. Keyed before it lands, the consumer’s key reflects the previous version, and the run replays a build made from another version. Cycling through versions shows the mismatch. A tool may refuse an input over another task’s output instead. | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 6: packages/app/dist/out.txt is "vA", expected "vB" |
An edit to a committed input whose name is not UTF-8 never replays the old buildWhy this is rightFile names on Linux are bytes and need not be valid UTF-8. Committed, such a name reaches a tool through git, which escapes it in its plain output; a tool that cannot decode it may drop the file from the key, and its edit replays the old build. Refusing the name is acceptable; ignoring the file is not. | ✓ | ✗Nx Step 4: packages/a/dist/all is "v1bad1", expected "v1bad2" | ✓ | ✗moon Step 4: packages/a/dist/all is "v1bad1", expected "v1bad2" | ✗Vite Task Step 2: packages/a/dist/all is missing, expected "v1bad1" | ✓ |
An edit to an untracked input whose name is not UTF-8 never replays the old buildWhy this is rightFile names on Linux are bytes and need not be valid UTF-8. Untracked, such a name is found only by walking the disk; a tool that cannot decode it may drop the file from the key, and its edit replays the old build. Refusing the name is acceptable; ignoring the file is not. | ✓ | ✗Nx Step 4: packages/a/dist/all is "v1bad1", expected "v1bad2" | ✓ | ✗moon Step 4: packages/a/dist/all is "v1bad1", expected "v1bad2" | ✗Vite Task Step 2: packages/a/dist/all is missing, expected "v1bad1" | ✓ |
An edit to a root input whose name is not UTF-8 never replays the old buildWhy this is rightRoot inputs are matched like project inputs, and a name that is not UTF-8 must count there too. Shared files at the root are often read by many tasks. A tool that drops the odd name from the key replays old builds in every task that reads it. Refusing the name is acceptable; skipping it is not. | ✓ | ✗Nx Step 4: packages/a/dist/all is "obad1", expected "obad2" | ✓ | ✓ | ✗Vite Task Step 2: packages/a/dist/all is missing, expected "obad1" | ✓ |
A name that is not UTF-8 in one project does not stop another project’s taskWhy this is rightA problem in one project’s files must not stop unrelated projects. A tool that fails the whole workspace on one odd file name blocks every build until someone renames it. Refusing the project that holds the name, or keying the name as bytes, both let project | ✓ | ✓ | ✗Turborepo Step 2: did not run b#build | ✓ | ✗Vite Task Step 2: did not run b#build | ✓ |
An input whose name holds U+FFFD as a character is an input like any otherWhy this is rightU+FFFD is a valid character that a file name can hold, not a sign of a broken name. A tool that uses it to mark bytes it could not decode, and then drops such names, loses a real file from the key. Edits to it then replay the old output, under a green run. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A file dropped by hand into a dependency’s output directory does not rerun its dependantsWhy this is rightA dependant keys on what its dependency is built from, not on every file sitting in the dependency’s output directory. A stray file dropped there by hand does not change what the dependency produces. Rerunning the whole chain for it wastes time, and the dependency’s replay restores its own outputs anyway. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A new file under a root input glob runs the task againWhy this is rightA root input glob names a set of files, and adding a file to it is a change. A new file under shared | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A task whose inputs cover another task’s output never replays a build of that output’s old bytesWhy this is rightWhen one task’s inputs cover another task’s output, the key must see that output as it is when the reader runs. Keyed before it lands, the reader’s key reflects the previous contents, and the run replays a build of old generated code. Switching the seed back and forth shows it. A tool may refuse the overlap instead. | ✓ | ✗Nx Step 3: packages/a/dist/out.txt is "one\n", expected "two\n" | ✓ | ✓ | ✓ | ✗Nix Step 3: packages/a/dist/out.txt is "one\n", expected "two\n" |
In projects that depend on each other, editing one reruns only its taskWhy this is rightProjects that depend on each other through their manifests still own separate files. A task that reads only its own sources, with no task dependency, need not rerun when the other project changes. Treating the cycle as one unit reruns both on every edit, or refuses a workspace the package manager accepts. | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✗Vite Task Step 1: exited 1, expected success | ✓ |
An input a dependency deletes before its dependant starts lets the dependant runWhy this is rightA file a dependency deletes before the dependant runs is simply absent when the dependant reads its inputs. The right key treats it as gone. A tool that hashed the inputs up front fails on the missing file, or keys content that no longer exists and later replays a listing that still names it. | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: packages/b/out.txt is "gen.txt\nkeep\n", expected "keep\n" |
Edits behind an input directory that is a link, and behind a linked root input directory, rerunWhy this is rightA task reads through a linked directory to the files behind it, and shared code is often linked into a project this way. A tool that keys on the link itself, or skips links while walking, sees no change when the files behind it are edited. The run then replays the old build. | ✓ | ✗Nx Step 5: packages/a/out.txt is "V1\nW1\n", expected "V2\nW1\n" | ✓ | ✗moon Step 5: packages/a/out.txt is "V1\nW1\n", expected "V2\nW1\n" | ✓ | ✗Nix Step 3: exited 100, expected success |
Edits behind links below a glob’s fixed directory, one of them never committed, rerunWhy this is rightLinks can sit anywhere below a glob’s fixed part, and some are made after the last commit. The task reads through all of them. A tool that walks only the fixed directory, or lists only what git tracks, misses edits behind these links and replays the old build. | ✓ | ✗Nx Step 6: packages/a/out.txt is "V1\nM1\nW1\n", expected "V2\nM1\nW1\n" | ✓ | ✗moon Step 6: packages/a/out.txt is "V1\nM1\nW1\n", expected "V2\nM1\nW1\n" | ✓ | ✗Nix Step 4: exited 100, expected success |
An edit behind a brace alternative’s directory that is a link rerunsWhy this is rightA brace pattern like | ✓ | ✗Nx Step 5: packages/a/out.txt is "V1\nW1\n", expected "V2\nW1\n" | ✗Turborepo Step 5: packages/a/out.txt is "V1\nW1\n", expected "V2\nW1\n" | ✗moon Step 5: packages/a/out.txt is "V1\nW1\n", expected "V2\nW1\n" | ✗Vite Task Step 5: packages/a/out.txt is "V1\nW1\n", expected "V2\nW1\n" | ✗Nix Step 3: exited 100, expected success |
An edit behind a link inside a gitignored input directory rerunsWhy this is rightNaming an ignored directory in the inputs opts it in, and links inside it still lead to files the task reads. Generated folders often hold links to shared code. A tool that walks the ignored folder but does not follow its links misses edits behind them and replays the old build. | ✓ | ✗Nx Step 5: packages/a/out.txt is "V1\nW1\n", expected "V2\nW1\n" | ✗Turborepo Step 5: packages/a/out.txt is "V1\nW1\n", expected "V2\nW1\n" | ✗moon Step 5: packages/a/out.txt is "V1\nW1\n", expected "V2\nW1\n" | ✗Vite Task Step 5: packages/a/out.txt is "V1\nW1\n", expected "V2\nW1\n" | ✗Nix Step 3: exited 100, expected success |
A committed edit to a tracked file an input link out of the project points at rerunsWhy this is rightA link that points outside the project still names a file the task reads. Committing the edit behind it must not hide it: git sees the link itself unchanged, since only the target’s content moved. A tool that keys on git’s record of the link replays the build made from the old target. | ✓ | ✓ | ✗Turborepo Step 6: packages/a/out.txt is "V1\n", expected "V2\n" | ✓ | ✗Vite Task Step 6: packages/a/out.txt is "V1\n", expected "V2\n" | ✗Nix Step 3: exited 100, expected success |
An edit behind an input link to a file outside the project rerunsWhy this is rightA task reads the contents behind an input link, wherever that file lives. A tool that keys on the link’s own text sees the same target path after an edit. The run then replays output built from the old contents, though the file the task reads has changed. | ✓ | ✗Nx Step 5: packages/a/out.txt is "O1\n", expected "O2\n" | ✗Turborepo Step 5: packages/a/out.txt is "O1\n", expected "O2\n" | ✓ | ✗Vite Task Step 5: packages/a/out.txt is "O1\n", expected "O2\n" | ✗Nix Step 3: exited 100, expected success |
An edit to a gitignored file an input link points at rerunsWhy this is rightA link listed as an input makes its target an input, even when the target is gitignored: explicit inputs beat ignore rules. A tool that keys only on tracked or unignored content misses the edit behind the link. The run then replays the build made from the old target. | ✓ | ✗Nx Step 5: packages/a/out.txt is "S1\n", expected "S2\n" | ✗Turborepo Step 5: packages/a/out.txt is "S1\n", expected "S2\n" | ✓ | ✗Vite Task Step 5: packages/a/out.txt is "S1\n", expected "S2\n" | ✗Nix Step 3: exited 100, expected success |
An edit, and then a new file, behind a linked input directory rerunWhy this is rightFiles behind a linked input directory count like files in a real one, including new ones. A tool that records the directory’s content once, or only what git tracks, misses a file added behind the link. The task’s glob reads it, so the replayed output is missing it. | ✓ | ✗Nx Step 6: packages/a/out.txt is "V1\n", expected "V2\n" | ✗Turborepo Step 6: packages/a/out.txt is "V1\n", expected "V2\n" | ✗moon Step 6: packages/a/out.txt is "V1\n", expected "V2\n" | ✗Vite Task Step 6: packages/a/out.txt is "V1\n", expected "V2\n" | ✗Nix Step 3: exited 100, expected success |
An edit behind a link found inside a linked input directory rerunsWhy this is rightLinks can chain: a linked directory may itself hold links. The task follows every one of them when it reads. A tool that resolves only the first level keys a link it cannot see through, and edits behind the second link replay the old build. Shared folders linked from several places often look like this. | ✓ | ✗Nx Step 5: packages/a/out.txt is "O1\nZ1\n", expected "O2\nZ1\n" | ✗Turborepo Step 5: packages/a/out.txt is "O1\nZ1\n", expected "O2\nZ1\n" | ✗moon Step 5: packages/a/out.txt is "O1\nZ1\n", expected "O2\nZ1\n" | ✗Vite Task Step 5: packages/a/out.txt is "O1\nZ1\n", expected "O2\nZ1\n" | ✗Nix Step 3: exited 100, expected success |
A dangling input link reruns its task once the file it names appearsWhy this is rightA dangling link is an input whose target is missing, and that missing state belongs in the key. When the target appears, the task reads new content and must rerun. A tool that skips dangling links keys the same before and after, and keeps replaying output that says the file is absent. | ✓ | ✓ | ✗Turborepo Step 5: packages/a/out.txt is "none\n", expected "N\n" | ✓ | ✗Vite Task Step 5: packages/a/out.txt is "none\n", expected "N\n" | ✗Nix Step 5: packages/a/out.txt is "none\n", expected "N\n" |
Input links that point at each other are no error, and an unchanged tree replaysWhy this is rightLinks that point at each other are legal on disk and turn up in real repos by mistake. A tool that follows them without a limit hangs or crashes, and one that treats them as an error fails a build that runs fine. Keying them as unresolved links lets an unchanged tree replay like any other. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An input made unreadable after an edit fails the task or rebuilds it, never replaysWhy this is rightA file the tool cannot read cannot key the task, so the tool must not act as if nothing changed. Leaving it out of the key replays the build from before the edit. Failing the run or rebuilding are both honest outcomes the user can see; a replay hides the edit. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Editing an input git was told to assume unchanged runs the task againWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Editing an input git was told to skip in the work tree runs the task againWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A same-size edit that keeps the file’s modification time runs the task againWhy this is rightSize and modification time are a hint for when to look again, not the content. Copying with | ✓ | ✗Nx Step 3: did not run a#build | ✓ | ✓ | ✓ | ✓ |
A same-size edit within the same whole-second stamp as the first write runs the task againWhy this is rightext3, HFS+, FAT and some network mounts keep only whole seconds. Two writes in one second then carry one stamp, and a same-size edit looks untouched. A tool that trusts the stamp replays stale output, a bug that only shows after quick edits such as a script or a fast save. | ✓ | ✗Nx Step 4: did not run a#build | ✓ | ✓ | ✓ | ✓ |
An input edited while the task runs is not replayed under the key of the bytes beforeWhy this is rightThe output was made from bytes edited during the run, not the ones read when the key was taken. Stored under the first bytes’ key, undoing the edit later replays the edited result. The tool must notice the change and not file the output under a key it does not match. | ✓ | ✗Nx Step 3: packages/a/dist/out.txt is "EDITED\n", expected "a\n" | ✗Turborepo Step 3: packages/a/dist/out.txt is "EDITED\n", expected "a\n" | ✗moon Step 3: packages/a/dist/out.txt is "EDITED\n", expected "a\n" | ✗Vite Task Step 3: packages/a/dist/out.txt is "EDITED\n", expected "a\n" | ✗Nix Step 3: packages/a/dist/out.txt is "EDITED\n", expected "a\n" |
An edit to a project inside a nested repository (a submodule) runs its task againWhy this is rightA submodule is held by the outer repository as one entry, a commit id, not as files. None of the project’s files are the outer git’s to list. A tool that keys the project from the outer index sees nothing change, and edits inside the submodule replay stale output. | ✓ | ✓ | ✓ | ✗moon Step 5: did not run b#build | ✓ | ✓ |
An edit to a project inside a nested repository (untracked) runs its task againWhy this is rightA nested repository the outer one does not track is invisible to the outer git’s listing, yet its files are still the project’s sources. A tool that relies on the outer git alone keys none of them. Every edit then replays the old output, and the project never rebuilds until its cache is cleared. | ✓ | ✓ | ✓ | ✗moon Step 5: did not run b#build | ✓ | ✓ |
An edit inside a nested repository (a submodule) under a project’s input glob runs its task againWhy this is rightThe outer git holds a vendored submodule as one path pinned to a commit, but its working files can still change. Those files are what the task reads. Keying the gitlink alone misses edits made inside it, which replay stale output until someone commits the submodule. | ✓ | ✓ | ✓ | ✗moon Step 5: did not run a#build | ✓ | ✓ |
An edit inside a nested repository (untracked) under a project’s input glob runs its task againWhy this is rightAn untracked nested repository under a project’s input glob is a directory of files the task reads. The outer git skips it as a separate repository. A tool that relies on that listing drops the files from the key, and their edits replay a stale result. | ✓ | ✓ | ✓ | ✗moon Step 5: did not run a#build | ✓ | ✓ |
A file a nested repository tracks despite its own ignore rule is an inputWhy this is rightAn ignore rule only stops untracked files: one a repository force-added is source like any other. That holds inside a nested repository too. A tool that applies the nested | ✓ | ✗Nx Step 5: did not run a#build | ✓ | ✗moon Step 5: did not run a#build | ✓ | ✓ |
An input glob over a nested project never replays a stale resultWhy this is rightAnother project’s files belong to it, so a glob reaching into a nested project is a boundary question. A tool may refuse the glob, or key on those files. Silently dropping them while the command still reads them replays a stale result, which is the one wrong answer. | ✓ | ✗Nx Step 3: packages/a/dist/out.txt is "d1\n", a stale result | ✓ | ✓ | ✓ | ✓ |
An input naming a file of a nested project never replays a stale resultWhy this is rightAn input that names one file of a nested project is explicit, but the file belongs to that other project. A tool may refuse the path or key on it. Accepting the name and then keying nothing replays output built from the old file, with no sign that anything was skipped. | ✓ | ✗Nx Step 3: packages/a/dist/out.txt is "d1\n", a stale result | ✓ | ✓ | ✓ | ✓ |
A brace input reaching a nested project never replays a stale resultWhy this is rightA brace alternative can reach a nested project as easily as a plain glob. A tool must apply the same rule to it: refuse it, or key the file. Expanding the brace without that check lets the nested file change unseen and replays a stale result. | ✓ | ✗Nx Step 3: packages/a/dist/out.txt is "d1\n", a stale result | ✓ | ✓ | ✓ | ✓ |
A ! input takes its files back from every positive glob that matched themWhy this is rightA negated input removes its files from the task’s whole input set, not only from the glob listed next to it. If the exclusion applied to one positive glob only, a spec file matched by another glob would still key the build. Every edit to a spec file would then rebuild output that never reads it. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An input src/* keys the dotfile src/.envWhy this is rightAn input glob lists the files a task reads, so | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A cached task declaring empty inputs replays after a project file changesWhy this is rightEmpty inputs is an explicit statement that the task reads no file, so it is keyed on its command and config alone. It differs from listing no inputs at all, which says nothing and is not cached. Rerunning it on every project edit would ignore what the user declared and waste the run the cache was meant to save. | ✓ | ✓ | ✗Turborepo Step 3: ran a#build, expected it replayed or left out | ✓ | ✓ | ✗Nix Step 1: exited 100, expected success |
An input src/*:*.txt keys a file whose name holds a colonWhy this is rightA colon is a plain character in a POSIX file name, so a wildcard must match it like any other. A tool that reads it as a drive or scheme separator drops the file from the key. Edits to that file then replay the old output, and the build ships content that no longer matches the source. | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✗Vite Task Step 1: exited 1, expected success | ✓ |
An input naming src/a:b.txt outright keys it, untracked or committedWhy this is rightAn input that names a file outright asks for exactly that file, whatever characters its name holds and whether git tracks it yet. A colon must not be parsed as a separator. Skipping the file lets edits to it replay a stale result, both before and after it is committed. | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 2: exited 1, expected success | ✓ |
A !src/a:b.txt input takes the file with the colon backWhy this is rightA negated input names a file the user said does not affect the task, and that holds for a name with a colon too. If the negation fails to parse, the file stays in the key. Every edit to it then reruns work that cannot change the output, on every machine and in CI. | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✗Vite Task Step 1: exited 1, expected success | ✓ |
An input named with a backslash and a double quote is keyedWhy this is rightA file name can hold a backslash or a double quote, and it must be keyed by its real bytes. Listed without | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 1: did not run a#build | ✓ |
An input whose name opens with a byte-order mark is keyed, untracked and committedWhy this is rightA file name is the exact bytes on disk, including a leading U+FEFF byte-order mark. A decoder that strips the mark loses the name, so the file drops out of the key. Every edit to it then replays the old output, whether the file is untracked or committed. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A file in a new untracked directory is an input; an ignored install inside it is notWhy this is rightA new file under an input glob is part of what the task reads, even in a directory git has never seen. Missing it replays output built without that file. An ignored | ✓ | ✓ | ✗Turborepo Step 5: ran a#build, expected it replayed or left out | ✓ | ✗Vite Task Step 5: ran a#build, expected it replayed or left out | ✓ |
A file the repository’s .git/info/exclude ignores changes nothingWhy this is right
| ✓ | ✓ | ✗Turborepo Step 5: ran a#build, expected it replayed or left out | ✓ | ✗Vite Task Step 5: ran a#build, expected it replayed or left out | ✗Nix Step 5: ran a#build, expected it replayed or left out |
Pointing a dangling input link at another missing target runs the task againWhy this is rightA symbolic link is an input in its own right: its target string is what it holds, with or without a file behind it. A command can read that string, as | ✓ | ✗Nx Step 4: did not run a#build | ✗Turborepo Step 4: did not run a#build | ✗moon Step 4: did not run a#build | ✗Vite Task Step 4: did not run a#build | ✓ |
A directory link back into its own input tree neither hangs nor fails the runWhy this is rightA directory link that points back into its own tree is legal, and it turns up in vendored and generated layouts. A walker that follows links without tracking where it has been loops forever or fails on an endless path. A hung CI job or a crash over a harmless link blocks every build in the repository. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Named pipes under the input and output globs neither hang the run nor stop a replayWhy this is rightA named pipe holds no bytes to hash or store, and opening one to read blocks until something writes. A tool that reads every matched path as a file hangs the run on the first pipe. Pipes under input or output globs must be passed over, so the build still runs, replays, and reruns on a real edit. | ✓ | ✗Nx Step 2: did not finish within 120 s | ✗Turborepo Step 4: ran a#build, expected it replayed or left out | ✓ | ✓ | ✗Nix Step 2: exited 1, expected success |
A committed input replaced by a directory keys what the directory holdsWhy this is rightWhen a committed file becomes a directory of the same name, the task now reads the files inside it. A tool that trusts git’s record of the old file, or skips the path as a type mismatch, keeps the old key. Edits inside the new directory then replay output built from a file that is gone. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A project nested in foo keeps its files out of foo’s inputs beside a sibling foo-barWhy this is rightA nested project’s files belong to it alone, so an edit there must not rerun the project around it. Here | ✓ | ✓ | ✗Turborepo Step 3: ran foo#build, foo-bar#build, expected it replayed or left out | ✗moon Step 3: ran foo#build, expected it replayed or left out | ✗Vite Task Step 3: ran foo#build, expected it replayed or left out | ✗Nix Step 3: ran foo#build, expected it replayed or left out |
A project nested two deep keeps its files out of both projects around itWhy this is rightEach project owns its own directory, and a project nested inside another is cut out of the outer one’s inputs. That must hold at any depth, not only one level down. If a leaf’s edit leaked into both projects above it, one change would rebuild three projects, and the waste grows with each level. | ✓ | ✓ | ✗Turborepo Step 3: ran foo#build, mid#build, foo-x#build, expected it replayed or left out | ✗moon Step 3: ran foo#build, mid#build, expected it replayed or left out | ✗Vite Task Step 3: ran foo#build, mid#build, expected it replayed or left out | ✗Nix Step 3: ran foo#build, mid#build, expected it replayed or left out |
A same-size edit is seen after git read-tree leaves the index without file statsWhy this is rightAn index entry with no stat data says nothing about the file on disk. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An edit made before git read-tree wiped the index stats still runs the task againWhy this is rightAn edit made before | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A gitignored input named outright keys its creation, each edit, and its removalWhy this is rightNaming an ignored file such as | ✓ | ✗Nx Step 3: packages/a/dist/out.txt is "a\n", expected "a\nAPI=a\n" | ✓ | ✓ | ✓ | ✓ |
An input gen/** keys every file under the gitignored directory genWhy this is rightA glob whose fixed prefix is an ignored directory asks for that directory by name. Generated sources often live in an ignored | ✓ | ✗Nx Step 3: did not run a#build | ✓ | ✗moon Step 3: did not run a#build | ✓ | ✓ |
An input gen/*.js keys a matching file in the gitignored directory genWhy this is rightAn input | ✓ | ✗Nx Step 3: did not run a#build | ✓ | ✗moon Step 3: did not run a#build | ✓ | ✓ |
An input gen/*.js over a gitignored gen does not key a JSON file beside the matchWhy this is rightNaming an ignored directory with a narrow glob asks only for what that glob matches. A JSON file beside the | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An input naming a generated client under node_modules keys itWhy this is rightA generator such as | ✓ | ✗Nx Step 4: did not run a#build | ✓ | ✗moon Step 4: did not run a#build | ✓ | ✗Nix Step 2: exited 100, expected success |
An input gen/** over a gitignored gen keys a node_modules inside itWhy this is rightAn entry naming an ignored directory asks for the whole directory; nothing under it is dropped by name. A | ✓ | ✗Nx Step 4: did not run a#build | ✓ | ✗moon Step 4: did not run a#build | ✓ | ✗Nix Step 2: exited 100, expected success |
A brace input {gen/a.txt,gen/b.txt} keys each gitignored file it namesWhy this is rightA brace of whole paths names each one outright, as its expansions written out would. Each named file is an input even though git ignores it. Treating the brace as a broad glob that skips ignored files replays output built from the old file. The brace is only shorthand for listing the paths one by one. | ✓ | ✗Nx Step 3: did not run a#build | ✓ | ✗moon Step 3: did not run a#build | ✓ | ✓ |
A brace input {gen,out}/** keys the files of both gitignored directoriesWhy this is right
| ✓ | ✗Nx Step 3: did not run a#build | ✓ | ✗moon Step 3: did not run a#build | ✓ | ✓ |
An input gen/** keys an ignored file beside the .gitkeep that keeps gen in gitWhy this is rightgit lists the | ✓ | ✗Nx Step 3: did not run a#build | ✓ | ✗moon Step 3: did not run a#build | ✓ | ✓ |
An input out/** over an ignored out keys an untracked file beside a force-added oneWhy this is rightOne force-added file makes git list part of the ignored | ✓ | ✗Nx Step 4: did not run a#build | ✓ | ✗moon Step 4: did not run a#build | ✓ | ✓ |
A task that rewrites its own input runs again once the old bytes are put backWhy this is rightA task that rewrites its own input, like a formatter, made its result from the bytes before the rewrite but leaves the bytes after on disk. Stored under the old bytes’ key, a later checkout of the unformatted file replays a no-op and leaves it unformatted. The task must run again so the file really is formatted. | ✓ | ✗Nx Step 3: packages/a/a.txt is "unformatted\n", expected "formatted\n" | ✗Turborepo Step 3: packages/a/a.txt is "unformatted\n", expected "formatted\n" | ✗moon Step 3: packages/a/a.txt is "unformatted\n", expected "formatted\n" | ✗Vite Task Step 3: packages/a/a.txt is "unformatted\n", expected "formatted\n" | ✗Nix Step 1: packages/a/a.txt is "unformatted\n", expected "formatted\n" |
An input edited and put back while the task runs does not replay the edited resultWhy this is rightA key describes the bytes the task read, and here the command read an edit that was undone before it finished. The bytes on disk match the key again, but the output came from the edit. Filed under that key, it replays the edited result over a source that no longer holds it. | ✓ | ✗Nx Step 2: packages/a/dist/out.txt is "EDITED\n", expected "a\n" | ✗Turborepo Step 2: packages/a/dist/out.txt is "EDITED\n", expected "a\n" | ✗moon Step 2: packages/a/dist/out.txt is "EDITED\n", expected "a\n" | ✗Vite Task Step 2: packages/a/dist/out.txt is "EDITED\n", expected "a\n" | ✗Nix Step 2: packages/a/dist/out.txt is "EDITED\n", expected "a\n" |
An input another task swaps out and puts back while a task reads it is not replayedWhy this is rightAnother task swapped the file while the reader ran, so the reader saw the swapped bytes. The key, taken before or after, describes the original bytes. Stored under it, the bad result replays on every later run with the good file, until someone clears the cache by hand. | ✓ | ✗Nx Step 2: packages/a/out.txt is "bad\n", expected "good\n" | ✗Turborepo Step 2: packages/a/out.txt is "bad\n", expected "good\n" | ✗moon Step 2: packages/a/out.txt is "bad\n", expected "good\n" | ✗Vite Task Step 2: packages/a/out.txt is "bad\n", expected "good\n" | ✗Nix Step 1: exited 100, expected success |
A committed input a dependency edits before the task starts is not replayed once put backWhy this is rightThe key must describe the bytes the command read, not the bytes before an earlier task ran. Here a dependency edits a committed input just before the task starts. A key taken before the dependency ran files the edited output under the old bytes, and putting those bytes back replays the edit. | ✓ | ✗Nx Step 5: packages/app/dist/out.txt is "EDITED\n", expected "X\n" | ✗Turborepo Step 5: packages/app/dist/out.txt is "EDITED\n", expected "X\n" | ✓ | ✓ | ✗Nix Step 1: exited 100, expected success |
An untracked input a dependency edits before the task starts is not replayed once put backWhy this is rightAn untracked input is read the same way as a committed one, so the same rule holds. A dependency edits it just before the task starts, and the output is built from the edit. Filed under the key of the bytes before, it replays the edited result once the original bytes come back. | ✓ | ✗Nx Step 6: packages/app/dist/out.txt is "EDITED\n", expected "X\n" | ✗Turborepo Step 6: packages/app/dist/out.txt is "EDITED\n", expected "X\n" | ✓ | ✓ | ✗Nix Step 2: exited 100, expected success |
A file behind a linked input directory, edited while the task runs, is not replayed once put backWhy this is rightA linked directory’s target string did not change, but a file behind it did while the command ran. The output was built from the new bytes. Stored under the key of the old ones, it replays the edited result once they are put back, and the link gives no sign anything moved. | ✓ | ✗Nx Step 5: packages/a/dist/out.txt is "MID\n", expected "A1\n" | ✗Turborepo Step 5: packages/a/dist/out.txt is "MID\n", expected "A1\n" | ✗moon Step 5: packages/a/dist/out.txt is "MID\n", expected "A1\n" | ✗Vite Task Step 5: packages/a/dist/out.txt is "MID\n", expected "A1\n" | ✗Nix Step 3: exited 100, expected success |
A file behind a linked input file, edited while the task runs, is not replayed once put backWhy this is rightA linked input file holds a target string, and the bytes behind it are what the command reads. Here those bytes changed while the task ran. Stored under the key of the bytes before, the output replays the edited content after the original is restored. | ✓ | ✗Nx Step 5: packages/a/dist/out.txt is "MID\n", expected "A1\n" | ✗Turborepo Step 5: packages/a/dist/out.txt is "MID\n", expected "A1\n" | ✗moon Step 5: packages/a/dist/out.txt is "MID\n", expected "A1\n" | ✗Vite Task Step 5: packages/a/dist/out.txt is "MID\n", expected "A1\n" | ✗Nix Step 3: exited 100, expected success |
A file the command adds under an input glob is not replayed once it is goneWhy this is rightA key must describe what the task read. Here the command created | ✓ | ✗Nx Step 3: packages/a/dist/out.txt is "hi\nNEW\n", expected "hi\n" | ✗Turborepo Step 3: packages/a/dist/out.txt is "hi\nNEW\n", expected "hi\n" | ✗moon Step 3: packages/a/dist/out.txt is "hi\nNEW\n", expected "hi\n" | ✗Vite Task Step 3: packages/a/dist/out.txt is "hi\nNEW\n", expected "hi\n" | ✗Nix Step 3: packages/a/dist/out.txt is "hi\nNEW\n", expected "hi\n" |
A file added in a directory created while the task runs is not replayed once it is goneWhy this is rightThe command created a new directory with a file in it after the key was taken, then read that file. The key describes the tree without the directory. Replayed under it, the output carries content from a file that is gone, and a clean build would not reproduce it. | ✓ | ✗Nx Step 3: packages/a/dist/out.txt is "hi\nNEW\n", expected "hi\n" | ✗Turborepo Step 3: packages/a/dist/out.txt is "hi\nNEW\n", expected "hi\n" | ✗moon Step 3: packages/a/dist/out.txt is "hi\nNEW\n", expected "hi\n" | ✗Vite Task Step 3: packages/a/dist/out.txt is "hi\nNEW\n", expected "hi\n" | ✗Nix Step 3: packages/a/dist/out.txt is "hi\nNEW\n", expected "hi\n" |
A named input absent when the task started, then created by it, is not replayed once goneWhy this is rightA named input that is missing when the key is taken is keyed as absent. The command then created it and read it into the output. Stored under the key for its absence, the output replays content from that file the next time it is missing, which a clean build would not produce. | ✓ | ✗Nx Step 3: packages/a/dist/out.txt is "hi\nNEW\n", expected "hi\n" | ✗Turborepo Step 3: packages/a/dist/out.txt is "hi\nNEW\n", expected "hi\n" | ✗moon Step 3: packages/a/dist/out.txt is "hi\nNEW\n", expected "hi\n" | ✗Vite Task Step 3: packages/a/dist/out.txt is "hi\nNEW\n", expected "hi\n" | ✗Nix Step 3: packages/a/dist/out.txt is "hi\nNEW\n", expected "hi\n" |
A directory link added under an input glob while the task runs is not replayed once goneWhy this is rightThe command created a link whose name matches no | ✓ | ✗Nx Step 3: packages/a/dist/out.txt is "hi\nNEW\n", expected "hi\n" | ✗Turborepo Step 3: packages/a/dist/out.txt is "hi\nNEW\n", expected "hi\n" | ✗moon Step 3: packages/a/dist/out.txt is "hi\nNEW\n", expected "hi\n" | ✗Vite Task Step 3: packages/a/dist/out.txt is "hi\nNEW\n", expected "hi\n" | ✗Nix Step 1: packages/a/dist/out.txt is "hi\n", expected "hi\nNEW\n" |
A task writing an ignored file, or one outside its inputs, still replays on the next runWhy this is rightA task that writes a log or another ignored file, or a file outside its inputs, has not changed its own inputs. Those writes are side effects and local state, not source. Treating them as input changes would keep the task from ever replaying, so it would rerun on every build. | ✓ | ✓ | ✗Turborepo Step 2: ran a#build, expected it replayed or left out | ✓ | ✗Vite Task Step 2: ran a#build, expected it replayed or left out | ✓ |
Under a git hook’s GIT_INDEX_FILE, a file a nested repository force-added despite its ignore rule is still an inputWhy this is rightGit hooks run with | ✓ | ✗Nx Step 5: did not run b#build | ✓ | ✗moon Step 5: did not run b#build | ✓ | ✓ |
Under a git hook’s GIT_DIR, a file a nested repository force-added despite its ignore rule is still an inputWhy this is rightGit hooks can run with | ✓ | ✗Nx Step 5: did not run b#build | ✓ | ✗moon Step 5: did not run b#build | ✓ | ✓ |
Under a git hook’s GIT_INDEX_FILE, a file a nested repository force-added despite its ignore rule is still an inputWhy this is rightA project can vendor a repository of its own under an input glob. Under a hook’s | ✓ | ✗Nx Step 5: did not run a#build | ✓ | ✗moon Step 5: did not run a#build | ✓ | ✓ |
Under a git hook’s GIT_DIR, a file a nested repository force-added despite its ignore rule is still an inputWhy this is rightThe variables a hook sets describe the outer repository, not one vendored inside a project. With | ✓ | ✗Nx Step 5: did not run a#build | ✓ | ✗moon Step 5: did not run a#build | ✓ | ✓ |
An input src/{b}.txt never replays a stale result for either file it could nameWhy this is rightA brace with one alternative reads as | ✓ | ✗Nx Step 5: packages/a/dist/out.txt is "b2\nbraces\n", a stale result | ✗Turborepo Step 5: packages/a/dist/out.txt is "b2\nbraces\n", a stale result | ✗moon Step 5: packages/a/dist/out.txt is "b2\nbraces\n", a stale result | ✗Vite Task Step 5: packages/a/dist/out.txt is "b2\nbraces\n", a stale result | ✓ |
Two tasks of one project with different root inputs: editing one’s root file reruns itWhy this is rightRoot inputs belong to the task that declares them, not to its project. Editing | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Two tasks of one project with different root inputs: editing one’s root file replays the otherWhy this is rightEditing a root file that only | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 3: ran a#x, expected it replayed or left out | ✓ |
A task whose root input is a file its dependency generates sees each fresh versionWhy this is rightA task that reads a file its dependency generates must see each new version of that file. Here | ✓ | ✗Nx Step 3: packages/b/out.txt is "v1\n", expected "v2\n" | ✓ | ✓ | ✓ | ✗Nix Step 3: packages/b/out.txt is "v1\n", expected "v2\n" |
A task reading a file an uncached task before it writes builds from the bytes written this runWhy this is rightA task’s key reflects the bytes it reads when it runs, not the bytes on disk when the run started. Here an uncached task rewrites | ✓ | ✗Nx Step 8: packages/a/dist/out.txt is "B", expected "A" | ✓ | ✓ | ✓ | ✗Nix Step 2: exited 100, expected success |
A committed file an uncached task rewrites ahead of its reader keys the reader on the new bytesWhy this is rightA committed, clean file can still change during a run. Here | ✓ | ✗Nx Step 6: packages/a/dist/out.txt is "A", expected "B" | ✓ | ✓ | ✓ | ✗Nix Step 3: exited 100, expected success |
A package.json listed as an input and rewritten by an uncached task before its reader keys the reader on the new bytesWhy this is rightA | ✓ | ✗Nx Step 5: packages/a/dist/out.txt is "\"v\": \"1\"\n", expected "\"v\": \"2\"\n" | ✗Turborepo Step 5: packages/a/dist/out.txt is "\"v\": \"1\"\n", expected "\"v\": \"2\"\n" | ✓ | ✓ | ✗Nix Step 2: exited 100, expected success |
A root input in a dependency that an uncached task there rewrites keys the dependant on the new bytesWhy this is rightAn input is keyed from what it holds when the task runs, even when it lives in another project and that project’s task writes it. Here | ✓ | ✗Nx Step 6: packages/app/dist/out.txt is "A", expected "B" | ✗Turborepo Step 6: packages/app/dist/out.txt is "A", expected "B" | ✓ | ✓ | ✗Nix Step 3: exited 100, expected success |
An uncached writer reached through a task that does nothing still keys its reader on what it wroteWhy this is rightOrdering passes through every task in the graph, even one that runs nothing. Here a do-nothing task sits between the uncached writer and the reader. If the reader is keyed before the writer runs because the writer is not its direct dependency, it replays output from the previous seed under a green run. | ✓ | ✗Nx Step 8: packages/a/dist/out.txt is "B", expected "A" | ✓ | ✓ | ✓ | ✗Nix Step 2: exited 100, expected success |
A cached task rewriting a file two do-nothing tasks ahead of its reader keys the reader on the new bytesWhy this is rightA cached task can write files too, and a reader further down is keyed on what it wrote. Here | ✓ | ✗Nx Step 8: packages/a/dist/out.txt is "B", expected "A" | ✓ | ✓ | ✓ | ✗Nix Step 2: exited 100, expected success |
Editing the inputs of an uncached task in a dependency reruns its cached dependantWhy this is rightA task that is not cached still has inputs, and its dependants still see them change. Here | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 3: did not run app#test | ✓ |
Editing a dependency’s sources reruns a cached dependant reached through an uncached taskWhy this is rightA chain of dependencies passes through an uncached task without breaking. Here | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 3: did not run app#test | ✓ |
Editing a project nothing depends on lets a task with an uncached dependency replayWhy this is rightAn uncached dependency does not make its dependants uncacheable. Its key comes from its inputs, so only an edit those inputs see reruns the dependant. Here the edit is in a project nothing depends on. Rerunning | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 3: ran app#test, expected it replayed or left out |
A project whose directory is named with glob syntax ([lib]) still keys its filesWhy this is rightA project directory can contain characters that are special in globs, such as | ✓ | ✗Nx Step 3: packages/[lib]/dist/o is "initial", expected "modified" | ✗Turborepo Step 3: packages/[lib]/dist/o is "initial", expected "modified" | ✗moon Step 1: exited 1, expected success | ✓ | ✓ |
An edit inside a git worktree checked out under a task’s inputs reruns the taskWhy this is rightFiles under a task’s inputs key the task, however git sees them. A git worktree checked out inside the inputs shows up in git as one untracked directory, and git never lists the files in it. A key built only from git’s listing misses an edit there and replays an old result. Refusing the workspace until the worktree is ignored is as safe. | ✓ | ✓ | ✓ | ✗moon Step 4: packages/a/dist/o is "11", expected "12" | ✓ | ✓ |
An executable bit still reruns the task when git’s core.fileMode is falseWhy this is rightFile mode is an input when the command reads it. With git’s | ✓ | ✗Nx Step 4: packages/a/dist/o is "plain\n", expected "exec\n" | ✗Turborepo Step 4: packages/a/dist/o is "plain\n", expected "exec\n" | ✗moon Step 4: packages/a/dist/o is "plain\n", expected "exec\n" | ✗Vite Task Step 4: packages/a/dist/o is "plain\n", expected "exec\n" | ✓ |
An executable bit still reruns the task when git’s core.fileMode is noWhy this is rightFile mode is an input when the command reads it. With git’s | ✓ | ✗Nx Step 4: packages/a/dist/o is "plain\n", expected "exec\n" | ✗Turborepo Step 4: packages/a/dist/o is "plain\n", expected "exec\n" | ✗moon Step 4: packages/a/dist/o is "plain\n", expected "exec\n" | ✗Vite Task Step 4: packages/a/dist/o is "plain\n", expected "exec\n" | ✓ |
An executable bit still reruns the task when git’s core.fileMode is offWhy this is rightFile mode is an input when the command reads it. With git’s | ✓ | ✗Nx Step 4: packages/a/dist/o is "plain\n", expected "exec\n" | ✗Turborepo Step 4: packages/a/dist/o is "plain\n", expected "exec\n" | ✗moon Step 4: packages/a/dist/o is "plain\n", expected "exec\n" | ✗Vite Task Step 4: packages/a/dist/o is "plain\n", expected "exec\n" | ✓ |
An executable bit still reruns the task when git’s core.fileMode is 0Why this is rightFile mode is an input when the command reads it. With git’s | ✓ | ✗Nx Step 4: packages/a/dist/o is "plain\n", expected "exec\n" | ✗Turborepo Step 4: packages/a/dist/o is "plain\n", expected "exec\n" | ✗moon Step 4: packages/a/dist/o is "plain\n", expected "exec\n" | ✗Vite Task Step 4: packages/a/dist/o is "plain\n", expected "exec\n" | ✓ |
An executable bit still reruns the task when git’s core.fileMode is emptyWhy this is rightFile mode is an input when the command reads it. With git’s | ✓ | ✗Nx Step 4: packages/a/dist/o is "plain\n", expected "exec\n" | ✗Turborepo Step 4: packages/a/dist/o is "plain\n", expected "exec\n" | ✗moon Step 4: packages/a/dist/o is "plain\n", expected "exec\n" | ✗Vite Task Step 4: packages/a/dist/o is "plain\n", expected "exec\n" | ✓ |
An executable bit still reruns the task when git’s core.fileMode is 00Why this is rightFile mode is an input when the command reads it. With git’s | ✓ | ✗Nx Step 4: packages/a/dist/o is "plain\n", expected "exec\n" | ✗Turborepo Step 4: packages/a/dist/o is "plain\n", expected "exec\n" | ✗moon Step 4: packages/a/dist/o is "plain\n", expected "exec\n" | ✗Vite Task Step 4: packages/a/dist/o is "plain\n", expected "exec\n" | ✓ |
Run from inside a project, a change in its dependency runs it againWhy this is rightA task’s key folds in its dependencies, and the directory the run starts from must not change that. Run from inside the project, a change in a dependency must still reach the task. Otherwise the developer gets a replay built against the old dependency while the same command from the root rebuilds, and tests pass against code that is gone. | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 3: did not run app#build | ✓ |
A project’s external dependency changing in the lockfile runs its task againWhy this is rightAn installed package is code the task runs or bundles, as much as its own source. When the lockfile resolves a different version for the project, the task’s output can change, so the task must run again. A runner blind to dependencies replays a build made with the old package, and an upgrade or a security fix never reaches what ships. | ✓ | ✗Nx Step 5: did not run a#build | ✗Turborepo Step 5: did not run a#build | ✗moon Step 5: did not run a#build | ✗Vite Task Step 5: did not run a#build | ✗Nix Step 1: exited 100, expected success |
A change to an external dependency of a workspace package a project links runs that project’s task againWhy this is rightWhat a project can import through a linked workspace package includes that package’s own dependencies. Here | ✓ | ✗Nx Step 4: did not run b#build, c#build | ✗Turborepo Step 4: did not run b#build, c#build | ✗moon Step 4: did not run b#build, c#build | ✗Vite Task Step 4: did not run b#build, c#build | ✗Nix Step 1: exited 100, expected success |
A lockfile change no part of a project’s dependencies holds replays its taskWhy this is rightA lockfile holds every project’s dependencies, but each task uses only its own project’s share. A change to | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: exited 100, expected success |
A catalog entry pointed elsewhere runs the task of the project that takes it, and no otherWhy this is rightThe project’s own manifest still says catalog:, so its files do not change; only the root and the lockfile do. The package it installs is now different, and so is what its build reads. The project that takes that entry must run again, and projects that do not take it must replay. | ✓ | ✗Nx Step 4: did not run a#build | ✓ | ✗moon Step 4: did not run a#build | ✗Vite Task Step 4: did not run a#build | ✗Nix Step 1: exited 100, expected success |
Installing again with nothing changed replays every taskWhy this is rightAn install that resolves the same packages leaves every task’s dependencies as they were. A key built from what is resolved, not from file times or a fresh | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: exited 100, expected success |
Swapping which packages two dependency names install runs the task againWhy this is rightThe same packages are installed, but the name | ✓ | ✗Nx Step 4: did not run a#build | ✗Turborepo Step 4: did not run a#build | ✗moon Step 4: did not run a#build | ✗Vite Task Step 4: did not run a#build | ✗Nix Step 1: exited 100, expected success |
Swapping which packages two of the root’s dependency names install runs a task reading themWhy this is rightPackages the root installs sit where every project resolves them, so a project can read them without naming them. Swapping which package the root’s | ✓ | ✗Nx Step 4: did not run a#build | ✗Turborepo Step 4: did not run a#build | ✗moon Step 4: did not run a#build | ✗Vite Task Step 4: did not run a#build | ✗Nix Step 1: exited 100, expected success |
An edit inside a local directory dependency runs the task of the project depending on itWhy this is rightA | ✓ | ✗Nx Step 4: did not run lib#build | ✗Turborepo Step 4: did not run lib#build | ✗moon Step 4: did not run lib#build | ✗Vite Task Step 4: did not run lib#build | ✗Nix Step 1: exited 100, expected success |
An edit inside a local directory dependency replays the projects that do not depend on itWhy this is rightA local directory dependency belongs to the projects that install it, not to the whole workspace. | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: exited 100, expected success |
A file added to a local directory dependency runs the task of the project depending on itWhy this is rightA new file in a local directory dependency is installed with the rest and can be imported, globbed or bundled. The lockfile still names the same path, so only the directory’s file list shows the change. A runner that hashes only the files it saw before replays a build that misses the new file. | ✓ | ✗Nx Step 4: did not run lib#build | ✗Turborepo Step 4: did not run lib#build | ✗moon Step 4: did not run lib#build | ✗Vite Task Step 4: did not run lib#build | ✗Nix Step 1: exited 100, expected success |
An edit to a file a local directory dependency links to runs the task depending on itWhy this is rightA symlink in a local directory dependency installs as the file it points to, so that file is part of the dependency’s content. Editing it changes what the task reads, even though no file inside the directory changed. A runner that hashes the link text rather than its target replays output built from the old file. | ✓ | ✗Nx Step 6: did not run lib#build | ✗Turborepo Step 6: did not run lib#build | ✗moon Step 6: did not run lib#build | ✗Vite Task Step 6: did not run lib#build | ✗Nix Step 3: exited 100, expected success |
A change in a local directory dependency’s own node_modules replays the task depending on itWhy this is rightAn install copies a local directory dependency without its | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: exited 100, expected success |
An edit inside a link: directory dependency runs the task of the project depending on itWhy this is rightA | ✓ | ✗Nx Step 3: did not run lib#build | ✗Turborepo Step 3: did not run lib#build | ✗moon Step 3: did not run lib#build | ✗Vite Task Step 3: did not run lib#build | ✗Nix Step 1: exited 100, expected success |
An edit to a local dependency kept inside another project’s directory runs the task of the project depending on itWhy this is rightA local dependency kept in another project’s directory is still the dependency of the project that installs it. When its content changes, | ✓ | ✗Nx Step 4: did not run lib#build | ✗Turborepo Step 4: did not run lib#build | ✗moon Step 4: did not run lib#build | ✗Vite Task Step 4: did not run lib#build | ✗Nix Step 1: exited 100, expected success |
A change to the dependencies of a workspace package linked under another name runs the linking project’s task againWhy this is right
| ✓ | ✗Nx Step 4: did not run a#build | ✗Turborepo Step 4: did not run a#build | ✗moon Step 4: did not run a#build | ✗Vite Task Step 4: did not run a#build | ✗Nix Step 1: exited 100, expected success |
A dependency change of a workspace nested in another’s directory runs the nested project’s task againWhy this is rightA workspace package can sit inside another project’s directory, and it keeps its own dependencies. When | ✓ | ✗Nx Step 4: did not run n#build | ✗Turborepo Step 4: did not run n#build | ✗moon Step 4: did not run n#build | ✗Vite Task Step 4: did not run n#build | ✗Nix Step 1: exited 100, expected success |
A dependency change of a workspace nested in another’s directory replays the outer project’s taskWhy this is rightA project inside another’s directory is a project of its own, with its own dependencies. A change to the nested project’s dependency does not touch the outer one, so the outer task should replay. A runner that folds nested manifests into the outer project reruns it on every change of the nested one. | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: exited 100, expected success |
An override that forces a different transitive dependency runs the task of the project reaching itWhy this is rightAn override in the root manifest can swap a package deep in a project’s dependency tree. Here | ✓ | ✗Nx Step 4: did not run b#build | ✓ | ✗moon Step 4: did not run b#build | ✗Vite Task Step 4: did not run b#build | ✗Nix Step 1: exited 100, expected success |
An override replays the task of a project that does not reach the overridden packageWhy this is rightAn override changes only the projects whose dependency tree contains the package it forces. | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: exited 100, expected success |
Respelling an override so it resolves the same package replays every taskWhy this is rightWhat a task loads is the package an override resolves to, not how the override is written. | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: exited 100, expected success |
A change to a workspace package the root depends on runs a project reading it through the root againWhy this is rightA package the root depends on is reachable by every project, so a project that reads it depends on it even without naming it. When | ✓ | ✗Nx Step 3: did not run app#build | ✗Turborepo Step 3: did not run app#build | ✗moon Step 3: did not run app#build | ✗Vite Task Step 3: did not run app#build | ✗Nix Step 1: exited 100, expected success |
An edit inside a file: dependency beside the repository runs the task of the project depending on itWhy this is rightThe dependency lives beside the repository, so no git status shows the edit and the lockfile names only its path. The task still reads its files through the install. A key built only from the repository misses the change and replays a result built from the old code. | ✓ | ✗Nx Step 4: did not run lib#build | ✗Turborepo Step 4: did not run lib#build | ✗moon Step 4: did not run lib#build | ✗Vite Task Step 4: did not run lib#build | ✗Nix Step 1: exited 100, expected success |
An edit inside a file: dependency beside the repository replays the projects that do not depend on itWhy this is rightOnly the projects that depend on the outside folder read its files. An edit there cannot change the output of a project that never installs it. Running those projects again for a change they do not read spends a full build on each, every time the shared folder moves. | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: exited 100, expected success |
Pointing a file: dependency at another directory beside the repository runs the task againWhy this is rightPointing the dependency at another folder changes the code the task installs and reads, even if the name stays the same. The manifest and lockfile record the new path. A key that does not follow the new folder’s content replays a result built from the old folder. | ✓ | ✗Nx Step 4: did not run lib#build | ✗Turborepo Step 4: did not run lib#build | ✗moon Step 4: did not run lib#build | ✗Vite Task Step 4: did not run lib#build | ✗Nix Step 1: exited 100, expected success |
An edit inside a link: dependency beside the repository runs the task of the project depending on it, with no installWhy this is rightnode_modules holds a link to the folder, so an edit there is what the task reads at once, with no install. Nothing in the repository changed, and git sees nothing. A key that only looks inside the repository replays the old output while the code it read has moved. | ✓ | ✗Nx Step 3: did not run lib#build | ✗Turborepo Step 3: did not run lib#build | ✗moon Step 3: did not run lib#build | ✗Vite Task Step 3: did not run lib#build | ✗Nix Step 1: exited 100, expected success |
A file added to a link: dependency beside the repository runs the task of the project depending on itWhy this is rightA linked folder is read live, so a file added there is part of what the task can import, with no install. Adding a module is as much a change as editing one. A key that only hashed the files present at install time misses it, and the build replays without the new file. | ✓ | ✗Nx Step 3: did not run lib#build | ✗Turborepo Step 3: did not run lib#build | ✗moon Step 3: did not run lib#build | ✗Vite Task Step 3: did not run lib#build | ✗Nix Step 1: exited 100, expected success |
An edit inside a link: dependency beside the repository replays the projects that do not depend on itWhy this is rightOnly the projects that link the outside folder read its files. An edit there cannot change the output of a project that does not depend on it. Running every project on such an edit spends builds that produce the same bytes and makes a shared folder cost the whole workspace. | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: exited 100, expected success |
Pointing a link: dependency at another directory beside the repository runs the task againWhy this is rightPointing the link at another folder changes the code behind the same name, though the name is unchanged. The task reads the new folder’s files from then on. A key that keeps hashing the old target, or only the name, replays a result built from code that is no longer installed. | ✓ | ✓ | ✗Turborepo Step 4: did not run lib#build | ✗moon Step 4: did not run lib#build | ✗Vite Task Step 4: did not run lib#build | ✗Nix Step 1: exited 100, expected success |
Installing again with a link: dependency beside the repository unchanged replays the taskWhy this is rightInstalling again with nothing changed leaves the same link to the same folder with the same files. The task reads exactly what it read before, so its result is already on disk. Running it again because an install touched node_modules costs a full build for nothing. | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: exited 100, expected success |
A file added to a link: directory dependency runs the task of the project depending on it, with no installWhy this is rightA linked folder is read live, so a file added to it is something the task can now import, with no install step. Adding a module is as much a change as editing one. A key that only covers files known at install time replays the old build without the new file. | ✓ | ✗Nx Step 3: did not run lib#build | ✗Turborepo Step 3: did not run lib#build | ✗moon Step 3: did not run lib#build | ✗Vite Task Step 3: did not run lib#build | ✗Nix Step 1: exited 100, expected success |
An edit inside a link: directory dependency replays the projects that do not depend on itWhy this is rightOnly the projects that link the folder read its files. An edit inside it cannot change the output of a project that does not depend on it. Running those projects again spends a build each on bytes that cannot differ, on every save in the shared folder. | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: exited 100, expected success |
Pointing a link: dependency at another directory in the repository runs the task againWhy this is rightPointing the link at another folder in the repository changes the code behind the same name. The task reads the new folder from then on. A key that keeps hashing the old folder, or only the dependency’s name, replays a result built from code that is no longer linked. | ✓ | ✓ | ✗Turborepo Step 4: did not run lib#build | ✗moon Step 4: did not run lib#build | ✗Vite Task Step 4: did not run lib#build | ✗Nix Step 1: exited 100, expected success |
An edit to a link: dependency kept inside another project’s directory runs the task of the project depending on itWhy this is rightThe edited folder sits inside another project’s directory, but the dependant links it and reads its files. Which project owns the folder on disk does not decide who reads it. A key that charges the edit only to the owner replays the dependant with code it no longer reads. | ✓ | ✗Nx Step 3: did not run lib#build | ✗Turborepo Step 3: did not run lib#build | ✗moon Step 3: did not run lib#build | ✗Vite Task Step 3: did not run lib#build | ✗Nix Step 1: exited 100, expected success |
Swapping which link: directories two dependency names point at runs the task againWhy this is rightThe same two folders are linked as before, but each name now points at the other one. The task imports by name, so it now reads different code. A key that only collects the set of linked folders sees no change and replays a result built from the old pairing. | ✓ | ✓ | ✗Turborepo Step 4: did not run a#build | ✗moon Step 4: did not run a#build | ✗Vite Task Step 4: did not run a#build | ✗Nix Step 1: exited 100, expected success |
Pointing a dependency name at another package, the import name unchanged, runs the task againWhy this is rightThe task imports the same name either way, but that name now resolves to a different package. What the task reads has changed even though its source has not. A key that sees only the import name, not what it installs to, replays a result built from the old package. | ✓ | ✗Nx Step 4: did not run a#build | ✗Turborepo Step 4: did not run a#build | ✗moon Step 4: did not run a#build | ✗Vite Task Step 4: did not run a#build | ✗Nix Step 1: exited 100, expected success |
An edit behind a dependency installed under another name, linked from beside the repository, runs the task againWhy this is rightThe dependency is installed under another name and linked from beside the repository, so neither git nor the name shows the edit. The task still reads the linked files. A key that follows only repository files, or the package’s own name, replays an output built from the old code. | ✓ | ✗Nx Step 3: did not run a#build | ✗Turborepo Step 3: did not run a#build | ✗moon Step 3: did not run a#build | ✗Vite Task Step 3: did not run a#build | ✗Nix Step 1: exited 100, expected success |
With * text=auto in .gitattributes, a same-size edit to a CRLF file runs the task againWhy this is rightUnder | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
With * text in .gitattributes, a same-size edit to a CRLF file runs the task againWhy this is rightUnder | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
With *.txt text eol=crlf in .gitattributes, a same-size edit to a CRLF file runs the task againWhy this is rightWith | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Under a project’s own *.md text rule, a same-size edit to a CRLF file runs the task againWhy this is rightA project can set its own line-ending rules in its own | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Under * text=auto, rewriting a file holding a lone CR from LF to CRLF runs the task againWhy this is rightWhether git calls a file text or binary, a rewrite from LF to CRLF changes the bytes the task reads. A lone CR makes git’s guess about the file uncertain, and in text mode it may store both versions as the same blob. A runner that trusts that blob replays output built from the LF file, so the CRLF version never reaches the build. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Under * text=auto, rewriting a file holding a NUL past its first 8000 bytes from LF to CRLF runs the task againWhy this is rightGit looks only at a file’s first 8000 bytes to decide if it is binary, so a NUL past them leaves it text. Rewritten from LF to CRLF, the file on disk changes while git may store the same normalised blob. A runner that trusts that blob replays the old build, though every line the task reads is different. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Under * text=auto, rewriting a file holding a control byte from LF to CRLF runs the task againWhy this is rightA control byte does not make git call a file binary, so under | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Under * text=auto, rewriting a file holding plain text from LF to CRLF runs the task againWhy this is rightUnder | ✓ | ✓ | ✗Turborepo Step 4: did not run a#build | ✓ | ✓ | ✓ |
Under * text=auto, dropping the CR from a binary file’s CRLF runs the task againWhy this is rightUnder | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An edit to a committed input with a non-ASCII name runs the task againWhy this is rightA committed file with a non-ASCII name is an input like any other. Git quotes such a path in its output unless asked not to, and a quoted path names no file on disk. A runner that reads git’s list as-is drops the file from the key, so editing it replays the old build. Names are bytes and must be read as git meant them. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An input src/*:bar.txt keys foo:bar.txt and foo::bar.txt: a : is part of a nameWhy this is rightIn a glob, a | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✗Vite Task Step 1: exited 1, expected success | ✓ |
An input src/a,b.ts keys the file named so: a , outside braces is part of a nameWhy this is rightA | ✓ | ✓ | ✗Turborepo Step 1: exited 1, expected success | ✓ | ✗Vite Task Step 1: exited 1, expected success | ✓ |
Inputs through @scope, a+b and (marketing) directories key the files under themWhy this is rightPaths with | ✓ | ✗Nx Step 7: did not run a#build | ✗Turborepo Step 1: exited 1, expected success | ✗moon Step 1: exited 1, expected success | ✗Vite Task Step 1: exited 1, expected success | ✓ |
Inputs named with a $, quotes, a space or a tab, committed or not, each rerun the task when editedWhy this is rightFile names may hold | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A file or directory whose name holds a newline is an input, untracked or committedWhy this is rightA newline is a legal byte in a file or directory name. A runner that reads git’s or the file system’s list one line at a time splits such a name in two, and neither half names a file. The file then drops out of the key, and an edit to it replays the old output. Git’s NUL-separated output keeps each name whole. | ✓ | ✓ | ✓ | ✗moon Step 3: did not run a#build | ✗Vite Task Step 3: did not run a#build | ✗Nix Step 3: exited 100, expected success |
An input src/[invalid keys the file named so, or is refused, never left unkeyedWhy this is rightAn unclosed | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Only a file named .gitignore holds ignore rules: foo.gitignore and .ignore do notWhy this is rightGit reads ignore rules only from files named | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An uncommitted .gitignore edit that stops ignoring a file, then ignores it again: an edit it ignores replays the taskWhy this is rightIgnore rules decide what is local state, and the rules in force are the ones on disk now, committed or not. While | ✓ | ✓ | ✗Turborepo Step 3: ran a#build, expected it replayed or left out | ✓ | ✗Vite Task Step 3: ran a#build, expected it replayed or left out | ✗Nix Step 3: ran a#build, expected it replayed or left out |
An uncommitted .gitignore edit that stops ignoring a file, then ignores it again: an edit it leaves an input runs the task againWhy this is rightOnce an uncommitted .gitignore edit stops ignoring | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A * .gitignore that whitelists one file: an edit it ignores replays the taskWhy this is rightA .gitignore of | ✓ | ✓ | ✗Turborepo Step 4: ran a#build, expected it replayed or left out | ✓ | ✗Vite Task Step 4: ran a#build, expected it replayed or left out | ✗Nix Step 4: ran a#build, expected it replayed or left out |
A * .gitignore that whitelists one file: an edit it leaves an input runs the task againWhy this is rightA | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A project’s !important.log under the root’s *.log: an edit it ignores replays the taskWhy this is rightA project’s own .gitignore can re-include a file the root ignores, since git applies the deeper rule last. Other | ✓ | ✓ | ✗Turborepo Step 4: ran a#build, expected it replayed or left out | ✓ | ✗Vite Task Step 4: ran a#build, expected it replayed or left out | ✗Nix Step 4: ran a#build, expected it replayed or left out |
A project’s !important.log under the root’s *.log: an edit it leaves an input runs the task againWhy this is rightGit applies the deeper .gitignore last, so a project’s | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A root .gitignore with *.log and !keep.log: an edit it ignores replays the taskWhy this is rightA root .gitignore with | ✓ | ✓ | ✗Turborepo Step 3: ran a#build, expected it replayed or left out | ✓ | ✗Vite Task Step 3: ran a#build, expected it replayed or left out | ✗Nix Step 3: ran a#build, expected it replayed or left out |
A root .gitignore with *.log and !keep.log: an edit it leaves an input runs the task againWhy this is rightIn a root .gitignore with | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A root rule two directories into a project, under an input **: an edit it ignores replays the taskWhy this is rightA root rule can name a path deep inside a project, such as | ✓ | ✓ | ✗Turborepo Step 4: ran a#build, expected it replayed or left out | ✓ | ✗Vite Task Step 4: ran a#build, expected it replayed or left out | ✗Nix Step 4: ran a#build, expected it replayed or left out |
A root rule two directories into a project, under an input **: an edit it leaves an input runs the task againWhy this is rightA root rule like | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A new directory’s own .gitignore: an edit it ignores replays the taskWhy this is rightA new, untracked directory can bring its own .gitignore, and git applies it at once. A file it ignores, such as | ✓ | ✓ | ✗Turborepo Step 4: ran a#build, expected it replayed or left out | ✓ | ✗Vite Task Step 4: ran a#build, expected it replayed or left out | ✗Nix Step 4: ran a#build, expected it replayed or left out |
A new directory’s own .gitignore: an edit it leaves an input runs the task againWhy this is rightA new directory’s .gitignore ignores only what it names. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A nested .gitignore, which ignores only below it: an edit it ignores replays the taskWhy this is rightA .gitignore applies to its own directory and below, so | ✓ | ✓ | ✗Turborepo Step 4: ran a#build, expected it replayed or left out | ✓ | ✗Vite Task Step 4: ran a#build, expected it replayed or left out | ✗Nix Step 4: ran a#build, expected it replayed or left out |
A nested .gitignore, which ignores only below it: an edit it leaves an input runs the task againWhy this is rightA nested .gitignore ignores only below its own directory. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A FIFO under an input glob does not hang the runWhy this is rightA FIFO is not a file with content: opening it to read waits for a writer that never comes. A runner that hashes everything under a glob hangs there, and a CI job runs until its timeout. Tools and test servers create FIFOs in project trees, so a runner must skip them, or treat them by type, and finish the run. | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 3: exited 1, expected success |
A file added with git add -N keys its bytes: an edit to it runs the task againWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A task keyed on a root file and no project file runs again when the root file changesWhy this is rightA task may depend on a file at the workspace root, such as a shared config, and declare it explicitly. That file is then part of its key even when no project file is. A runner that ignores root inputs when the project lists none replays the old output after the root file changes, and the new config never reaches the build. | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: exited 100, expected success |
A task ordered after a dependency, but not keyed on it, replays when only the dependency changesWhy this is rightThe task was told to run after its dependency but not to key on it, because it never reads what the dependency produces. A change only in the dependency leaves this task’s inputs as they were. Running it again ignores that declaration and costs a build that gives the same bytes. | ✓ | ✓ | – | ✓ | ✓ | – |
A negation with a trailing slash, !src/gen/, takes that directory’s tree out of the keyWhy this is rightA trailing slash only says the path is a directory: | ✓ | ✓ | ✓ | ✗moon Step 3: ran a#build, expected it replayed or left out | ✓ | ✓ |
A !**/*.bak input still keys a file named plain bakWhy this is rightAn exclusion by suffix means files ending in | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A key command that leaves a background child running does not hold up the runWhy this is rightA key command is done once its shell exits and its answer is printed. A child it started in the background, such as a daemon a version check wakes, can keep the output pipe open for a long time. A tool that waits for the pipe to close waits on that child, and every run, even one with nothing to do, stalls with it. | ✓ | ✗Nx Step 1: did not finish within 30 s | – | ✗moon Step 1: did not finish within 30 s | – | – |
An edit in a project whose directory name starts with : runs its task againWhy this is rightA directory name is just a name, and | ✓ | ✓ | ✗Turborepo Step 1: exited 1, expected success | ✗moon Step 1: exited 1, expected success | ✗Vite Task Step 1: exited 1, expected success | ✓ |
A project directory named *g keys only its own files, not those of big beside itWhy this is rightA | ✓ | ✓ | ✗Turborepo Step 3: ran star#build, expected it replayed or left out | ✗moon Step 1: exited 1, expected success | ✓ | ✓ |
A brace input naming src and the escaped app/\[id\] keys both directoriesWhy this is rightNext.js routes live in folders like | ✓ | ✗Nx Step 3: did not run a#build | ✗Turborepo Step 3: did not run a#build | ✗moon Step 1: exited 1, expected success | ✗Vite Task Step 3: did not run a#build | ✓ |
Environment
33 cases100%31 of 3331 supported80%25 of 3331 supported87%27 of 3331 supported83%26 of 3331 supported96%29 of 3330 supported73%22 of 3330 supported
Environment variables a task reads. A changed variable must run the task again, or a build made for one environment ships to another.
| Case | vx | Nx | Turborepo | moon | Vite Task | Nix |
|---|---|---|---|---|---|---|
A PATH=… prefix on a command decides where that command’s program is foundWhy this is rightIn sh, | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Listing the same declared variables in another order still replaysWhy this is rightDeclared environment variables are a set: their order in the config says nothing about what the task reads. Reordering the list is a cosmetic edit with the same values. Treating it as a change misses the cache for every task the edit touched, on every machine. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A declared variable reaches the commandWhy this is rightDeclaring a variable says the task reads it, so the command must see its value. A tool that filters the environment must still pass declared names through. Otherwise a build meant for production runs with its defaults, and the output is wrong though the run is green. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Changing a declared variable runs the task againWhy this is rightA declared variable is an input like a file, and a new value is a new input. Builds often switch API addresses, feature flags or modes on a variable. Replaying the dev output when | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Going back to an earlier value restores that value’s outputsWhy this is rightA key that depends only on inputs gives back the same output for the same inputs. Switching between dev and prod is routine, and each switch must leave a tree that matches the current value. A tool that keeps the last output in place, or restores the newest entry, leaves a build for the wrong mode. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An unset variable and an empty one are different valuesWhy this is rightA command can tell an unset variable from an empty one, and many scripts do, for example with | ✓ | ✗Nx Step 2: packages/a/dist/out.txt is "unset\n", expected "\n" | ✓ | ✗moon Step 2: packages/a/dist/out.txt is "unset\n", expected "\n" | ✓ | ✗Nix Step 2: packages/a/dist/out.txt is "unset\n", expected "\n" |
MODE and mode are different variablesWhy this is rightEnvironment variable names are case-sensitive on Linux and macOS, so | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A variable the task does not declare changes nothingWhy this is rightOnly declared variables key a task; the rest of the environment changes all the time. Shells and CI runners set values such as job ids, timestamps and temporary paths that differ on every run. Keying on all of them makes every run a miss, so the cache never helps. Declaring what a task reads is what keeps its key stable. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A .env file at the workspace root does not set a task’s variableWhy this is rightA task’s environment is the one the shell that started the run gave it. A runner that also reads a | ✓ | ✗Nx Step 1: packages/a/dist/out.txt is "from-dotenv\n", expected "unset\n" | ✓ | ✓ | ✓ | ✗Nix Step 1: packages/a/dist/out.txt is "from-dotenv\n", expected "unset\n" |
Neither .env.local nor the .env.<NODE_ENV> file sets a task’s variableWhy this is rightFiles such as | ✓ | ✗Nx Step 1: packages/a/dist/out.txt is "from-local\n", expected "unset\n" | ✓ | ✓ | ✓ | ✗Nix Step 1: packages/a/dist/out.txt is "from-local\n", expected "unset\n" |
A .env in the directory the run starts from does not set a task’s variableWhy this is rightWhere a run starts must not change what a task sees. If a | ✓ | ✗Nx Step 1: packages/a/dist/out.txt is "from-dotenv\n", expected "unset\n" | ✓ | ✓ | ✓ | ✗Nix Step 1: packages/a/dist/out.txt is "from-dotenv\n", expected "unset\n" |
With a .env present, the value the shell sets is the one the task seesWhy this is rightA value set in the shell is an explicit request for this run; a file on disk is not. If a | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Editing a .env never replays a result built from its old valueWhy this is rightWhatever a runner does with | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A bin a project’s dependency ships is on that project’s task PATHWhy this is rightA project that depends on a package expects to call that package’s bins by name, as package scripts do. The package manager links them into the project’s | ✓ | ✓ | ✓ | ✗moon Step 2: exited 127, expected success | ✓ | ✗Nix Step 2: exited 100, expected success |
A dependency shipping a bin named sh does not become the shell that runs the commandWhy this is rightThe task’s PATH decides what the names in its command resolve to, not which shell parses the command. If a dependency’s bin named | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A project under a directory holding : does not run a file the split PATH entry names relative to itWhy this is rightA directory name is data and must not change how PATH is read. Joined into PATH, | ✓ | ✗Nx Step 2: packages/x:y/pwned exists, expected none | ✗Turborepo Step 2: exited 1, expected success | ✗moon Step 2: exited 1, expected success | ✗Vite Task Step 2: exited 1, expected success | ✓ |
Forwarded flags reach the command as written, never parsed and rewrittenWhy this is rightForwarded args belong to the command, and the runner passes them through untouched. A runner that parses flags and writes them back merges repeated flags, re-cases | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An empty arg, spaces, a newline, quotes and $HOME each reach the command as one arg, unexpandedWhy this is rightEach forwarded arg must arrive as exactly one arg with its bytes unchanged. If the runner joins args into a shell string, an empty arg vanishes, | ✓ | ✗Nx Step 1: packages/a/args.txt is "<sp ace><two\nlines><it's><q><{\"a\":1}></tmp/conf-nx-0K8XL0/.home><&&><->", expected "<><sp ace><two\nlines><it's><\"q\"><{\"a\":1}><$HOME><&&><->" | ✓ | ✓ | ✓ | ✓ |
Forwarded args that look like the runner’s own flags, and an inner --, reach the commandWhy this is rightEverything after the first | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Forwarded args reach the requested task, not its dependency of the same task nameWhy this is rightArgs are given for the task that was asked for; a dependency was not asked for, only pulled in. If | ✓ | ✓ | ✗Turborepo Step 1: packages/b/args.txt is "<--x>", expected "<>" | ✓ | ✓ | ✓ |
A declared variable set to its own name is not the same as the variable unsetWhy this is rightA declared variable’s key must tell an unset variable apart from every value it can take. A key that writes the name in place of a missing value makes | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Moving a value from one declared variable to another, or swapping two, runs the task againWhy this is rightA variable’s value means something only with its name. A key that folds values without their names cannot tell | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A declared value holding =, newlines, a tab and non-ASCII reaches the task byte for byteWhy this is rightA declared variable must reach the task exactly as the shell set it. Values with | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A PATH with one more directory, not declared, replays the taskWhy this is rightPATH differs on almost every machine and changes with each tool installed. If an undeclared PATH were keyed, no two machines would share an entry, and a remote cache would never hit. The trade-off is that a tool found through PATH is not in the key; a task that depends on a tool’s version can declare that explicitly. | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 2: ran a#build, expected it replayed or left out |
TMPDIR, TMP and TEMP reach a task as set, and what a task exports reaches its childrenWhy this is rightTMPDIR, TMP and TEMP tell a task where it may write temporary files, and the host sets them for a reason, such as a larger or private disk. A runner that drops or rewrites them sends the task’s temp files where the host never pointed it, filling a small disk or sharing files between jobs. A task’s own exports must also reach its children. | ✓ | ✓ | ✗Turborepo Step 1: packages/a/seen.txt is "|/tmp/conf-tmp|/tmp/conf-temp", expected "/tmp/.|/tmp/conf-tmp|/tmp/conf-temp" | ✓ | ✓ | ✗Nix Step 1: packages/a/seen.txt is "/build|/build|/build", expected "/tmp/.|/tmp/conf-tmp|/tmp/conf-temp" |
A dependency’s bin named sh does not become the shell of a long-running task eitherWhy this is rightA long-running task, such as a dev server, is started the same way as any other, so the same rule holds. The task’s PATH decides what its command resolves, not which shell parses it. If a dependency’s bin named | ✓ | ✓ | ✗Turborepo Step 2: exited 1, expected success | ✗moon Step 2: did not finish within 60 s | – | – |
A variable passed through reaches the taskWhy this is rightA pass-through variable is one the task needs to run, such as a token or a path, but whose value does not change the output. It must reach the command, or the task fails or behaves differently. Keeping it out of the key is a choice about caching, not about whether the task sees it. | ✓ | ✓ | ✓ | ✓ | ✓ | – |
A change to a variable passed through, not keyed, replays the taskWhy this is rightThe user declared that this variable reaches the task but does not change its output, such as a log level or a token. Changing it therefore leaves the result as it was. Running the task again ignores that declaration and costs a full build on every machine where the value differs. | ✓ | ✓ | ✓ | ✓ | ✓ | – |
Switching a declared variable back and forth replays each value’s own resultWhy this is rightEach value of a keyed variable has its own entry, and a new value must not push out the old one. Builds that switch between modes, such as dev and prod or two targets, go back and forth all day. A cache that keeps only the latest value rebuilds on every switch, though both results are already known. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A task runs by name a program the task it depends on builtWhy this is rightOne project builds a tool, and the projects that depend on it run that tool. Calling it by a path into another project ties every command to that layout. Putting the dependency’s programs on PATH lets the command say only what it runs, and the edge says where it comes from. | – | – | – | – | – | ✓ |
A task whose two dependencies ship a program of one name fails instead of picking oneWhy this is rightWhen two dependencies both put a program of one name on PATH, the one that runs depends on the order they were listed in. Reordering the list then swaps the tool under the same key. Failing the task and naming both makes the choice explicit instead of an accident of order. | – | – | – | – | – | ✗Nix Step 1: succeeded, expected a failure |
An IFS in the runner’s environment does not change how a task’s shell splits wordsWhy this is rightA task command is a shell command, and a fresh | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A declared variable named __proto__ reaches the task and keys itWhy this is right
| ✓ | ✓ | ✓ | ✗moon Step 2: did not run a#build | ✓ | ✓ |
Outputs
224 cases100%224 of 224224 supported71%157 of 224221 supported72%161 of 224221 supported54%122 of 224224 supported63%140 of 224221 supported72%159 of 224219 supported
The files a task leaves on disk, saved and brought back on a replay. A replay must leave the disk exactly as a real run would.
| Case | vx | Nx | Turborepo | moon | Vite Task | Nix |
|---|---|---|---|---|---|---|
Outputs written ./dist/** are stored and restoredWhy this is rightOutput globs are project-relative paths, and | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An output naming a directory, dist, stores and restores its whole treeWhy this is rightAn output naming a directory, | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 3: packages/a/dist/a.txt is missing, expected "a\n" | ✓ |
A dotfile under an output glob is restored with the restWhy this is rightAn output glob covers every file the task wrote under it, dotfiles included. Dotfiles in build output often carry metadata, manifests or config. Leaving them out of the stored entry restores a tree that differs from a fresh build. A replay must leave the same tree a fresh run does. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An output app/\[id\]/page.js restores the route and leaves the sibling app/i aloneWhy this is rightAn escaped output | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✓ | ✓ |
An output at the workspace root is restored there on a replayWhy this is rightAn output declared at the workspace root, such as generated API types, is part of the task’s result. A replay must put it back where the task wrote it. Restoring only the project’s outputs reports a hit while the root file stays missing, and the next step fails or reads an old copy. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An outer project’s **/*.js output never restores a nested project’s file over its newer buildWhy this is rightAn output glob such as | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 5: packages/outer/inner/dist/nested.js is "v1\n", expected "v2\n" | ✓ |
An output glob climbing out of the project, ../../victim/**, never deletes what is thereWhy this is rightA task owns only the files in its project, and an output glob that climbs out with | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An output glob into a sibling whose name extends the project’s, ../a-extra/**, deletes nothingWhy this is rightA sibling whose name starts with the project’s, like | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An output directory that is a link out of the project never has the link’s target emptiedWhy this is rightClearing an output directory must not follow a link out of the project. If | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A link a task leaves in its output directory is cleared, and the file it points at survivesWhy this is rightA link the task wrote is the output, not the file it points at. Clearing outputs must remove the link itself and leave its target alone. A tool that follows the link while cleaning deletes a file elsewhere in the repo that the task never wrote, and no later run brings it back. | ✓ | ✓ | ✓ | ✗moon Step 6: packages/a/dist/linked.txt is missing | ✗Vite Task Step 6: packages/a/dist/linked.txt is missing | ✓ |
An output under a directory whose name holds a backslash is restoredWhy this is rightA backslash is an ordinary character in a file name on Linux and macOS. A tool that reads it as a path separator or an escape stores or restores the file under the wrong path, or not at all. The replay then leaves a tree missing an output the task wrote, under a green run. | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 1: exited 1, expected success | ✓ |
A file under an output directory that no output pattern matches survives a rebuild and a replayWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An output naming a directory with a trailing slash, dist/, stores and restores its treeWhy this is rightWriting a directory as | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A root output naming a directory, generated, stores and restores its treeWhy this is rightA root output that names a directory, like | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 3: generated/s.txt is missing, expected "a\n" | ✓ |
A committed edit to a file under the outputs, outside the inputs, is not reverted by a replayWhy this is rightA replay writes back what the task produced, not the committed state of every file under its output glob. Here | ✓ | ✗Nx Step 5: packages/a/out/LICENSE is "license v1", expected "license v2" | ✗Turborepo Step 5: packages/a/out/LICENSE is "license v1", expected "license v2" | ✓ | ✗Vite Task Step 5: packages/a/out/LICENSE is "license v1", expected "license v2" | ✗Nix Step 5: packages/a/out/LICENSE is missing, expected "license v2" |
A committed file under the outputs made executable is not made plain again by a replayWhy this is rightThe user committed a mode change to a file that sits under the outputs. A replay writes back the cached copy with its old mode and quietly undoes that commit. The tree must keep what the user changed and committed, since the task itself never ran to produce a new one. | ✓ | ✗Nx Step 6: packages/a/out/tool.sh is not executable | ✗Turborepo Step 6: packages/a/out/tool.sh is not executable | ✓ | ✗Vite Task Step 6: packages/a/out/tool.sh is not executable | ✗Nix harness: Error: ENOENT: no such file or directory, chmod '/tmp/conf-nix-qWUlfy/packages/a/out/tool.sh' |
A file force-added to git under ignored outputs, then edited and committed, is not reverted by a replayWhy this is rightGit tracks the file although its folder is ignored, because the user added it on purpose and then edited and committed it. A replay that writes the cached copy over it destroys committed work. The tree must keep what version control holds, not what an older run left behind. | ✓ | ✗Nx Step 6: packages/a/out/keep.txt is "keep v1\n", expected "keep v2\n" | ✗Turborepo Step 6: packages/a/out/keep.txt is "keep v1\n", expected "keep v2\n" | ✓ | ✗Vite Task Step 6: packages/a/out/keep.txt is "keep v1\n", expected "keep v2\n" | ✗Nix Step 6: packages/a/out/keep.txt is missing, expected "keep v2\n" |
A generator rewriting its committed output leaves the new bytes, and a later run brings them backWhy this is rightA generated file that is also committed must end up matching what the generator makes now. After the user resets it to the committed version, the next run must bring the new output back, by running or by replay. A tool that sees the committed bytes and calls it done leaves a stale route table in the tree. | ✓ | ✓ | ✓ | ✗moon Step 4: packages/a/src/routeTree.gen.txt is "a.tsx\n", expected "a.tsx\nb.tsx\n" | ✓ | ✓ |
A committed file under a root output glob survives a run that no longer writes itWhy this is rightA committed file under an output glob is source the repository holds, not something this task made. A task that stops writing it does not own it. Clearing every match before the run deletes it, and the user finds a tracked file missing after a build. | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 3: gen/b.ts is missing, expected "b" |
Renaming an input and back leaves only the outputs that name producesWhy this is rightRenaming a file can change which output files a task writes, even with the same bytes. Each name’s entry holds its own outputs, and switching back must leave only that name’s files. A replay that adds to the old tree leaves | ✓ | ✗Nx Step 3: packages/a/dist/a.js exists, expected none | ✗Turborepo Step 3: packages/a/dist/a.js exists, expected none | ✗moon Step 3: packages/a/dist/a.js exists, expected none | ✗Vite Task Step 3: packages/a/dist/a.js exists, expected none | ✓ |
An output whose name is not UTF-8 is there after the next run, replayed or rebuiltWhy this is rightOutputs whose names are not UTF-8 must be stored and restored like any other. An entry saved without such a file restores a tree missing an output the task wrote. Anything that later reads that file fails, or ships without it, under a green run. Rebuilding instead of replaying is also correct. | ✓ | ✗Nx harness: Error: step 4 `test -f "packages/a/dist/$(printf 'x\377y')"` exited 1: | ✓ | ✓ | ✓ | ✓ |
A stray file under an output glob the task never wrote to is gone after a replayWhy this is rightA replay must leave the output tree as the task left it, and here the task wrote nothing. A tool that skips the clean when the entry is empty leaves a stray file in place. That file then ships, or gets tested, as if this build had made it, and the next clean build disagrees. | ✓ | ✗Nx Step 4: packages/a/build/stray.js exists, expected none | ✗Turborepo Step 4: packages/a/build/stray.js exists, expected none | ✗moon Step 1: exited 1, expected success | ✗Vite Task Step 4: packages/a/build/stray.js exists, expected none | ✓ |
A stray file under a root output glob the task never wrote to is gone after a replayWhy this is rightA root output glob is cleaned on replay like a project one, even when the task wrote nothing under it. Otherwise a stray file, left by hand or by an older build, stays in a directory the task owns. Later steps then read it as if this build produced it. | ✓ | ✗Nx Step 4: nowhere/stray.js exists, expected none | ✗Turborepo Step 4: nowhere/stray.js exists, expected none | ✗moon Step 1: exited 1, expected success | ✗Vite Task Step 4: nowhere/stray.js exists, expected none | ✓ |
A committed file in an output directory survives a run when git cannot read its indexWhy this is rightA git that cannot read its index lists nothing, which looks like nothing is committed. A tool that clears outputs before a run must refuse when it cannot tell what is committed, not delete. Losing a user’s committed file is worse than any failed build. | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 2: packages/a/out/keep.txt is missing, expected "keep\n" |
A committed file reached through a linked output directory is never deletedWhy this is rightA link such as | ✓ | ✗Nx Step 4: packages/a/public/a.txt is missing, expected "v1\n" | ✓ | ✗moon Step 4: packages/a/public/a.txt is missing, expected "v1\n" | ✓ | ✗Nix Step 2: exited 100, expected success |
An output **/*.js never deletes or captures a nested project’s built filesWhy this is rightProject boundaries are hard: a nested project’s build output belongs to that project. An outer output glob like | ✓ | ✗Nx Step 5: packages/outer/inner/dist/inner.js is "inner\n", expected "mine\n" | ✗Turborepo Step 5: packages/outer/inner/dist/inner.js is "inner\n", expected "mine\n" | ✗moon Step 5: packages/outer/inner/dist/inner.js is "inner\n", expected "mine\n" | ✗Vite Task Step 5: packages/outer/inner/dist/inner.js is "inner\n", expected "mine\n" | ✗Nix Step 3: packages/outer/inner/dist/inner.js is missing, expected "inner\n" |
An output listed twice, and one inside another, restores each file once and intactWhy this is rightOutput globs that list a file twice, or overlap, still describe one set of files. Storing a file twice in one cache entry has broken restores before, leaving files missing or corrupt. Each file must be stored once and come back intact, however the globs overlap. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A replayed app/** leaves the route app/[id] a later task adds, and drops a stray beside itWhy this is rightIn an output glob, | ✓ | ✗Nx Step 3: packages/a/app/i/page.js exists, expected none | ✗Turborepo Step 3: packages/a/app/i/page.js exists, expected none | ✗moon Step 1: exited 1, expected success | ✗Vite Task Step 3: packages/a/app/i/page.js exists, expected none | ✓ |
Deleted outputs come back from the cache with the same bytesWhy this is rightA cache hit stands in for running the command, so it must leave the files the command would have left. If deleted outputs are not restored, the run is green but | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Deleted outputs are restored, not rebuiltWhy this is rightDeleted outputs with unchanged inputs are the normal state after a fresh clone or a clean. The cache holds the exact bytes the command made, so restoring them is enough. Rerunning the command instead spends its full time on every fresh CI machine, which is the work a cache exists to save. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An output edited by hand is put back on the next runWhy this is rightThe result of a run is the tree the inputs produce, not whatever is on disk. A hand edit left in an output ships bytes no build made, and the green run says nothing is wrong. Putting the cached bytes back, or rerunning, keeps outputs a function of inputs. An edit meant to last belongs in the sources. | ✓ | ✓ | ✓ | ✗moon Step 3: packages/a/dist/out.txt is "tampered\n", expected "a\n" | ✓ | ✓ |
A rebuild leaves no output the new inputs no longer produceWhy this is rightOutputs should be exactly what the current inputs produce. When a source is renamed or deleted, the file it used to produce must go too, or it ships from | ✓ | ✗Nx Step 3: packages/a/dist/old.txt exists, expected none | ✗Turborepo Step 3: packages/a/dist/old.txt exists, expected none | ✗moon Step 3: packages/a/dist/old.txt exists, expected none | ✗Vite Task Step 3: packages/a/dist/old.txt exists, expected none | ✓ |
Restoring an older entry leaves no file a later build addedWhy this is rightRestoring a cache entry must give the tree that entry’s inputs produced, not that tree laid over the current one. Here a later build added | ✓ | ✗Nx Step 5: packages/a/dist/b.txt exists, expected none | ✗Turborepo Step 5: packages/a/dist/b.txt exists, expected none | ✗moon Step 5: packages/a/dist/b.txt exists, expected none | ✗Vite Task Step 5: packages/a/dist/b.txt exists, expected none | ✓ |
A stray file dropped in an output directory is gone after a restoreWhy this is rightDeclared outputs are what the task wrote, so a restore leaves exactly that. A stray file dropped into | ✓ | ✗Nx Step 3: packages/a/dist/stray.txt exists, expected none | ✗Turborepo Step 3: packages/a/dist/stray.txt exists, expected none | ✗moon Step 3: packages/a/dist/stray.txt exists, expected none | ✗Vite Task Step 3: packages/a/dist/stray.txt exists, expected none | ✓ |
Only declared outputs are restoredWhy this is rightOnly declared outputs are stored and restored. Here the command also writes | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A ! output pattern keeps that file out of the cacheWhy this is rightA | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An executable output comes back executableWhy this is rightFile mode is part of what a build produces. A script or binary that comes back from the cache without its executable bit fails with permission denied, though the same command run fresh works. The failure shows only on cache hits, often only in CI, which makes it slow to trace. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A symlink output comes back as the same linkWhy this is rightA symlink in the outputs is part of the tree, not a copy of its target. Restored as a regular file, | ✓ | ✓ | ✓ | ✗moon Step 4: packages/a/dist/latest.txt is missing | ✗Vite Task Step 4: packages/a/dist/latest.txt is missing | ✓ |
Nested output directories and binary bytes restore intactWhy this is rightOutputs are bytes, not text. Images, wasm and compiled files hold null bytes, high bytes and carriage returns that must come back unchanged, and deep directories must keep their full paths. A restore that converts line endings, stops at a null byte or drops nested files gives a broken artifact under a green run. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A dependency’s deleted outputs are back before a rebuilding dependent reads themWhy this is rightWhen a dependant rebuilds, its dependencies’ outputs must be on disk first, even if those dependencies hit the cache. Here | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Two projects with the same command and the same sources keep their own outputsWhy this is rightThe cache key must include which project a task belongs to. The same command over the same sources runs in a different directory and can produce different files, here each project’s own name. A key shared across projects restores one project’s bytes into the other: a wrong tree under a green run. | ✓ | ✓ | ✓ | ✓ | ✓ | – |
A replayed task that lists no outputs leaves a file it wrote, and the user then edited, aloneWhy this is rightA task that declares no outputs restores nothing on a replay. Any default would guess what the task writes, and restoring a guessed file overwrites the user’s later edit with an old copy. Here the user changed | ✓ | ✗Nx Step 3: a#build replayed and left packages/a/dist/report.txt "report\n", expected "mine\n" | ✓ | ✓ | ✓ | ✗Nix Step 3: a#build replayed and left packages/a/dist/report.txt "report\n", expected "mine\n" |
A replayed task that lists no outputs does not bring back a file the user deletedWhy this is rightA task that declares no outputs restores nothing on a replay. The user deleted | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 3: a#build replayed and left packages/a/report.txt "report\n", expected none |
Empty directories a task leaves under its outputs come back with a hitWhy this is rightA cache hit should leave the same tree a fresh run leaves, empty directories included. Later steps often expect those directories to exist, such as a server that writes uploads into | ✓ | ✓ | ✓ | ✗moon Step 4: packages/a/dist/empty is missing | ✗Vite Task Step 4: packages/a/dist/empty is missing | ✓ |
An empty output directory removed on its own comes back on the next runWhy this is rightRemoving one empty directory from the outputs changes the tree the task is meant to leave, so the next run must put it back. A tool that compares only files sees nothing missing and reports the task as current. Whatever relies on that directory then fails, though the run said every output was in place. | ✓ | ✓ | ✓ | ✗moon Step 5: packages/a/dist/empty is missing | ✗Vite Task Step 5: packages/a/dist/empty is missing | ✓ |
An empty directory no output pattern reaches is not brought back by a hitWhy this is rightOutput patterns say exactly what a task owns, and a hit restores only that. Here the pattern is | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A file dropped into an output directory the task left empty is gone after the next runWhy this is rightA directory the task left empty is part of its outputs, so a file dropped into it is a stray. If the next run keeps it, the stray ships with the build: a leftover bundle, an old secret, a test fixture. The tree after a run should be the same whether or not someone touched it in between. The empty directory itself stays, since the task made it. | ✓ | ✓ | ✗Turborepo Step 3: packages/a/dist/empty/stale.txt exists, expected none | ✗moon Step 3: packages/a/dist/empty/stale.txt exists, expected none | ✗Vite Task Step 3: packages/a/dist/empty/stale.txt exists, expected none | ✓ |
Empty directories an older build left in the outputs do not come back with a hitWhy this is rightA cache entry should hold what the task made, not whatever an older build left in the output folder. These empty directories predate the run, so an entry that holds them recreates them on every hit, on every machine. Old layouts then come back long after the code that made them is gone. | ✓ | ✗Nx Step 6: packages/a/dist/old exists | ✗Turborepo Step 6: packages/a/dist/old exists | ✓ | ✓ | ✓ |
A stray empty directory under the outputs is gone after a hitWhy this is rightA hit should leave the tree exactly as the task makes it, and a stray empty directory is not part of that. Left in place, it changes what a later step sees, such as a packager that ships every folder or a glob that now matches. The directories the task did make stay; only the leftover goes. | ✓ | ✗Nx Step 6: packages/a/dist/stray exists | ✗Turborepo Step 6: packages/a/dist/stray exists | ✗moon Step 6: packages/a/dist/stray exists | ✗Vite Task Step 6: packages/a/dist/stray exists | ✗Nix Step 6: packages/a/dist/stray exists |
A directory standing where an output file belongs is replaced by the fileWhy this is rightWhen something blocks an output path, the run must still end with the file the task makes. Here a directory stands where | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 5: exited 1, expected success | ✗Nix Step 5: packages/a/dist/real.txt is missing, expected "real\n" |
Output directories come back with the modes the task gave themWhy this is rightDirectory modes are part of what a task produces, so a hit must bring them back. A | ✓ | ✗Nx Step 6: packages/a/dist/keys has mode 755, expected 700 | ✓ | ✗moon Step 6: packages/a/dist/keys has mode 755, expected 700 | ✗Vite Task Step 6: packages/a/dist/keys has mode 755, expected 700 | ✗Nix Step 2: packages/a/dist/keys has mode 755, expected 700 |
Output directory modes come back when the outputs hold a large file tooWhy this is rightDirectory modes must come back however big the outputs are. A cache may store small and large files by different paths, for example inline or as a separate blob, and one of those paths can drop the modes. The developer then finds a private directory readable only on builds that hold a large file. The result should not depend on file size. | ✓ | ✗Nx Step 5: packages/a/dist/keys has mode 755, expected 700 | ✓ | ✗moon Step 5: packages/a/dist/keys has mode 755, expected 700 | ✗Vite Task Step 5: packages/a/dist/keys has mode 755, expected 700 | ✗Nix Step 5: packages/a/dist/keys has mode 755, expected 700 |
Restoring into a partly deleted tree gives the directories it remakes their modesWhy this is rightWhen a restore remakes only part of a tree, the directories it recreates need the same modes a full run gives them. Here | ✓ | ✗Nx Step 4: packages/a/dist/keys has mode 755, expected 700 | ✓ | ✗moon Step 4: packages/a/dist/keys has mode 755, expected 700 | ✗Vite Task Step 4: packages/a/dist/keys has mode 755, expected 700 | ✗Nix Step 4: packages/a/dist/keys has mode 755, expected 700 |
An edited output inside a read-only output directory is put back, and the mode keptWhy this is rightAn edited output must be put back even when it sits in a directory the task made read-only. Writing there means opening the directory for a moment and closing it again. A tool that skips the file leaves the hand edit in a build marked current; one that leaves the directory writable undoes a mode the task chose. Both the bytes and the mode must end as the task left them. | ✓ | ✗Nx Step 4: packages/a/dist/keys has mode 755, expected 700 | ✓ | ✗moon Step 3: packages/a/dist/ro/f is "edited\n", expected "r\n" | ✓ | ✗Nix Step 4: packages/a/dist/keys has mode 755, expected 700 |
A restore writes into an emptied read-only output directory and leaves it read-onlyWhy this is rightA restore must be able to write into a read-only directory the task made, and leave it read-only after. Failing with a permission error turns a cached build red for no reason. Opening the directory and forgetting to close it changes a mode the task set on purpose. The result should match a fresh run. | ✓ | ✗Nx Step 6: packages/a/dist/keys has mode 755, expected 700 | ✓ | ✗moon Step 6: packages/a/dist/keys has mode 755, expected 700 | ✗Vite Task Step 6: packages/a/dist/keys has mode 755, expected 700 | ✗Nix Step 6: packages/a/dist/keys has mode 755, expected 700 |
A stray file added after a hit is gone after the next hitWhy this is rightEvery run must check the outputs against what the task makes, even right after a hit judged them current. A file added after that hit is not part of the cache entry. A tool that trusts its last look skips the check and ships the stray with the next build. Checking the tree on each run is what keeps a hit honest. | ✓ | ✗Nx Step 4: packages/a/dist/stray.txt exists, expected none | ✗Turborepo Step 4: packages/a/dist/stray.txt exists, expected none | ✗moon Step 4: packages/a/dist/stray.txt exists, expected none | ✗Vite Task Step 4: packages/a/dist/stray.txt exists, expected none | ✓ |
A brace output pattern over two directories restores both, and nothing beside themWhy this is rightA brace pattern like | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✓ | ✓ |
A brace of two directory names restores both treesWhy this is rightA pattern naming a directory means everything under it, and a brace of two names means both trees. A tool that matches only the directory entries, or only the first alternative, restores empty folders or half the outputs. The hit is green while the build is missing files. This short spelling must cache the same as the long form | ✓ | ✓ | ✗Turborepo Step 3: packages/a/dist/sub/a.js is missing, expected "a\n" | ✗moon Step 1: exited 1, expected success | ✗Vite Task Step 3: packages/a/dist/sub/a.js is missing, expected "a\n" | ✓ |
A brace whose alternative starts with ./ restores both treesWhy this is right
| ✓ | ✗Nx Step 3: packages/a/dist/sub/a.js is missing, expected "a\n" | ✗Turborepo Step 3: packages/a/dist/sub/a.js is missing, expected "a\n" | ✗moon Step 1: exited 1, expected success | ✗Vite Task Step 3: packages/a/dist/sub/a.js is missing, expected "a\n" | ✓ |
Two output patterns under one directory restore what each matches: dotfiles and empty directories too, and nothing elseWhy this is rightWhen two output patterns cover parts of one directory, a hit restores what either one matches. That includes dotfiles and empty directories under | ✓ | ✓ | ✓ | ✗moon Step 4: packages/a/out/d3/empty is missing | ✗Vite Task Step 4: packages/a/out/d3/empty is missing | ✓ |
A **/*.js output leaves installed packages under node_modules aloneWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An install task that names node_modules as its output gets it restoredWhy this is rightWhen a task names node_modules as its output, that is explicit config, and it beats any default that skips the folder. An install task cached this way should get its packages back on a hit. A tool that silently drops the pattern replays a green install with nothing installed, and the next task fails. Skipping node_modules is right only when nobody asked for it. | ✓ | ✗Nx Step 3: packages/a/node_modules/dep/index.js is missing, expected "a\n" | ✓ | ✓ | ✓ | ✓ |
A replayed task that declares no outputs brings back none of the files it wroteWhy this is rightA task that declares no outputs has said it leaves nothing worth keeping, so a hit restores nothing. Here the task writes | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A task that declares no outputs still replays on an unchanged treeWhy this is rightA task with declared inputs and no outputs, such as a lint or a type check, is still worth caching. Its result is its exit code and log, and both are known for an unchanged tree. Running it again spends minutes on every build for an answer already known. Having no outputs means nothing to restore, not nothing to cache. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An output rewritten by hand to bytes of the same length is put backWhy this is rightAn output edited by hand must be noticed even when the new bytes have the same length. A check of size alone, or an mtime with one-second resolution, sees an unchanged file and keeps the edit. The build then ships content no run of the task produced, under a green result. Comparing content avoids this. | ✓ | ✓ | ✓ | ✗moon Step 3: packages/a/dist/out.txt is "b\n", expected "a\n" | ✓ | ✓ |
Going back to earlier sources restores that build’s output when the task pins every mtimeWhy this is rightSome builds pin every output mtime for reproducible archives, so two different builds can share size and mtime. Going back to earlier sources must restore that earlier build’s bytes. A tool that judges the outputs current from size and mtime keeps the newer build’s output, so the tree no longer matches its sources. Only the bytes tell the two builds apart. | ✓ | ✓ | ✗Turborepo Step 5: packages/a/dist/out.txt is "two2\n", expected "one1\n" | ✓ | ✓ | ✓ |
An output made executable by hand comes back with the mode the task gave itWhy this is rightFile modes are part of the output, so a mode changed by hand must be put back. Here the task makes | ✓ | ✓ | ✓ | ✗moon Step 4: packages/a/dist/out.txt has mode 755, expected 644 | ✓ | ✓ |
Output files named like git’s own (.gitkeep, .gitignore) restoreWhy this is rightFiles like | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A .git directory a task writes inside its outputs restores where it standsWhy this is rightOnly the workspace’s own | ✓ | ✓ | ✗Turborepo Step 3: ran a#build, expected it replayed or left out | ✗moon Step 3: packages/a/dist/sub/.git/config is missing, expected "nested\n" | ✓ | ✓ |
A replay where a plain file stands in place of the output directory fails, and keeps the fileWhy this is rightNo output pattern selects a plain file where the output folder should be, so it is work nothing declared. Deleting it to make room for the restore would destroy it. The task itself could not run there either, so failing and keeping the file is the only safe outcome. | ✓ | ✗Nx Step 3: succeeded, expected a failure | ✓ | ✗moon Step 3: succeeded, expected a failure | ✓ | ✗Nix Step 3: succeeded, expected a failure |
A task that exits while a child it started still writes its outputs is not replayed with the partial treeWhy this is rightThe task exited while a child process it started was still writing the outputs. Saved at that moment, the cache entry holds a half-written tree. Every later replay restores that half, so the build looks done and green while files are missing or cut short. | ✓ | ✗Nx Step 3: did not run a#build | ✗Turborepo Step 3: did not run a#build | ✗moon Step 3: did not run a#build | ✗Vite Task Step 3: did not run a#build | ✗Nix Step 3: did not run a#build |
An output symlink to a file elsewhere in the workspace comes back as the same linkWhy this is rightA link in the outputs comes back as a link with the same target text, even when it points outside the output directory. Storing the target’s bytes instead turns the link into a copy that no longer follows the shared file. Dropping it leaves a missing path. Either way the replayed tree differs from the one the command made. | ✓ | ✓ | ✓ | ✗moon Step 3: packages/a/dist/x is missing, expected "x\n" | ✗Vite Task Step 3: packages/a/dist/x is missing, expected "x\n" | ✓ |
A symlink output beside a large file restores bothWhy this is rightA cache may store large files apart from small ones, but a restore must still give the whole tree. The link and the large file it points to come from one run; restoring one without the other leaves a broken link or a missing file. The file here is large and hard to compress so that such a separate path is taken. | ✓ | ✓ | ✓ | ✗moon Step 4: packages/a/dist/link is missing | ✗Vite Task Step 4: packages/a/dist/link is missing | ✓ |
An output directory that is a symlink is cached through itWhy this is rightThe user declared | ✓ | ✗Nx Step 4: packages/a/build/a.js is missing, expected "a\n" | ✓ | ✗moon Step 4: packages/a/build/a.js is missing, expected "a\n" | ✓ | ✗Nix Step 2: packages/a/dist/a.js is missing, expected "a\n" |
An output directory that is a symlink is cached through it beside a ** patternWhy this is rightThe user declared | ✓ | ✗Nx Step 4: packages/a/build/a.js is missing, expected "a\n" | ✓ | ✗moon Step 4: packages/a/build/a.js is missing, expected "a\n" | ✓ | ✗Nix Step 2: packages/a/dist/a.js is missing, expected "a\n" |
A file pattern under an output directory that is a symlink is cached through itWhy this is rightThe user declared | ✓ | ✗Nx Step 4: packages/a/build/a.js is missing, expected "a\n" | ✗Turborepo Step 4: packages/a/build/a.js is missing, expected "a\n" | ✗moon Step 4: packages/a/build/a.js is missing, expected "a\n" | ✓ | ✗Nix Step 2: packages/a/dist/a.js is missing, expected "a\n" |
An output path that is a link into a nested project never deletes or overwrites that project’s filesWhy this is rightProject boundaries hold even through a link. Here | ✓ | ✓ | ✗Turborepo Step 7: packages/a/site/dist/index.html is "site\n", expected "kept\n" | ✓ | ✓ | – |
A link into .git left inside an output directory never empties the repositoryWhy this is rightClearing an output directory removes the links in it; it never deletes what they point to. Here a link to | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An output directory that is a link into .git never empties the repositoryWhy this is rightHere the output directory itself is a link into | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A replay never writes an output through a link into .gitWhy this is rightA replay writes only the files the task produced, and never through a link that leads into | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 4: a#build replayed and left .git/hooks/pre-commit "planted\n", expected none | ✓ |
A replay never writes an output through a link to a directory .git does not hold yetWhy this is rightA replay writes only the files the task produced, and never through a link that leads into | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Two unordered tasks declaring the same output pattern: neither loses its fileWhy this is rightTwo tasks that declare the same output pattern both claim every file under | ✓ | ✗Nx Step 5: play 2 of 5: packages/a/dist/x.txt is "x1\n", expected "x2\n" | ✗Turborepo Step 5: play 1 of 5: packages/a/dist/x.txt is "x1\n", expected "x2\n" | ✗moon Step 5: play 1 of 5: packages/a/dist/x.txt is "x1\n", expected "x2\n" | ✗Vite Task Step 5: play 1 of 5: packages/a/dist/x.txt is "x1\n", expected "x2\n" | ✗Nix Step 1: play 1 of 5: packages/a/dist/y.txt is missing, expected "y1\n" |
An output pattern that covers another task’s output file: neither loses its fileWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: play 1 of 5: packages/a/dist/app.js is missing, expected "y1\n" |
An output directory named outright that holds another task’s output file: neither loses its fileWhy this is rightNaming | ✓ | ✓ | ✓ | ✗moon Step 5: play 2 of 5: packages/a/dist/x.txt is missing, expected "x2\n" | ✗Vite Task Step 5: play 1 of 5: packages/a/dist/x.txt is missing, expected "x2\n" | ✗Nix Step 1: play 1 of 5: packages/a/dist/sub/deep.txt is missing, expected "y1\n" |
Two output patterns that both match a file one of the tasks writes: neither loses its fileWhy this is right
| ✓ | ✗Nx Step 5: play 2 of 5: packages/a/dist/x.js is "x1\n", expected "x2\n" | ✗Turborepo Step 5: play 2 of 5: packages/a/dist/x.js is "x1\n", expected "x2\n" | ✗moon Step 5: play 1 of 5: packages/a/dist/x.js is "x1\n", expected "x2\n" | ✗Vite Task Step 5: play 2 of 5: packages/a/dist/x.js is "x1\n", expected "x2\n" | ✓ |
A brace output pattern that names another task’s output file: neither loses its fileWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: play 1 of 5: packages/a/dist/a.txt is missing, expected "y1\n" |
Two tasks writing distinct files into one directory build, cache and restore side by sideWhy this is rightTwo tasks writing | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Two tasks writing sibling subdirectories of one directory build, cache and restore side by sideWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A pattern and a file it does not match in one directory build, cache and restore side by sideWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A one-level pattern beside another task’s subdirectory build, cache and restore side by sideWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A pattern whose ! entry leaves out the other task’s file build, cache and restore side by sideWhy this is rightThe | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A file output and another task’s file whose name extends it build, cache and restore side by sideWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Editing one of two tasks that write distinct files into one directory reruns only itWhy this is rightTasks that write different files are independent, so editing one task’s sources reruns only that task. Here both write into | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 3: ran a#y, expected it replayed or left out | ✓ |
A task that adds a subdirectory of the task before it: a warm run leaves the cold run’s treeWhy this is rightWhen one task adds files to another’s output tree after it, a warm run must leave the tree a cold run of both leaves. A tool that restores the first task’s entry as the whole of | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✓ | ✓ |
A task that adds a subdirectory of the task before it: an unchanged tree replays all threeWhy this is rightWith nothing changed, all three tasks should replay. A shared output tree can make a tool see the second task’s files as damage to the first task’s outputs, so it reruns the first, then the second, on every build. That repeats work no edit asked for. Here the second task declares only | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✓ | ✓ |
A task that adds a subdirectory of the task before it: editing the first keeps the second’s file and none of the first’s old bytesWhy this is rightThe second task’s cache entry should hold only what it added, not the first task’s files. After an edit to the first task, a fresh build writes new bytes, and an entry that also held the first task’s files would restore the old ones over them. The tree then mixes old and new output under a green run. Here the second task declares only | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✓ | ✓ |
A task that adds a subdirectory of the task before it: editing the second rebuilds its file beside the first’sWhy this is rightEditing the second task’s sources reruns it, and its new file must land beside the first task’s files. A tool that clears the shared tree before the rerun, or restores the first task over it afterwards, loses one side. The result should match a cold run of both commands. Here the second task declares only | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✓ | ✓ |
A task that adds a subdirectory of the task before it: undoing an edit to the second restores its first fileWhy this is rightUndoing an edit brings back sources built before, so their cached result must restore. The second task’s earlier file has to come back next to the first task’s files, which stay untouched. A tool that restores the second entry as the whole tree, or skips it because the tree looks current, leaves the wrong bytes. Here the second task declares only | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✓ | ✓ |
A task that declares the same tree of the task before it: a warm run leaves the cold run’s treeWhy this is rightWhen one task adds files to another’s output tree after it, a warm run must leave the tree a cold run of both leaves. A tool that restores the first task’s entry as the whole of | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✓ | ✓ |
A task that declares the same tree of the task before it: an unchanged tree replays all threeWhy this is rightWith nothing changed, all three tasks should replay. A shared output tree can make a tool see the second task’s files as damage to the first task’s outputs, so it reruns the first, then the second, on every build. That repeats work no edit asked for. Here both tasks declare all of | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✓ | ✓ |
A task that declares the same tree of the task before it: editing the first keeps the second’s file and none of the first’s old bytesWhy this is rightThe second task’s cache entry should hold only what it added, not the first task’s files. After an edit to the first task, a fresh build writes new bytes, and an entry that also held the first task’s files would restore the old ones over them. The tree then mixes old and new output under a green run. Here both tasks declare all of | ✓ | ✗Nx Step 3: packages/a/dist/a.txt is "A1\n", expected "A2\n" | ✓ | ✗moon Step 1: exited 1, expected success | ✓ | ✓ |
A task that declares the same tree of the task before it: editing the second rebuilds its file beside the first’sWhy this is rightEditing the second task’s sources reruns it, and its new file must land beside the first task’s files. A tool that clears the shared tree before the rerun, or restores the first task over it afterwards, loses one side. The result should match a cold run of both commands. Here both tasks declare all of | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✓ | ✓ |
A task that declares the same tree of the task before it: undoing an edit to the second restores its first fileWhy this is rightUndoing an edit brings back sources built before, so their cached result must restore. The second task’s earlier file has to come back next to the first task’s files, which stay untouched. A tool that restores the second entry as the whole tree, or skips it because the tree looks current, leaves the wrong bytes. Here both tasks declare all of | ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✓ | ✓ |
A file a later task deletes from an earlier task’s outputs stays deleted on warm runsWhy this is rightWhen a later task deletes a file from an earlier task’s outputs, a warm run must end without it, as a cold run does. Replaying the earlier task brings the file back. The later task must then remove it again, or its run must not be replayed while the file is there. Otherwise a temporary file ships with every warm build. | ✓ | ✗Nx Step 2: packages/a/dist/tmp.js exists, expected none | ✗Turborepo Step 2: packages/a/dist/tmp.js exists, expected none | ✗moon Step 2: packages/a/dist/tmp.js exists, expected none | ✗Vite Task Step 2: packages/a/dist/tmp.js exists, expected none | ✓ |
A later task’s same-length rewrite of an earlier task’s output survives warm runs, mtime pinnedWhy this is rightWhen a later task rewrites an earlier task’s output, every run must end with the later bytes. Here the rewrite keeps the length and pins the mtime, as reproducible builds do. A tool that judges files by size and mtime alone takes the rewritten file for the earlier task’s own, and the old bytes win. Only the content tells them apart. | ✓ | ✓ | ✓ | ✗moon Step 2: packages/a/dist/a.txt is "hello\n", expected "HELLO\n" | ✓ | ✓ |
An output directory written with a trailing slash that holds another task’s file: neither loses its fileWhy this is right
| ✓ | ✓ | ✓ | ✗moon Step 5: play 1 of 5: packages/a/dist/x.txt is missing, expected "x2\n" | ✓ | ✗Nix Step 1: play 1 of 5: packages/a/dist/app.js is missing, expected "y1\n" |
One output pattern spelled ./dist/** and the same pattern spelled dist/**: neither loses its fileWhy this is right
| ✓ | ✗Nx Step 5: play 3 of 5: packages/a/dist/x.txt is "x1\n", expected "x2\n" | ✗Turborepo Step 5: play 1 of 5: packages/a/dist/x.txt is "x1\n", expected "x2\n" | ✗moon Step 5: play 1 of 5: packages/a/dist/x.txt is "x1\n", expected "x2\n" | ✗Vite Task Step 5: play 1 of 5: packages/a/dist/x.txt is "x1\n", expected "x2\n" | ✗Nix Step 1: play 1 of 5: packages/a/dist/y.txt is missing, expected "y1\n" |
A ? output pattern that matches another task’s output file: neither loses its fileWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: play 1 of 5: packages/a/dist/ab.txt is missing, expected "y1\n" |
A name-* output pattern that matches another task’s output file: neither loses its fileWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: play 1 of 5: packages/a/dist/vx-linux-x64 is missing, expected "y1\n" |
A whole output tree and another task’s pattern for a directory inside it: neither loses its fileWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: play 1 of 5: packages/a/dist/extra/y.txt is missing, expected "y1\n" |
A whole output tree and another task’s pattern for one extension inside it: neither loses its fileWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: play 1 of 5: packages/a/dist/types/y.d.ts is missing, expected "y1\n" |
A pattern for one extension anywhere and another task’s output tree holding such a file: neither loses its fileWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: play 1 of 5: packages/a/dist/y.d.ts is missing, expected "y1\n" |
A pattern with a wildcard directory that matches another task’s output file: neither loses its fileWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: play 1 of 5: packages/a/dist/app.js is missing, expected "y1\n" |
A pattern with a brace set of directories that names another task’s output file: neither loses its fileWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: play 1 of 5: packages/a/lib/app.js is missing, expected "y1\n" |
A brace output pattern and a wildcard one that both match the file one task writes: neither loses its fileWhy this is right
| ✓ | ✗Nx Step 5: play 2 of 5: packages/a/build/b.js is "x1\n", expected "x2\n" | ✗Turborepo Step 5: play 1 of 5: packages/a/build/b.js is "x1\n", expected "x2\n" | ✗moon Step 5: play 1 of 5: packages/a/build/b.js is "x1\n", expected "x2\n" | ✗Vite Task Step 5: play 1 of 5: packages/a/build/b.js is "x1\n", expected "x2\n" | ✓ |
A pattern whose ! entry takes back only part of another task’s output tree: neither loses its fileWhy this is rightThe | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: play 1 of 5: packages/a/types/dist/y.txt is missing, expected "y1\n" |
Two projects’ root output patterns where one covers the other’s file: neither loses its fileWhy this is rightRoot outputs live outside a project, so two projects can claim the same workspace path. Here | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: play 1 of 5: shared/b.txt is missing, expected "y1\n" |
A root output file inside another project’s output tree: neither loses its fileWhy this is rightA root output can point into another project’s folder. Here one task writes | ✓ | ✗Nx Step 5: play 1 of 5: packages/b/dist/a.txt is "x1\n", expected "x2\n" | ✗Turborepo Step 5: play 2 of 5: packages/b/dist/a.txt is "x1\n", expected "x2\n" | ✗moon Step 5: play 1 of 5: packages/b/dist/a.txt is "x1\n", expected "x2\n" | ✗Vite Task Step 5: play 1 of 5: packages/b/dist/a.txt is "x1\n", expected "x2\n" | ✓ |
A root output tree that holds another project’s output file: neither loses its fileWhy this is rightThe root output tree | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: play 1 of 5: packages/b/dist/y.txt is missing, expected "y1\n" |
Two tasks naming two different output directories outright build, cache and restore side by sideWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 5: packages/a/dist/x.txt is missing, expected "x2\n" | ✓ |
An output directory and another task’s file in a directory whose name extends it build, cache and restore side by sideWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 5: packages/a/dist/x.txt is missing, expected "x2\n" | ✓ |
Two tasks naming sibling subdirectories of one directory outright build, cache and restore side by sideWhy this is rightNaming | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 5: packages/a/dist/a/x.txt is missing, expected "x2\n" | ✓ |
An output tree and another task’s tree in a directory whose name extends it build, cache and restore side by sideWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An output tree and another task’s pattern over a sibling directory build, cache and restore side by sideWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Two patterns over one tree that take different extensions build, cache and restore side by sideWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An output tree and another task’s deep pattern over another directory build, cache and restore side by sideWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A ? output pattern beside a file it does not match build, cache and restore side by sideWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A brace output pattern beside a file it does not name build, cache and restore side by sideWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Two sibling output trees spelled ./dist/a/** and dist//b/** build, cache and restore side by sideWhy this is right
| ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✗Vite Task Step 1: exited 1, expected success | ✗Nix Step 1: packages/a/dist/b/y.txt is missing, expected "y1\n" |
A pattern whose ! entry takes back another task’s whole output tree build, cache and restore side by sideWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A brace output pattern beside another task’s file in the same directory build, cache and restore side by sideWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An output tree and another task’s files beside it, one in a directory whose name extends it build, cache and restore side by sideWhy this is right
| ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✓ | ✓ |
Two projects writing distinct root output files into one directory build, cache and restore side by sideWhy this is rightTwo projects writing | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A project output and another project’s root output with the same relative name build, cache and restore side by sideWhy this is rightA project output | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A root output file in another project, beside that project’s output tree build, cache and restore side by sideWhy this is rightA root output in | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A root output file in another project’s output directory that its pattern does not match build, cache and restore side by sideWhy this is rightProject b claims | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A root output file in another project’s output tree that a ! entry takes back build, cache and restore side by sideWhy this is rightProject b’s | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Two projects declaring the same project-relative output pattern build, cache and restore side by sideWhy this is rightTwo projects declaring | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A project that adds a root file beside a dependency’s root output tree: warm runs and an upstream edit keep bothWhy this is rightWhen a project adds a root file beside a dependency’s root output tree, every run must end with both files. The dependency’s | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A root output file added into a dependency project’s output tree survives warm runs and that project’s rebuildWhy this is rightA dependant may add a file into a dependency’s output tree through a root output. After the dependency rebuilds, that file must still be present. A tool that clears the whole | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A stray file dropped deep in an output tree after warm runs is gone on the next runWhy this is rightOutputs are what the task wrote, so a file dropped deep in the tree after several warm runs is a stray. A tool that trusts an unchanged tree from its last look, and skips the check, ships the stray with the next build. The deeper the file, the less likely anyone notices. Each run must leave exactly the task’s tree. | ✓ | ✓ | ✗Turborepo Step 5: packages/a/dist/sub/stray.js exists, expected none | ✗moon Step 5: packages/a/dist/sub/stray.js exists, expected none | ✗Vite Task Step 5: packages/a/dist/sub/stray.js exists, expected none | ✓ |
A file a later task writes into an earlier task’s outputs is gone when that task replaysWhy this is rightA file a later task writes into an earlier task’s outputs is not part of the earlier task’s result. When the earlier task replays, its tree must match what it made. A replay that keeps the file, or an entry that stored it as the task’s, ships a file no build of that task produced. If the later task needs it, the later task makes it again. | ✓ | ✓ | ✗Turborepo Step 2: packages/a/dist/sub/stray.js exists, expected none | ✗moon Step 2: packages/a/dist/sub/stray.js exists, expected none | ✗Vite Task Step 2: packages/a/dist/sub/stray.js exists, expected none | ✓ |
An output a later task deletes is back when the task that wrote it replaysWhy this is rightA replayed task’s outputs are what the task made, even if a later task deleted one in between. Here an uncached step removed | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: packages/a/dist/sub/in.js exists, expected none |
A stray file in an output directory named outright is gone after the next runWhy this is rightNaming a directory outright, as | ✓ | ✓ | ✗Turborepo Step 4: packages/a/dist/sub/stray.js exists, expected none | ✗moon Step 4: packages/a/dist/sub/stray.js exists, expected none | ✗Vite Task Step 4: packages/a/dist/sub/stray.js exists, expected none | ✓ |
An output file named outright and edited by hand is put back on the next runWhy this is rightAn output named outright, like | ✓ | ✓ | ✓ | ✗moon Step 4: packages/a/dist/tool is "tampered", expected "a\n" | ✓ | ✓ |
A directory standing where an output file named outright belongs is replaced by the fileWhy this is rightWhen a directory stands where a literal output file belongs, the next run must replace it with the file. Failing to remove a non-empty directory turns a cached build red, and keeping it ships a stray tree. The run should end with | ✓ | ✓ | ✗Turborepo Step 5: packages/a/dist/tool is missing, expected "a\n" | ✗moon Step 5: packages/a/dist/tool is missing, expected "a\n" | ✗Vite Task Step 5: exited 1, expected success | ✗Nix Step 5: packages/a/dist/tool is missing, expected "a\n" |
A stray in a new directory an anywhere-pattern (**/dist/**) reaches is gone after a hitWhy this is right
| ✓ | ✗Nx Step 4: packages/a/lib/dist/stray.js exists, expected none | ✗Turborepo Step 4: packages/a/lib/dist/stray.js exists, expected none | ✗moon Step 4: packages/a/lib/dist/stray.js exists, expected none | ✗Vite Task Step 4: packages/a/lib/dist/stray.js exists, expected none | ✓ |
A stray file in a deleted root output directory is gone when the task replaysWhy this is rightA root output tree is the task’s output like any other, even though it sits outside the project. If it is deleted and a stray file appears there, a replay must restore the real files and remove the stray. A tool that cleans only project outputs keeps the stray at the workspace root. Root outputs need the same care as project outputs. | ✓ | ✗Nx Step 4: generated/stale.txt exists, expected none | ✗Turborepo Step 4: generated/stale.txt exists, expected none | ✗moon Step 4: generated/stale.txt exists, expected none | ✗Vite Task Step 4: generated/stale.txt exists, expected none | ✓ |
A task with project and root outputs replays once both are restoredWhy this is rightOnce a task’s project and root outputs are both restored, nothing about it has changed, so later runs should replay. A tool that tracks root outputs poorly may see them as missing or new and rerun the task on every build. That repeats the work each time though no input changed. A restore should settle the task. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Outputs with paths and file names over 100 bytes restore intact, with their modesWhy this is rightA cache must store and restore every path a task writes, whatever its length. Old archive formats limit names to 100 bytes, and deep package layouts or snapshot files pass that easily. A cache that cannot hold a long name drops the file on every restore, or fails a build that passed. Modes, like the executable bit on | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Outputs with names over 100 bytes are cached: deleted, they are restored, not rebuiltWhy this is rightLong output names should not stop a task from being cached. A tool that cannot store them may skip the entry and rebuild the task each time its outputs are missing. The build stays correct but pays the full cost on every clean checkout. Deleted outputs should mean a restore, not a rebuild. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A task whose own output patterns and directories overlap restores exactly the tree it wroteWhy this is rightA task’s own output patterns may overlap: | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 3: packages/a/.next/server/page.js is missing, expected "p\n" | ✓ |
A task whose own output patterns overlap replays after a restoreWhy this is rightOverlapping patterns within one task, common in framework configs, should not stop it from replaying. A tool that counts a file twice may see a mismatch after its own restore and rerun the task. Then every run after a clean checkout rebuilds work that was already cached. A restore should settle the task. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An output pattern with a brace set of extensions restores those files and no otherWhy this is right
| ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Output names with a space, a quote, a backslash or non-ASCII letters restore as writtenWhy this is rightFile names with spaces, quotes, backslashes or non-ASCII letters are legal and turn up in assets and tests. A cache must store and restore them exactly. A tool that passes names through a shell or an escaping step can drop, rename or split them, so the restored tree differs from the built one. A file must come back under the name the task gave it. | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 1: exited 1, expected success | ✓ |
Outputs with odd names are cached: deleted, they are restored, not rebuiltWhy this is rightOdd file names should not stop a task from being cached. A tool that fails to store or match them may treat the outputs as missing and rebuild every time. The build stays correct, but a task that never hits costs its full run on every machine. Deleted outputs should be restored, not rebuilt. | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 1: exited 1, expected success | ✓ |
A large incompressible output restores byte for byte, an executable beside it kept executableWhy this is rightA large output must restore byte for byte. Incompressible data, like this 6 MB file, takes the paths that compression, chunking or streaming use for big files. A cache that truncates or corrupts it replays a broken artifact under a green run. The executable bit on the script beside it must survive the same trip. | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix harness: Error: step 4 `cd packages/a && cksum < dist/big.bin | cmp -s - sum.txt` exited 2: |
A retry does not remove the root output directory a task beside it is writing intoWhy this is rightA retry may clean up the failed attempt’s outputs, but only the outputs that task declared. Here | ✓ | – | – | ✓ | – | – |
An output whose mode was changed by hand gets back the mode the task gave itWhy this is rightA file’s mode is part of what a task produces, so a hand change to it must be undone. Here the task makes | ✓ | ✓ | ✓ | ✗moon Step 4: packages/a/dist/out.txt has mode 755, expected 644 | ✓ | ✓ |
Empty directories a task leaves under its root outputs come back with a hitWhy this is rightEmpty directories under a root output tree are part of the task’s result, just as under a project’s outputs. A hit must bring back | ✓ | ✓ | ✓ | ✗moon Step 4: gen/empty is missing | ✗Vite Task Step 4: gen/empty is missing | ✓ |
A file dropped into a root output directory the task left empty is gone after the next runWhy this is rightA root output folder the task left empty is part of its outputs, so a file dropped into it is a stray. The next run must remove it, as it would under a project’s outputs. A tool that cleans only project trees leaves the stray at the workspace root, where it ships or leaks into later steps. The empty folder itself stays. | ✓ | ✓ | ✗Turborepo Step 3: gen/empty/stale.txt exists, expected none | ✗moon Step 3: gen/empty/stale.txt exists, expected none | ✗Vite Task Step 3: gen/empty/stale.txt exists, expected none | ✓ |
Empty directories an older build left in a root output tree do not come back with a hitWhy this is rightA cache entry holds what the task made, not what older builds left behind. | ✓ | ✗Nx Step 6: gen/old exists | ✗Turborepo Step 6: gen/old exists | ✓ | ✓ | ✓ |
A stray empty directory under a root output tree is gone after a hitWhy this is rightA stray empty folder under a root output tree is not part of the task’s result, so a hit removes it. Left in place, it changes what a later step sees, such as a glob that now matches or a package that ships an extra folder. Root outputs should be cleaned the same way as project outputs. | ✓ | ✗Nx Step 5: gen/stray exists | ✗Turborepo Step 5: gen/stray exists | ✗moon Step 5: gen/stray exists | ✗Vite Task Step 5: gen/stray exists | ✗Nix Step 5: gen/stray exists |
A hit puts back an edited output, a file replaced by a directory, and removes a stray tree, in one restoreWhy this is rightA restore must repair every kind of damage at once: an edited file, a file replaced by a directory, and a stray tree. A tool may handle each alone yet fail on the mix, for example by writing a file into a path a directory still holds. The run then goes red, or leaves part of the damage. The end tree must match a fresh run. | ✓ | ✗Nx Step 5: packages/a/dist/stale exists | ✗Turborepo Step 4: exited 1, expected success | ✗moon Step 4: exited 1, expected success | ✗Vite Task Step 4: exited 1, expected success | ✗Nix Step 4: packages/a/dist/out is missing, expected "out\n" |
Appending to an output after a build or a restore does not change what the next restore brings backWhy this is rightWhat a cache stores for a task must not change after the task ends. A cache that hard-links or shares its stored copy with the working tree lets a later edit to the tree rewrite the entry. Every later hit then replays the edit instead of the build. Edits after a build or a restore must stay local. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An output symlink to a directory comes back as the same linkWhy this is rightA link to a directory in the outputs is part of the tree and comes back as a link. Restored as a copy of the directory, the two paths split apart, so a later write to one no longer shows in the other. A replay must leave the tree the command made: the real directory, with its files, and the link to it. | ✓ | ✓ | ✓ | ✗moon Step 3: packages/a/dist/linkdir/s.txt is missing, expected "s\n" | ✗Vite Task Step 3: packages/a/dist/linkdir/s.txt is missing, expected "s\n" | ✓ |
A link with a 300-byte target and an empty directory 300 bytes deep come backWhy this is rightOutputs can have long paths, and empty directories are part of the tree. Classic tar headers hold 100 bytes for a name or a link target; past that an archive needs an extended header. One that truncates instead restores a link to the wrong place and drops the deep empty directory: a different tree under a green run. | ✓ | ✓ | ✓ | ✗moon Step 3: packages/a/dist/link is missing, expected "t\n" | ✗Vite Task Step 3: packages/a/dist/link is missing, expected "t\n" | ✓ |
An output link whose target is not there comes back as the same linkWhy this is rightA link whose target does not exist yet is still output: a build may leave it for a later step, or for the program at runtime, to fill. Saving only what resolves drops the link, and a replay then gives a tree without it. Restoring it with its exact target text keeps a replay equal to a real run. | ✓ | ✗Nx Step 4: packages/a/dist/link.txt is missing | ✓ | ✗moon Step 4: packages/a/dist/link.txt is missing | ✗Vite Task Step 4: packages/a/dist/link.txt is missing | ✓ |
An output link pointed elsewhere by hand, at the same length, is put backWhy this is rightA link output edited by hand is like a file edited by hand: the next run puts back what the inputs produce. Here the link is pointed at | ✓ | ✓ | ✓ | ✗moon Step 4: packages/a/dist/link.txt is "b\n", expected "a\n" | ✗Vite Task Step 4: packages/a/dist/link.txt is "b\n", expected "a\n" | ✓ |
Paths a ! output entry takes back are left in place by a run and never come back from a replayWhy this is rightA | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A replay restores the outputs and leaves an edited path a ! entry takes back aloneWhy this is rightThe | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A stray file in an output directory with ! entries is gone after a replayWhy this is right
| ✓ | ✗Nx Step 4: packages/a/out/stray.js exists, expected none | ✗Turborepo Step 4: packages/a/out/stray.js exists, expected none | ✗moon Step 4: packages/a/out/stray.js exists, expected none | ✗Vite Task Step 4: packages/a/out/stray.js exists, expected none | ✓ |
Editing a path a ! output entry takes back, and the inputs name, reruns the taskWhy this is rightA path can be excluded from outputs and still be an input. Here | ✓ | ✓ | ✓ | ✗moon Step 3: did not run a#build | ✓ | ✓ |
A task adding to the tree of the task before it keeps its ! scratch file out of what a replay brings backWhy this is rightWhen a later task adds to the earlier task’s output tree, each task’s | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Two tasks with disjoint outputs, each with a ! entry, both runWhy this is rightTwo tasks conflict only when they write the same path. Here | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A stray in an earlier task’s outputs is gone after a replay, beside a later task’s narrower ! entryWhy this is rightEach task’s | ✓ | ✗Nx Step 3: packages/a/out/stray.js exists, expected none | ✗Turborepo Step 3: packages/a/out/stray.js exists, expected none | ✗moon Step 3: packages/a/out/stray.js exists, expected none | ✗Vite Task Step 3: packages/a/out/stray.js exists, expected none | ✓ |
A root output’s ! entry leaves that file in place, out of the replay, and still an inputWhy this is rightA | ✓ | ✓ | ✓ | ✗moon Step 5: did not run a#build | ✓ | ✓ |
An output directory the user made a link out of the project stays a link, and a replay writes nothing through itWhy this is rightA link at the output directory that leads out of the project is the user’s setup, not the task’s output. A replay that writes through it puts files outside the project, where no run of the task asked. Replacing it with a real directory discards what the user set up. Keeping the link and writing nothing through it is the safe outcome. | ✓ | ✗Nx Step 5: packages/a/dist is not a symlink | ✗Turborepo Step 5: packages/a/dist is not a symlink | ✓ | ✗Vite Task Step 4: a#build replayed and left elsewhere/o.txt "x", expected none | ✓ |
An output directory the user made a link inside the project is restored throughWhy this is rightA link at the output directory that stays inside the project changes where the files live, not what the task writes. Restoring through it gives the same tree as running the command, which also writes through the link. Replacing the link with a directory undoes the user’s layout and leaves the real directory empty. | ✓ | ✗Nx Step 4: packages/a/real-out/o.txt is missing, expected "x" | ✗Turborepo Step 4: packages/a/real-out/o.txt is missing, expected "x" | ✗moon Step 4: packages/a/real-out/o.txt is missing, expected "x" | ✓ | ✗Nix Step 4: packages/a/real-out/o.txt is missing, expected "x" |
A dangling link inside the project at the output directory is restored through: its target made, the link keptWhy this is rightA link inside the project is written through, whether or not its target exists yet. The command would create the target and write there, so a replay does the same: it makes the target, restores the file and keeps the link. Replacing a dangling link discards the user’s layout just because its target was cleaned. | ✓ | ✗Nx Step 4: packages/a/real-out/o.txt is missing, expected "x" | ✗Turborepo Step 4: packages/a/real-out/o.txt is missing, expected "x" | ✗moon Step 4: packages/a/real-out/o.txt is missing, expected "x" | ✗Vite Task Step 4: exited 1, expected success | ✗Nix Step 4: packages/a/real-out/o.txt is missing, expected "x" |
A dangling link inside the project below the output directory is restored through: its target made, the link keptWhy this is rightA link inside the project is written through, whether or not its target exists yet. The command would create the target and write there, so a replay does the same: it makes the target, restores the file and keeps the link. Replacing a dangling link discards the user’s layout just because its target was cleaned. | ✓ | ✗Nx Step 4: packages/a/real-sub/o.txt is missing, expected "x" | ✗Turborepo Step 4: packages/a/real-sub/o.txt is missing, expected "x" | ✗moon Step 4: packages/a/real-sub/o.txt is missing, expected "x" | ✗Vite Task Step 4: exited 1, expected success | ✗Nix Step 4: packages/a/real-sub/o.txt is missing, expected "x" |
A dangling link inside the project at the output directory, above a deeper output is restored through: its target made, the link keptWhy this is rightA link inside the project is written through, whether or not its target exists yet. The command would create the target and write there, so a replay does the same: it makes the target, restores the file and keeps the link. Replacing a dangling link discards the user’s layout just because its target was cleaned. | ✓ | ✗Nx Step 4: packages/a/real-out/sub/o.txt is missing, expected "x" | ✗Turborepo Step 4: packages/a/real-out/sub/o.txt is missing, expected "x" | ✗moon Step 4: packages/a/real-out/sub/o.txt is missing, expected "x" | ✗Vite Task Step 4: exited 1, expected success | ✗Nix Step 4: packages/a/real-out/sub/o.txt is missing, expected "x" |
A dangling link inside the project through a chain of links is restored through: its target made, the link keptWhy this is rightA link inside the project is written through, whether or not its target exists yet. The command would create the target and write there, so a replay does the same: it makes the target, restores the file and keeps the link. Replacing a dangling link discards the user’s layout just because its target was cleaned. | ✓ | ✗Nx Step 4: packages/a/real-out/o.txt is missing, expected "x" | ✗Turborepo Step 4: packages/a/real-out/o.txt is missing, expected "x" | ✗moon Step 4: packages/a/real-out/o.txt is missing, expected "x" | ✗Vite Task Step 4: exited 1, expected success | ✗Nix Step 4: packages/a/real-out/o.txt is missing, expected "x" |
A dangling absolute link inside the project at the output directory is restored throughWhy this is rightA link written as an absolute path is judged by where it leads, not how it is spelled. This one leads inside the project, so it is treated like a relative link there: the replay makes the target and restores through it. Treating every absolute link as outside the project would replace the user’s link with a directory. | ✓ | ✗Nx Step 4: packages/a/real-out/o.txt is missing, expected "x" | ✗Turborepo Step 4: packages/a/real-out/o.txt is missing, expected "x" | ✗moon Step 4: packages/a/real-out/o.txt is missing, expected "x" | ✗Vite Task Step 4: exited 1, expected success | ✗Nix Step 4: packages/a/real-out/o.txt is missing, expected "x" |
A dangling output link is restored through by a task that waits on a same-project dependencyWhy this is rightWriting through an output link works the same when the task waits on another task. Here | ✓ | ✗Nx Step 4: packages/a/real-out/o.txt is missing, expected "seed" | ✗Turborepo Step 4: packages/a/real-out/o.txt is missing, expected "seed" | ✗moon Step 4: packages/a/real-out/o.txt is missing, expected "seed" | ✗Vite Task Step 4: exited 1, expected success | ✗Nix Step 4: packages/a/real-out/o.txt is missing, expected "seed" |
A dangling output link out of the project at the output directory gets nothing created through itWhy this is rightA link out of the project, even one whose target is missing, is no place for a replay to write. Creating the target to restore into it writes files outside the project, where the task has no say. The link is the user’s and stays as it was. A failed run is acceptable here; creating the target is not. | ✓ | ✗Nx Step 5: packages/a/dist is not a symlink | ✗Turborepo Step 5: packages/a/dist is not a symlink | ✓ | ✓ | ✓ |
A dangling output link out of the project below the output directory gets nothing created through itWhy this is rightA link out of the project, even one whose target is missing, is no place for a replay to write. Creating the target to restore into it writes files outside the project, where the task has no say. The link is the user’s and stays as it was. A failed run is acceptable here; creating the target is not. | ✓ | ✗Nx Step 5: packages/a/dist/sub is not a symlink | ✗Turborepo Step 5: packages/a/dist/sub is not a symlink | ✓ | ✓ | ✓ |
A dangling output link out of the project at the output directory, above a deeper output gets nothing created through itWhy this is rightA link out of the project, even one whose target is missing, is no place for a replay to write. Creating the target to restore into it writes files outside the project, where the task has no say. The link is the user’s and stays as it was. A failed run is acceptable here; creating the target is not. | ✓ | ✗Nx Step 5: packages/a/dist is not a symlink | ✗Turborepo Step 5: packages/a/dist is not a symlink | ✓ | ✓ | ✓ |
A dangling output link out of the project as the last hop of a chain gets nothing created through itWhy this is rightA link out of the project, even one whose target is missing, is no place for a replay to write. Creating the target to restore into it writes files outside the project, where the task has no say. The link is the user’s and stays as it was. A failed run is acceptable here; creating the target is not. | ✓ | ✗Nx Step 5: packages/a/dist is not a symlink | ✗Turborepo Step 5: packages/a/dist is not a symlink | ✓ | ✓ | ✓ |
A dangling output link out of the project relatively, up out of the project gets nothing created through itWhy this is rightA link out of the project, even one whose target is missing, is no place for a replay to write. Creating the target to restore into it writes files outside the project, where the task has no say. The link is the user’s and stays as it was. A failed run is acceptable here; creating the target is not. | ✓ | ✗Nx Step 5: packages/a/dist is not a symlink | ✗Turborepo Step 5: packages/a/dist is not a symlink | ✓ | ✓ | ✓ |
A dangling link out of the project where an output directory belongs is replaced by the directoryWhy this is right
| ✓ | ✓ | ✓ | ✗moon Step 4: packages/a/dist/sub/o.txt is missing, expected "x" | ✗Vite Task Step 4: exited 1, expected success | ✓ |
The root project’s output directory linked to .git never empties the repositoryWhy this is rightWhen the repository root is itself a project, | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A root output directory linked to .git never empties the repositoryWhy this is rightAn output declared at the workspace root, outside any project, can be a link into | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
The root project’s output directory linked to .git/refs never empties the repositoryWhy this is rightWhen the repository root is itself a project, | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A root output directory linked to .git/refs never empties the repositoryWhy this is rightAn output declared at the workspace root, outside any project, can be a link into | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A root **/*.js output leaves installed packages and .git aloneWhy this is right
| ✓ | ✓ | ✗Turborepo Step 7: lib/node_modules/dep/index.js is "dep\n", expected "mine\n" | ✗moon Step 7: lib/node_modules/dep/index.js is "dep\n", expected "mine\n" | ✓ | ✓ |
An escaped literal output out/\[x\] restores as a file where it was a directory, and backWhy this is right
| ✓ | ✓ | ✓ | ✗moon Step 2: exited 1, expected success | ✓ | ✗Nix Step 4: packages/a/out/[x] is missing, expected "2\n" |
A root output edited by hand is put back on the next runWhy this is rightAn output at the workspace root, outside the project, is still the task’s output and follows the same rule. A hand edit to it is not what the inputs produce, so the next run puts back the cached bytes or reruns. Leaving the edit ships a file no build made under a green run. | ✓ | ✓ | ✓ | ✗moon Step 3: shared/g.txt is "CORRUPT", expected "g" | ✓ | ✓ |
A stray file in the root project’s output directory is gone after a replayWhy this is rightWhen the repository root is a project, its | ✓ | ✗Nx Step 3: dist/STRAY.txt exists, expected none | ✗Turborepo Step 3: dist/STRAY.txt exists, expected none | ✗moon Step 3: dist/STRAY.txt exists, expected none | ✗Vite Task Step 3: dist/STRAY.txt exists, expected none | ✓ |
A task adding a subdirectory to an earlier task’s root output: a warm run leaves the cold run’s treeWhy this is rightTwo tasks may share an output tree when one runs after the other: here | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A task adding a subdirectory to an earlier task’s root output: an unchanged tree replays bothWhy this is rightWhen nothing changed, both tasks writing into a shared tree replay. Rerunning one because its outputs overlap another’s costs its full time on every run. The graph orders the two, so restoring the earlier task and then the later one gives the cold run’s tree with no command run. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A task writing into the root output it reads from the task before it keeps that task’s filesWhy this is rightA later task may read from and write into the tree the task before it wrote. Here | ✓ | ✓ | ✓ | ✗moon Step 4: gen/b.txt is missing, expected "A1B1" | ✗Vite Task Step 4: gen/a.txt is missing, expected "A1" | ✓ |
A task writing into another project’s output directory from the root, and that project’s task adding to it: a warm run keeps both filesWhy this is rightA task in one project may write into another project’s output directory: here | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 1: packages/b/dist/b.txt is missing, expected "B1" |
A task writing into another project’s output directory from the root, and that project’s task adding to it: both replayWhy this is rightWhen nothing changed, both tasks writing into | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A file a later task deletes from an earlier task’s root output stays deleted on warm runsWhy this is rightA later task may delete a file the earlier task wrote, here | ✓ | ✓ | ✗Turborepo Step 2: gen/tmp.js exists, expected none | ✓ | ✓ | ✓ |
A replay started from a project’s subdirectory restores each output at its own projectWhy this is rightOutputs belong to their project, and the directory a run starts from changes none of their paths. A replay that restored relative to the current directory would write | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
An output that is a directory, a file or a link in turn comes back in each build’s own shapeWhy this is rightA replay must leave the output exactly as the build that stored it, whatever was there before. When | ✓ | ✓ | ✓ | ✗moon Step 8: packages/a/dist/out/inner.txt is missing, expected "inner\n" | ✓ | ✗Nix Step 3: packages/a/dist/out is missing, expected "flat\n" |
A directory where a replayed output file goes, or a file where its directory goes, is replacedWhy this is rightA replay can find a stray directory where its output file goes, or a stale file where its directory goes. Writing around it, or failing on it, leaves a tree that is not the stored one. A runner must replace what is in the way, since the declared output path is the task’s to own, and the run must not fail over leftovers. | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 3: packages/a/dist/out is missing, expected "flat\n" |
An output link to a directory, or a dangling one, comes back as the linkWhy this is rightA link is an output in its own right: what it points to, not a copy of the target. A link to a directory, or one whose target does not exist, must come back as that link. A runner that follows links when storing copies a whole directory or fails on the dangling one, and the restored tree differs from what the build made. | ✓ | ✗Nx Step 9: packages/a/dist/out is missing | ✓ | ✗moon Step 4: packages/a/dist/out is missing | ✗Vite Task Step 4: packages/a/dist/out is missing | ✓ |
Through an output directory that is a link inside the project, a deleted output comes backWhy this is rightBuilds often make their output directory a link into another folder of the project. When the files behind it are deleted, a replay must restore them where the link leads. A runner that stores only the link, or writes through it to the wrong place, leaves | ✓ | ✗Nx Step 3: packages/a/dist/out.js is missing, expected "v1\n" | ✓ | ✗moon Step 3: packages/a/dist/out.js is missing, expected "v1\n" | ✓ | ✗Nix Step 3: packages/a/dist/out.js is missing, expected "v1\n" |
Through an output directory link, each build leaves only its own filesWhy this is rightEach build’s output is exactly the files it wrote, also when the output directory is a link. A replay of | ✓ | ✗Nx Step 3: packages/a/real-out/one.js exists, expected none | ✗Turborepo Step 3: packages/a/real-out/one.js exists, expected none | ✗moon Step 3: packages/a/real-out/one.js exists, expected none | ✗Vite Task Step 3: packages/a/real-out/one.js exists, expected none | ✗Nix Step 1: packages/a/real-out/one.js is missing, expected "one" |
An output list ["dist"] naming a directory link brings back the file behind the link once it is deletedWhy this is rightAn output named | ✓ | ✗Nx Step 3: packages/a/dist/out.js is missing, expected "v1\n" | ✗Turborepo Step 3: packages/a/dist/out.js is missing, expected "v1\n" | ✗moon Step 3: packages/a/dist/out.js is missing, expected "v1\n" | ✗Vite Task Step 3: packages/a/dist/out.js is missing, expected "v1\n" | ✗Nix Step 3: packages/a/dist/out.js is missing, expected "v1\n" |
An output list ["dist/","real-out/**"] naming a directory link brings back the file behind the link once it is deletedWhy this is rightNaming both the link | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A root output that links to another file of the same output comes back as the linkWhy this is rightA task can declare an output at the workspace root, and a link inside it that points to another file of the same output. Such a | ✓ | ✓ | ✓ | ✗moon Step 3: gen/latest is missing, expected "v2\n" | ✗Vite Task Step 3: gen/latest is missing, expected "v2\n" | ✓ |
A rebuild whose output turns from a directory into a file finds no stale directory in its wayWhy this is rightDeclared outputs belong to the task, so the runner clears them before a run, as a replay would. If the old | ✓ | ✗Nx Step 3: exited 1, expected success | ✗Turborepo Step 3: exited 2, expected success | ✗moon Step 3: exited 1, expected success | ✗Vite Task Step 3: exited 2, expected success | ✗Nix Step 3: packages/a/dist/out is missing, expected "flat\n" |
An output named outright, dist/out, turns from a directory to a file and back, run and replayedWhy this is rightAn output named outright, such as | ✓ | ✗Nx Step 3: exited 1, expected success | ✗Turborepo Step 3: exited 2, expected success | ✗moon Step 3: exited 1, expected success | ✗Vite Task Step 3: exited 2, expected success | ✗Nix Step 3: packages/a/dist/out is missing, expected "flat\n" |
A retry does not remove the output directory a task beside it is writing intoWhy this is rightBefore a retry, a runner may clean the failed attempt’s own outputs, and only those. Removing a shared directory, or pruning one that looks empty, can delete what a task running beside it has just made there. The sibling then fails, or its output vanishes, for no fault of its own. | ✓ | – | – | ✓ | – | – |
An uncached task over a cached dependency finds the dependency’s deleted outputs backWhy this is rightAn uncached task still needs its cached dependencies’ outputs on disk. When those outputs were deleted and the dependency is a cache hit, the runner must restore them before the dependant runs. Otherwise the dependant reads nothing and fails, or ships an empty deploy. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Replaying an older build over a tree holding the same directory link restores through itWhy this is rightA store layout such as | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
Either of a dependency’s two output trees, deleted alone, is back before a rebuilding dependant reads itWhy this is rightA task that writes two trees, as a build writing | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A read-only output file left by a newer build is replaced by the cached one, still read-onlyWhy this is rightSome tools write their outputs read-only, as generated sources or vendored files often are. When the inputs go back to an earlier state, the cache must put the earlier file back over the read-only one on disk. A restore that cannot overwrite it either fails the run or leaves the newer file under a green replay. The file comes back as the task wrote it, read-only. | ✓ | ✓ | ✓ | ✓ | ✓ | ✗Nix Step 6: packages/a/dist/out.txt has mode 644, expected 444 |
Six thousand output files, half empty and half with the same content, all come back on a hitWhy this is rightA bundler can write thousands of chunks, many empty or byte-identical. A cache that stores each content once must still put every path back, and an empty file is a file, not a missing one. A restore that drops the empty files, writes one copy of a shared content, or stops at a size limit leaves a partial tree under a green replay. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A file only a failed attempt wrote is not saved with the retry that passedWhy this is rightThe stored result should be what a passing run produces. Here the first attempt writes | ✓ | – | – | ✗moon Step 3: packages/a/dist/stale.txt exists, expected none | – | – |
A brace output whose alternative escapes [ and ] restores both treesWhy this is right
| ✓ | ✓ | ✓ | ✗moon Step 1: exited 1, expected success | ✓ | ✓ |
Run report
9 cases100%9 of 99 supported50%4 of 98 supported50%4 of 98 supported55%5 of 99 supported25%2 of 98 supported25%2 of 98 supported
The data a run hands back about each task: time, cache result, CPU, memory. Without it, nobody can see why CI is slow.
| Case | vx | Nx | Turborepo | moon | Vite Task | Nix |
|---|---|---|---|---|---|---|
A retried task’s reported time covers every attempt, not only the lastWhy this is rightA task’s reported time is how long it held up the run, and a retried task held it up for every attempt. Reporting only the last attempt hides the cost of flaky tasks. Time budgets, critical-path estimates and CI dashboards then look better than the build really is. Each attempt counts. | ✓ | – | – | ✓ | – | – |
A run reports that a task missed the cacheWhy this is rightKnowing which tasks missed the cache is how a user finds what broke caching. A key that changes on every run looks like a slow build until the report shows the miss. Without it, a team cannot tell a cold cache from one that never hits, and cannot find the input that keeps changing. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A run reports that a task was replayedWhy this is rightA report of each hit tells the user what was replayed rather than run. When a result looks wrong, the first question is whether the task ran at all. It is also how a team checks that CI gets the reuse it expects, and notices when a change quietly turns hits into misses. | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A run reports how long each task tookWhy this is rightPer-task time is the number a build is tuned with. Without it, a user cannot find the slow task on the critical path or tell whether a change made a task slower. The total time of a run alone hides which task to fix, since many tasks run at once and overlap. | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 1: reports no durationMs | ✗Nix Step 1: reports no durationMs |
A run reports how long a replay took, not the original runWhy this is rightA replay’s time is the time to restore it, not the original command’s time. Reporting the original time makes a hit look as slow as a miss and hides whether the cache helps at all. It also hides a slow restore, such as a large artifact from a slow store. The original time belongs to the run that paid it. | ✓ | ✓ | ✓ | ✓ | ✗Vite Task Step 3: reports no durationMs | ✗Nix Step 3: reports no durationMs |
A run reports each task’s CPU timeWhy this is rightCPU time shows how much work a task does, apart from how long it waited. A task that waits on the network and one that compiles for the same span look alike by wall time. CPU time tells a user which tasks gain from more cores and which from less waiting, and whether parallel runs are starved. | ✓ | ✗Nx Step 1: reports no cpuMs | ✗Turborepo Step 1: reports no cpuMs | ✗moon Step 1: reports no cpuMs | ✗Vite Task Step 1: reports no cpuMs | ✗Nix Step 1: reports no cpuMs |
A run reports each task’s peak memoryWhy this is rightPeak memory decides how many tasks fit on a machine at once. Without it, a user sizing CI runners or a concurrency limit guesses, and a run can be killed for running out of memory with no hint of which task caused it. Per-task numbers show which task needs the larger machine. | ✓ | ✗Nx Step 1: reports no peakMemoryBytes | ✗Turborepo Step 1: reports no peakMemoryBytes | ✗moon Step 1: reports no peakMemoryBytes | ✗Vite Task Step 1: reports no peakMemoryBytes | ✗Nix Step 1: reports no peakMemoryBytes |
A run reports the size of what each task wroteWhy this is rightThe size of what a task writes is what the cache must store and move. Large outputs make saves and restores slow and fill a remote cache. Reporting it per task shows which outputs to trim, split or leave uncached, instead of guessing from the size of the whole cache. | ✓ | ✗Nx Step 1: reports no outputBytes | ✗Turborepo Step 1: reports no outputBytes | ✗moon Step 1: reports no outputBytes | ✗Vite Task Step 1: reports no outputBytes | ✗Nix Step 1: reports no outputBytes |
A run reports the size of what a replay restoredWhy this is rightOn a replay, the bytes restored are the cost of the hit. A hit that pulls a large artifact over the network can take longer than running the task. Reporting the restored size lets a user see when that happens and narrow what the task declares as output. | ✓ | ✗Nx Step 3: reports no outputBytes | ✗Turborepo Step 3: reports no outputBytes | ✗moon Step 3: reports no outputBytes | ✗Vite Task Step 3: reports no outputBytes | ✗Nix Step 3: reports no outputBytes |
Isolation
19 cases100%19 of 1919 supported0%0 of 191 supported100%1 of 191 supported0%0 of 191 supported100%1 of 191 supported88%16 of 1918 supported
Whether a task is stopped from reading or writing what it never declared. Without that, the inputs list is a promise nobody checks.
| Case | vx | Nx | Turborepo | moon | Vite Task | Nix |
|---|---|---|---|---|---|---|
An isolated task that reads its inputs and writes its outputs succeedsWhy this is rightIsolation exists to catch what a task did not declare, not to break tasks that declared everything. A task that reads only its inputs and writes only its outputs must run as it would without isolation. A sandbox that fails it makes isolation too costly to turn on. | ✓ | – | – | – | – | ✓ |
An isolated task that reads a project file it never declared failsWhy this is rightA file the task reads but never declared is outside its key. An edit to that file would leave the key the same, and a later run would replay a stale result. Failing the read now shows the missing input at once, while it is cheap to add, instead of after a wrong replay. | ✓ | – | – | – | – | ✓ |
An isolated task may read a file it is granted beyond its inputsWhy this is rightSome files a task must read are not inputs, such as a tool’s config outside the project, and the user grants them on purpose. A granted read is a decision the user made, not a mistake to catch. Failing it forces users to turn isolation off for tasks that are already correct. | ✓ | – | – | – | – | ✓ |
An isolated task that reads another project’s source failsWhy this is rightReading another project’s source ties this task to files outside its key and its own folder. An edit there changes the output while nothing reruns. Failing the read makes the hidden link visible, so the user can declare the dependency instead of shipping a stale build. | ✓ | – | – | – | – | ✓ |
An isolated task that writes outside its outputs fails, and the file is not left behindWhy this is rightA file written outside the outputs is never stored with the task. A later replay restores the outputs but not that file, so the tree differs depending on whether the task ran. Failing the write, and leaving nothing behind, keeps a run and a replay producing the same tree. | ✓ | – | – | – | – | ✗Nix Step 1: succeeded, expected a failure |
An isolated task that writes into another project fails, and leaves that project as it wasWhy this is rightWriting into another project changes files that project owns and keys on, behind its back. Its next run may replay or rerun on content no one edited. Failing the write and leaving that project as it was keeps each project’s files under its own control and key. | ✓ | – | – | – | – | ✓ |
An isolated task that rewrites its own source fails, and the source is keptWhy this is rightA task that rewrites its own source changes its inputs while it runs, so its key no longer matches what it built from. A replay then hides the rewrite, or the next run loops on it. Failing the write and keeping the source protects the user’s code from a build step. | ✓ | – | – | – | – | ✗Nix Step 1: succeeded, expected a failure |
An isolated task with no network grant cannot reach a server the run startedWhy this is rightA response from the network is an input no key holds. The next run replays the saved result whatever the server says now, and two machines can build different bytes under one key. A task without a network grant must fail to connect, so that hidden input shows up at once. | ✓ | – | – | – | – | – |
An isolated task cannot read an undeclared file through a link inside its inputsWhy this is rightA link inside the inputs can point at a file the task was never granted. Following it reads that file as surely as opening it directly, and the key does not cover it. Isolation must check where the link leads, or one symlink turns every grant into a way around it. | ✓ | – | – | – | – | ✓ |
A task closed off from the machine cannot read a system fileWhy this is rightA file of the machine, such as one under /etc, is an input no key holds and no other machine shares. A build that reads it can give different bytes on two machines under one key. A task closed off from the machine must fail that read, so the build depends only on what it declared. | ✓ | – | – | – | – | ✓ |
A task closed off from the machine still runs when it reads only its inputsWhy this is rightClosing off the machine must not stop the shell and its tools from starting. A task that reads only its inputs and writes only its outputs needs nothing more. A closed sandbox that fails it cannot be turned on for real builds. | ✓ | – | – | – | – | ✓ |
An isolated task that writes into the home folder fails, and leaves nothing thereWhy this is rightA file a build drops in the home folder is never stored with the task, and the next build may read it. Runs then depend on what earlier runs left behind. Failing the write, and leaving nothing, keeps every build starting from the same machine. | ✓ | – | – | – | – | ✓ |
An isolated task reads what the task it depends on built in another projectWhy this is rightA dependency’s outputs are what the task asked for by depending on it, and its key moves with them. Hiding them makes one project unable to build a tool that others run. An isolated task must read them, or isolation cannot be used for builds with a toolchain step. | ✓ | – | – | – | – | ✓ |
An isolated task may read the outputs of a task it depends onWhy this is rightReading what a dependency built is the reason to depend on it, and those outputs fold into the task’s key through the dependency. Refusing the read makes isolation unusable for every build that consumes another project. The grant is the dependency’s declared outputs. | ✓ | – | – | – | – | ✓ |
An isolated task that reads a dependency file beside its declared outputs failsWhy this is rightThe dependency declared | ✓ | – | – | – | – | ✓ |
An isolated task that reads another project’s file through git failsWhy this is rightGit’s object store holds every committed file of every project. A task that runs | ✓ | – | – | – | – | ✓ |
Watch mode
31 cases100%31 of 3131 supported87%27 of 3131 supported90%28 of 3131 supported–0 of 310 supported–0 of 310 supported–0 of 310 supported
Rerunning on save. Rebuild on every real change and only then, so you never wait on work that was not needed.
| Case | vx | Nx | Turborepo | moon | Vite Task | Nix |
|---|---|---|---|---|---|---|
Editing a source file while watching rebuilds with the new contentWhy this is rightRerunning a task when what it reads changes is the whole point of watching. The output must hold the new source, not the one the watch started with. A watch that misses the edit leaves a stale build that looks fresh, so the user tests old code and trusts the result. | ✓ | ✓ | ✓ | – | – | – |
Editing a file the task does not read reruns nothingWhy this is rightThe task said which files it reads, and the edited file is not one of them. Running it again would build the same output from the same inputs. Work done for a file nobody reads only delays the result the user is waiting for, and in a large project that wait comes with every save. | ✓ | ✓ | ✓ | – | – | – |
Saving a file without changing it reruns nothingWhy this is rightAn editor that saves on focus loss, or a formatter with nothing to fix, writes the same bytes again. The task reads the content of its files, and the content did not change. A rerun here repeats work whose result is already on disk, and it happens on every idle save. | ✓ | ✓ | ✓ | – | – | – |
Undoing an edit while watching brings back the earlier result without rebuildingWhy this is rightAfter the undo the inputs are exactly the ones an earlier run already built. That result can be put back as it was, which is both correct and immediate. Building it again gives the same bytes and only costs the user time, on the edit people make most while trying things out. | ✓ | ✓ | ✓ | – | – | – |
A new file matching the task’s inputs rebuilds with it includedWhy this is rightInputs given as a pattern also cover files that do not exist yet. Adding a source file changes what the task reads as surely as editing one does. A watch that only follows the files present at the start misses one of the most common edits of all, a new module, and the build quietly leaves it out. | ✓ | ✓ | ✓ | – | – | – |
Deleting a source file rebuilds without itWhy this is rightA deleted file is no longer part of what the task reads, so the output built with it is now wrong. Keeping that output would ship code the user just removed. A deletion is a change to the inputs exactly as an edit is, and the next build must show the tree as it now stands. | ✓ | ✓ | ✗Turborepo Step 1: phase 2: after 30 s: did not run app#build | – | – | – |
A file added in new folders under the inputs rebuilds with it, and so does its next editWhy this is rightA pattern over a folder covers its subfolders, including ones made after the watch started. Creating folders and a file in them is a normal edit while adding a feature. A watch that only follows the folders it saw at the start misses the file, or its next edit, and the build goes stale. | ✓ | ✓ | ✓ | – | – | – |
Creating an empty folder under the inputs reruns nothingWhy this is rightAn empty folder holds no file the task could read, so the task’s output cannot change. Version control does not even record an empty folder. A rerun for it is work that produces the same bytes again, and it happens every time a tool or an editor makes a scratch folder. | ✓ | ✓ | ✓ | – | – | – |
A change to a git-ignored file among the sources reruns nothingWhy this is rightIgnored files are scratch: logs, editor swap files, local caches. They are not part of what the task builds from, and a cache key does not see them. A rerun on each of their writes is busy work, and when the task itself writes such a file it can keep the watch busy forever. | ✓ | ✓ | ✓ | – | – | – |
A build’s own output does not start it againWhy this is rightThe task writes its output after reading its inputs, and the output is not one of them. Treating that write as a new change would build once more, or loop for good. After one edit the task should run exactly once, and then the watch should go quiet until the user changes something. | ✓ | ✓ | ✓ | – | – | – |
A build writing a declared output folder that git does not ignore does not loopWhy this is rightSome projects keep their built files next to the sources, where version control does not ignore them. They are still declared as the task’s output, not its input, and only inputs decide whether to run again. Rerunning on its own output turns every edit into a loop that never settles. | ✓ | ✓ | ✓ | – | – | – |
A build that rewrites an ignored side file every run does not loopWhy this is rightA process id file or a timestamped log differs on every run, so its bytes never settle. It is ignored by version control, so no input can depend on it. A watch that follows it reruns the task on its own leftovers without end, and the machine never goes idle between edits. | ✓ | ✓ | ✓ | – | – | – |
Many files changed at once, as a checkout does, rebuild onceWhy this is rightSwitching branches or pulling rewrites many files within a moment. Together they are one change, and one build over all of them gives the final result. Building once per file repeats the same work many times and shows results for trees that never existed in between. | ✓ | ✓ | ✓ | – | – | – |
An editor’s save by renaming a temporary file over the source rebuildsWhy this is rightMany editors write a temporary file and rename it over the original, so the file is replaced rather than edited in place. The source still changed, and the build must hold the new content. A watch that only follows edits in place misses every save from such an editor. | ✓ | ✓ | ✓ | – | – | – |
A source folder deleted and made again, as a checkout does, rebuilds from the new filesWhy this is rightTools that restore a tree, such as a checkout or a stash, often remove a folder and write it again. The new folder holds new content, and the build must reflect it. A watch that kept following the deleted folder hears nothing from then on, and every later edit is lost too. | ✓ | ✓ | ✓ | – | – | – |
An edit made while the build is running is built right afterWhy this is rightThe running build read the source before the edit, so its output is already stale when it finishes. The edit must lead to one more build, or the result shown is older than the code. Dropping changes that land mid-run is the usual way a watch drifts out of date without saying so. | ✓ | ✓ | ✓ | – | – | – |
An edit undone while its build runs leaves the output of the bytes on diskWhy this is rightThe running build read the edit, so its output belongs to bytes no longer on disk. Comparing the file with the state seen before the edit says nothing changed, and the watch stays green on a build of code that is gone. Save, undo, save is how editors are used, so the output must end as the bytes on disk build it, run again or replayed. | ✓ | ✓ | ✓ | – | – | – |
An edit made during the watch’s first build is built right after itWhy this is rightThe first build of a watch is a build like any other, and it can read a file before the user saves it. Arming the watcher only after that build ends misses the save, so the watch idles on output from the old bytes. The edit must lead to one more build. | ✓ | ✗Nx Step 1: phase 2: after 30 s: did not run app#build | ✓ | – | – | – |
A build that fails while watching is built again once the source is fixedWhy this is rightBreaking the code halfway through an edit is the normal state of work in progress. The watch should report the failure and keep going rather than exit. The fix is an edit like any other, and it must produce a fresh build without the user having to start the watch again. | ✓ | ✓ | ✓ | – | – | – |
Editing a library rebuilds it, then the app that depends on itWhy this is rightThe app’s build reads what the library’s build produces, so it must run after it, on the new output. Building the app first, or not at all, leaves it built against the old library. Order matters under a watch exactly as much as it does in a plain run. | ✓ | ✓ | ✓ | – | – | – |
Editing the app rebuilds only the app, not the library it usesWhy this is rightThe library reads nothing from the app, so its output cannot change when the app does. Only the edited project and those that depend on it need to run. Rebuilding the library on every app edit adds its whole build time to each save, for a result that is already on disk. | ✓ | ✓ | ✓ | – | – | – |
Editing one project reruns nothing in an unrelated oneWhy this is rightTwo projects with no dependency between them share no inputs. An edit in one cannot change the other’s output. Rebuilding every project on any edit makes watching a large workspace as slow as building all of it, which is the cost watching was supposed to avoid. | ✓ | ✓ | ✓ | – | – | – |
Watching one app also rebuilds it when a library it depends on changesWhy this is rightSelecting the app means its build and everything that build needs. A library edit changes what the app is built from. A watch that only follows the selected project’s own folder leaves the app built against the old library, and the user sees a result that no longer matches the code. | ✓ | ✓ | ✓ | – | – | – |
Watching one app reruns nothing when a project outside it changesWhy this is rightThe user asked to watch the app, and the edited project is neither the app nor something it needs. Its build was not requested at the start, so the edit does not request it either. Running it anyway spends time on work nobody asked for and fills the output with noise. | ✓ | ✓ | ✓ | – | – | – |
Editing a shared file at the root that the task reads rebuilds itWhy this is rightA shared settings file outside the project is still one of the task’s declared inputs. Changing it changes the output as much as a source edit does. A watch that only follows project folders leaves the build on the old settings, though a plain run would have rebuilt it. | ✓ | ✗Nx Step 1: phase 2: after 30 s: did not run app#build | ✓ | – | – | – |
Editing a root file no task reads reruns nothingWhy this is rightA file at the workspace root, such as a readme or a log, is no task’s input unless a task says so. The task’s output cannot depend on it. A rerun for each such write wastes a full build, and a log written at the root by the watch’s own output could feed it forever. | ✓ | ✓ | ✓ | – | – | – |
Changing a task’s command while watching runs the new commandWhy this is rightThe command is part of what produces the output, so a new command means the old output is stale. Each tool has its own step to apply a config edit, and the user takes it. After that the watch must run what the config now says, not the command it read at the start. | ✓ | ✓ | ✓ | – | – | – |
Adding a new task to the config while watching does not rebuild the existing oneWhy this is rightThe build’s command, inputs and outputs are exactly what they were; only a task beside it was added. Nothing that produces the build’s output changed, so its result on disk is still right. Rebuilding everything on any config edit makes each tweak to an unrelated task cost a full build. | ✓ | ✗Nx Step 1: phase 2: ran app#build, expected it replayed or left out | ✗Turborepo Step 1: phase 2: ran app#build, expected it replayed or left out | – | – | – |
Editing a file that a source symlink points to rebuilds with the new contentWhy this is rightThe task reads its source through the link, so what it reads is the file the link points to. Editing that file changes the build’s result as much as editing the source itself. A watch that only hears writes inside the project misses it, and the build keeps the old content. | ✓ | ✗Nx Step 2: phase 2: after 30 s: did not run app#build | ✗Turborepo Step 2: phase 2: after 30 s: did not run app#build | – | – | – |
An edit saved among many writes to git-ignored files still rebuilds, onceWhy this is rightA test run or a dev server can write dozens of ignored logs in the moment the user saves a source. The logs are noise, but the source edit among them is a real change. A watch that drops the burst with its noise leaves the build stale; one that answers each write rebuilds many times. | ✓ | ✓ | ✓ | – | – | – |
Edits to two projects, one after another, each rebuild their project and the last winsWhy this is rightA watch stays up for many edits, not one, and each must be answered as the first was. After the first rebuild the watch has to listen again, in every project it follows. One that hears only the first change, or only its first project, leaves later edits unbuilt while it still looks alive. | ✓ | ✓ | ✓ | – | – | – |