Skip to content
vxvx
GitHubBlueskydev.toRSS

// 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.

All cases100%986 of 988986 supported69%642 of 988929 supported69%629 of 988904 supported65%579 of 988890 supported67%501 of 988742 supported67%477 of 988703 supported

Task graph

149 cases
100%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.

CasevxNxTurborepomoonVite TaskNix
^build builds every dependency before the project that needs it
Why this is right

A 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.

graph/deps-first · correctness
✓✓✓✓✓✓
Running one project's task runs what it depends on, and nothing else
Why this is right

Asking 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.

graph/one-project-pulls-its-deps · correctness
✓✓✓✓✓–
A bare name in dependsOn waits on the same project’s task
Why this is right

A 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 dist that is missing or from the last build. The test then passes or fails on old code, which is worse than no test.

graph/same-project-dependency · correctness
✓✓✓✓✓✓
lib#build in dependsOn runs that task first, with no package dependency
Why this is right

An edge like lib#build states an order the package manifests do not show. site publishes lib’s output, so lib must be built first even though site does not list lib as a dependency. Ignoring the edge publishes a missing or stale file, and the order would hold only by luck of timing.

graph/explicit-task-edge · correctness
✓✓✓✓✓✓
A dependency two projects share builds once per run
Why this is right

When 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.

graph/diamond-runs-once · efficiency
✓✓✓✓✓✓
^build reaches a dependency’s dependency when the middle project has no build
Why this is right

app 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.

graph/transitive-through-taskless · correctness
✓✓
✗

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 it
Why this is right

Dependencies 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.

graph/dependents-not-pulled · efficiency
✓✓✓✓✓–
A task only some projects declare runs in exactly those
Why this is right

Running 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 build in place of a missing lint does work nobody asked for and reports it as lint.

graph/task-in-some-projects · correctness
✓✓✓✓✓✓
A project whose directory holds spaces builds, caches and restores
Why this is right

A 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 dist stays empty, or never caches at all.

graph/project-dir-with-spaces · correctness
✓✓✓✓✓✓
A task named test:unit runs as its own task
Why this is right

A colon is a common part of task names such as test:unit, not a separator the runner may reinterpret. Reading it as project and task, or as a prefix of test, runs the wrong command. The user asked for unit tests and gets the whole suite, or nothing, under the same green exit.

graph/task-name-with-colon · correctness
✓✓✓
✗

moon Step 1: exited 2, expected success

✓✓
^gen runs the dependencies’ gen first, and not their build
Why this is right

^gen asks for one specific task in each dependency, here the code generation app reads. Running lib’s build in its place leaves gen/g.txt missing or stale, and app builds on it. Running both wastes time on a build nobody asked for; the caret names a task, not a whole project.

graph/caret-names-other-task · correctness
✓✓✓✓✓✓
^build over a dependency that declares no build runs the project alone
Why this is right

^build means build each dependency that has a build. A dependency without one has nothing to wait for, so the project runs on its own. Failing the run would make every project declare an empty build, and running some other task of lib in its place does work nobody asked for.

graph/caret-dependency-without-task · correctness
✓✓✓✓✓✓
A five-project chain builds strictly from the bottom up
Why this is right

Each 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.

graph/deep-chain-order · correctness
✓✓✓✓✓✓
Two selected projects over one dependency build it once, before both
Why this is right

Two 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.

graph/two-roots-shared-leaf · correctness
✓✓✓✓✓–
test on build and ^build waits for both the project’s and its dependency’s build
Why this is right

Two 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.

graph/same-and-caret · correctness
✓✓✓✓✓✓
Selecting a project and its dependency runs the dependency once, first
Why this is right

Selecting 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.

graph/selected-dependency-ordered · correctness
✓✓✓✓✓–
^build follows a dependency by package name when its directory is named otherwise
Why this is right

Projects 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.

graph/name-differs-from-dir · correctness
✓✓✓✓✓✓
@acme/core#build in dependsOn runs that scoped project’s build first
Why this is right

Scoped names such as @acme/core are ordinary package names and must work in an explicit edge. A runner that misreads the @ or the / cannot find the task and either fails or drops the edge. Dropping it lets site build first and copy a missing or stale core output.

graph/scoped-explicit-edge · correctness
✓✓✓✓✓✓
A project nested in another runs its own task in its own directory
Why this is right

A 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.

graph/nested-project-own-task · correctness
✓✓✓✓✓✓
Projects that depend on each other run a task that names no dependency
Why this is right

Packages 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.

graph/package-cycle-without-task-edges · correctness
✓✓✓
✗

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 runs
Why this is right

When 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.

graph/package-cycle-caret-refused · correctness
✓✓✓✓✓✓
A project listing itself as a dependency is no cycle: ^build runs its build
Why this is right

A 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.

graph/package-names-itself · correctness
✓✓
✗

Turborepo Step 1: exited 1, expected success

✓✓✓
Two projects’ tasks naming each other fail the run before either runs
Why this is right

Two 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.

graph/cross-project-cycle-refused · correctness
✓✓✓✓✓✓
A task that depends on itself fails the run
Why this is right

A 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.

graph/self-dependency-refused · correctness
✓
✗

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 again
Why this is right

The 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.

graph/peer-workspace-dependency-orders-and-keys · correctness
✓✓
✗

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’s
Why this is right

Selecting 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.

graph/select-exact-name · correctness
✓✓✓✓✓–
A * in a name selects every project it matches
Why this is right

A * in a project name is a pattern, so u* means every project whose name starts with u. Treating it as a literal name selects nothing, and matching too loosely pulls in projects the pattern excludes. Either way the user runs a different set than the one they wrote.

graph/select-name-glob · correctness
✓✓✓✓✓–
A name matches whole: foo never selects foo-e2e
Why this is right

A name without a wildcard must match a project name in full. Matching it as a prefix makes foo also run foo-e2e, which may be slow or need services the user did not start. The user would have no plain way left to select just foo. Wildcards are there for when the user wants more.

graph/select-name-whole · correctness
✓✓✓✓✓–
A . in a name is a dot: socket.io never selects socketxio
Why this is right

Package names often contain dots, and a dot in a selector means a dot. Reading it as a pattern that matches any character selects socketxio too, a different package. A selector that matches more than its text runs work the user never named.

graph/select-name-dot-literal · correctness
✓✓✓✓✓–
Two selectors run the union of what each selects
Why this is right

Several 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.

graph/select-union · correctness
✓✓✓✓✓–
Selecting a project with its dependencies takes every transitive one
Why this is right

Selecting 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.

graph/select-with-dependencies · correctness
✓–
✗

Turborepo Step 1: did not run ui#check, utils#check

–✓–
Selecting a project with its dependents takes every transitive one
Why this is right

Selecting 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.

graph/select-with-dependents · correctness
✓–
✗

Turborepo Step 1: did not run ui#check, app#check

–✓–
Selecting only a project’s dependents leaves the project out
Why this is right

Asking 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.

graph/select-only-dependents · correctness
✓–
✗

Turborepo Step 1: did not run ui#check, app#check

–✓–
Selecting only a project’s dependencies leaves the project out
Why this is right

Asking 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.

graph/select-only-dependencies · correctness
✓–
✗

Turborepo Step 1: did not run ui#check, utils#check

–✓–
Dependents with their dependencies takes what each dependent builds on
Why this is right

To 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.

graph/select-dependents-and-their-dependencies · correctness
✓–
✗

Turborepo Step 1: did not run app#check, kit#check, utils#check

–✓–
An exclude alone selects every project but the one it names
Why this is right

An 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.

graph/select-exclude-from-all · correctness
✓✓✓✓✓–
* and an exclude select every project but that one
Why this is right

* with an exclude is the explicit form of every project but one. The exclude must win over the wildcard, or the project the user named is run anyway. Common uses are skipping a slow or known broken package while still checking the rest.

graph/select-all-minus-one · correctness
✓✓✓✓✓–
An exclude given before an include still removes what the include adds
Why this is right

An 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.

graph/select-exclude-before-include · correctness
✓–
✗

Turborepo Step 1: did not run ui#check

–✓–
Excluding a project with its dependencies removes them all
Why this is right

Excluding 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.

graph/select-exclude-walk · correctness
✓–
✗

Turborepo Step 1: ran utils#check, expected it replayed or left out

–✓–
* selects scoped and unscoped projects alike
Why this is right

* means every project, and a scoped name like @acme/core is a project like any other. A wildcard that stops at the / drops every scoped package from the run. In workspaces where most packages are scoped, that is most of the checks, gone without a warning.

graph/select-star-takes-scoped · correctness
✓✓✓
✗

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 scope
Why this is right

@acme/* is the usual way to select one npm scope. It must take every package in that scope and nothing outside it. Missing scoped names runs nothing, and matching unscoped ones runs projects the user did not ask for. Scopes often group a team’s or a product’s packages, so this selection is used a lot in CI.

graph/select-scope-glob · correctness
✓✓✓✓✓–
A glob matches across the scope’s /: *core* selects @acme/core
Why this is right

Users think of a scoped package by its name, and *core* should find @acme/core. A wildcard that cannot cross the scope’s / silently skips it. The selection then runs fewer projects than the pattern plainly matches, with no sign anything was missed.

graph/select-glob-across-scope · correctness
✓
✗

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/utils
Why this is right

An exclude pattern must match the same names an include would. If *utils cannot match @acme/utils, the package the user meant to skip runs anyway. Include and exclude have to read patterns the same way, or one of them lies. Scoped names are common enough that this mismatch would surface in most workspaces.

graph/select-exclude-scoped-glob · correctness
✓
✗

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 name
Why this is right

When only one package is named core under any scope, a bare core can only mean that package. This is how the common package manager filter grammar reads it, which users already know. Selecting nothing instead runs nothing the user asked for, and may pass CI having checked nothing.

graph/select-name-without-scope · correctness
✓✓
✗

Turborepo Step 1: exited 1, expected success

✗

moon Step 1: exited 1, expected success

✓–
A directory glob selects the projects directly under it
Why this is right

A directory glob such as packages/* selects the projects directly under that path. It must not reach projects nested deeper, which a ** is for, and must not reach other folders. Users rely on this to run one area of the repo, such as every app or every library.

graph/select-dir-glob · correctness
✓✓✓✓✓–
A ** directory glob reaches a project nested in another
Why this is right

** in a directory glob means any depth, so it takes a project nested inside another. Stopping at one level drops the nested project without warning. Other top level folders such as apps must still stay out. Nested projects are common in repos that keep examples or fixtures inside a package.

graph/select-dir-globstar · correctness
✓✓✓✓✓–
A directory that is a project selects that project, not one nested in it
Why this is right

Naming 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.

graph/select-dir-is-project · correctness
✓✓✓✓✓–
A directory does not select a sibling that shares its prefix
Why this is right

A directory names a path, and packages/app is not a prefix of packages/app-extra. Matching by string prefix runs a sibling the user did not name. A selection that runs more than asked reports nothing wrong; it only costs time and can hide a failure behind someone else’s.

graph/select-dir-no-prefix-spill · efficiency
✓✓✓✓✓–
A directory glob minus one directory selects every other project there
Why this is right

A 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.

graph/select-dir-minus-dir · correctness
✓✓✓✓✓–
A name and a directory select the union
Why this is right

Selectors 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.

graph/select-name-and-dir · correctness
✓✓✓✓✓–
Selecting by name in a package graph with a cycle runs that project alone
Why this is right

A 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.

graph/select-cyclic-packages-name · correctness
✓✓✓
✗

moon Step 1: exited 1, expected success

✓–
Walking a package cycle takes each project in it once
Why this is right

A 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.

graph/select-cyclic-packages-walk · correctness
✓–
✗

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 dependent
Why this is right

A 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.

graph/select-dependents-through-task-edge · correctness
✓–✓–
✗

Vite Task Step 1: did not run e2e#check

–
A selector that matches no project fails the run
Why this is right

A 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.

graph/select-no-match-refused · correctness
✓
✗

Nx Step 1: succeeded, expected a failure

✓✓
✗

Vite Task Step 1: succeeded, expected a failure

–
A tag selects exactly the projects carrying it
Why this is right

A 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.

graph/select-by-tag · correctness
✓✓–
✗

moon Step 1: exited 1, expected success

––
A * in a tag selects the projects carrying any tag it matches
Why this is right

A 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.

graph/select-by-tag-glob · correctness
✓✓–
✗

moon Step 1: exited 1, expected success

––
A tag excluded takes the projects carrying it out of the selection
Why this is right

Excluding 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.

graph/select-exclude-tag · correctness
✓✓–
✗

moon Step 1: exited 1, expected success

––
A tag and its dependencies select the tagged projects and what they depend on
Why this is right

Selecting 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.

graph/select-tag-with-dependencies · correctness
✓–––––
Skipping dependencies runs the selected task and not what it depends on
Why this is right

Skipping 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.

graph/only-skips-dependencies · correctness
✓✓✓✓✓–
A task the full run built replays when its dependencies are skipped
Why this is right

Skipping 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.

graph/only-replays-full-run · efficiency
✓✓✓
✗

moon Step 2: ran app#build, expected it replayed or left out

✓–
With dependencies skipped, a dependency rebuilt since still reruns its dependant
Why this is right

app 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.

graph/only-dependency-change-reruns · correctness
✓✓✓
✗

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 dependant
Why this is right

base 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.

graph/only-two-hops-change-reruns · correctness
✓✓✓✓
✗

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 run
Why this is right

When 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.

graph/only-stale-dependency-not-saved · correctness
✓
✗

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 both
Why this is right

Selecting 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.

graph/only-requested-dependency-replays · efficiency
✓✓✓
✗

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 skipped
Why this is right

app 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.

graph/only-requested-dependency-ordered · correctness
✓
✗

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 not
Why this is right

A 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 here, spends time on work nobody asked for and writes files the run should not touch.

graph/pattern-group · correctness
✓✓––––
^build.* waits for every matching task of each dependency
Why this is right

^build.* means every matching task in each dependency finishes before the dependant starts. A dependant that starts after only some of them reads a half-built dependency: here one of the two files could be missing or old. The result then depends on scheduling, so a failure shows up on one machine and not on the next.

graph/pattern-caret · correctness
✓✓––––
A task on build.* reruns when a matched task’s input changes
Why this is right

A task that depends on a pattern depends on what each matched task produced. top copies the file build.x wrote, so a change to build.x’s source must change top’s key too. Replaying top after that source changed ships the old bytes under a green run, and nothing in the output says so.

graph/pattern-dependency-change-reruns · correctness
✓
✗

Nx Step 3: did not run app#top

––––
At a concurrency of 1, independent tasks run one at a time
Why this is right

A 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.

graph/concurrency-one-runs-one-at-a-time · correctness
✓✓✓✓✓✓
At a concurrency of 2, no more than two tasks run at once
Why this is right

The 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.

graph/concurrency-limit-holds · correctness
✓✓✓✓✓✓
Tasks that fail still count against the concurrency limit while they run
Why this is right

A 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.

graph/concurrency-limit-holds-through-failures · correctness
✓✓✓✓✓✓
A concurrency far above the number of tasks runs them all and ends
Why this is right

A 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.

graph/concurrency-above-task-count · correctness
✓✓✓✓✓✓
A task behind a replayed task still waits for what the replayed one depends on
Why this is right

app’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.

graph/behind-a-replay-waits-for-its-deps · correctness
✓✓✓✓✓
✗

Nix Step 1: exited 100, expected success

A server starts when one of the tasks that need it must run, though another replays
Why this is right

When 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.

graph/server-starts-when-one-dependant-misses · correctness
✓✓
✗

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 need
Why this is right

A 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.

graph/task-absent-pulls-no-dependencies · efficiency
✓✓✓✓✓✓
Two tasks in one run each run where declared, though one project lacks one
Why this is right

Asking 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.

graph/two-tasks-some-projects-declare · correctness
✓✓✓✓–✓
Two tasks one project declares both run in one run
Why this is right

Naming 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.

graph/two-tasks-both-run · correctness
✓✓✓✓–✓
Excluding one member keeps the workspace-root project’s task
Why this is right

The 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.

graph/select-exclude-keeps-root-project · correctness
✓✓✓✓✓–
Selecting the workspace-root project by name runs its task and not its members’
Why this is right

The 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.

graph/select-root-project-by-name · correctness
✓✓
✗

Turborepo Step 1: exited 1, expected success

✓✓–
A selection its excludes leave empty, beside a selector matching nothing, fails the run
Why this is right

Here 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.

graph/select-emptied-beside-no-match-refused · correctness
✓
✗

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 run
Why this is right

Every 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.

graph/unusual-project-dirs · correctness
✓✓✓✓✓✓
A project whose directory holds spaces and parentheses runs there, caches and restores
Why this is right

Spaces 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 dist empty.

graph/project-dir-with-parentheses · correctness
✓
✗

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 project
Why this is right

e2e’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.

graph/select-dependencies-through-task-edge · correctness
✓–
✗

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 names
Why this is right

A 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.

graph/select-dependents-through-package-then-task-edge · correctness
✓–
✗

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 starts
Why this is right

Nothing 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.

graph/only-skipped-server-replays-dependant · efficiency
✓
✗

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 dependencies
Why this is right

A 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.

graph/only-every-skipped-dependency-keyed · correctness
✓✓✓✓
✗

Vite Task Step 4: did not run app#build, top#build

–
Args forwarded to a run reach a requested server’s command
Why this is right

Arguments after -- are how a user passes options such as a port to the command a task runs. A server task is still a task, so dev -- --port 3000 must start the server with that port. Dropping them starts it on the default port, where the test or the developer then talks to the wrong server or none.

graph/server-gets-forwarded-args · correctness
✓✓
✗

Turborepo Step 1: did not run app#dev, app#smoke

✓––
Args forwarded to a run reach a requested server that prints a ready line
Why this is right

A server the run waits on until it prints a ready line is started like any other task. Its forwarded arguments must reach it, so dev -- --port 3000 does not lose the port. Starting it a different way when a ready line is declared would make the same command behave differently for a setting about timing.

graph/ready-server-gets-forwarded-args · correctness
✓✓––––
Args forwarded to a server reach it as typed, never expanded by a shell
Why this is right

The 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 $(...) and backquotes, which runs commands the user never asked to run. It also splits a b in two, so the server gets different arguments than typed.

graph/server-args-reach-it-literally · correctness
✓
✗

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 again
Why this is right

Adding 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.

graph/new-depends-on-edge-keys-consumer · correctness
✓✓✓✓
✗

Vite Task Step 5: did not run app#build

✓
A member’s task that depends on a workspace-root task runs after it
Why this is right

A 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.

graph/root-task-before-member · correctness
✓✓✓✓✓✓
Selecting a project without the task, with its dependencies, runs the task in those dependencies
Why this is right

The 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.

graph/select-taskless-project-with-dependencies · correctness
✓–
✗

Turborepo Step 1: did not run lib#build

–✓–
A dependency named in both dependencies and devDependencies is built once, before its dependant
Why this is right

Listing a package in two dependency fields of one manifest still makes it one dependency. ^build must build it first, and once: two edges to the same task are one edge. Missing the edge builds app against a missing lib; running lib twice wastes the work and can rewrite its output while app reads it.

graph/dependency-in-two-manifest-fields-builds-once · correctness
✓✓✓✓✓
✗

Nix Step 1: exited 100, expected success

Run from inside a project, its dependencies’ tasks still run first
Why this is right

A 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.

graph/from-member-dir-runs-upstream · correctness
✓✓✓✓✓✓
A workspace package the root depends on builds before a project that reads it through the root
Why this is right

The root installs a workspace package where every project resolves it, whatever that project’s own manifest says. Here app reads lib’s build through the root, so lib must build first. A runner that orders tasks only by each project’s own manifest may run app first, and it then reads a missing or old dist.

graph/root-workspace-dependency-orders-every-project · correctness
✓
✗

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 cycle
Why this is right

util can reach lib through the root, but lib depends on util. Adding the root’s dependencies as an edge from every project would give util an edge back to lib, a cycle no manifest wrote. The runner would then refuse to build or pick an order at random. The root’s edge must not point a package at what depends on it.

graph/root-workspace-dependency-no-cycle · correctness
✓✓✓✓✓
✗

Nix Step 1: did not run util#build

^build runs the dependency’s build, not the builds that build itself never names
Why this is right

^build means the build of each nearest dependency that has one, not every build further down. Here b’s own build declares no dependency on c’s build: c is in b’s package graph but not in this task graph. Running c’s build anyway does work nobody asked for, on every run.

graph/caret-stops-at-nearest-holder · efficiency
✓✓✓✓✓✓
^build reaching one build through two projects without a build runs it once
Why this is right

^build passes through dependencies that have no build to reach the builds beyond them. When two such paths lead to the same project, its build is still one task and runs once. Running it twice wastes time and can race two writers on the same output directory.

graph/bridged-diamond-runs-shared-once · correctness
✓✓
✗

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 build
Why this is right

Each task that lists ^build needs its dependencies built before it starts. When two tasks of one project both list it, each must wait, not only the first. If the second loses its edge, it reads the dependency half built and produces output from stale or missing files.

graph/two-tasks-on-caret-both-wait · correctness
✓✓✓✓✓✓
Each entry of a dependsOn list runs first: three of the project’s, two ^ names
Why this is right

Every entry in a dependsOn list is a separate requirement. A runner that honours only the first entry, or lets one ^ entry stand for the others, starts the task before some of what it needs exists. The task then reads missing files, or old ones from an earlier run.

graph/every-depends-on-entry-is-an-edge · correctness
✓✓✓✓✓✓
^test and ^lint through a project with no tasks reach each project beyond that declares them
Why this is right

A project that declares no tasks still sits in the package graph. ^test and ^lint must each pass through it to the projects beyond that declare them, and the order they are listed in must not matter. Stopping at the empty project, or letting one name’s walk decide the other’s, runs the task before its real dependencies.

graph/two-carets-through-taskless · correctness
✓✓
✗

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 alone
Why this is right

^build asks for the builds of this project’s dependencies, and none of them has one. Some other project declares build, so the name is not a typo and the run should not fail. The task runs alone, and the unrelated project’s build is not pulled in.

graph/caret-declared-off-path-runs-alone · correctness
✓✓✓✓✓✓
A task that depends on the requested builds is not run with them
Why this is right

A 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.

graph/task-built-on-the-tree-not-run · efficiency
✓✓✓✓✓✓
dependsOn naming another project’s task that does not exist fails before any runs
Why this is right

A 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.

graph/unknown-project-task-edge-refused · correctness
✓
✗

Nx Step 1: succeeded, expected a failure

✓✓✓✓
dependsOn naming a task its own project lacks fails before any runs
Why this is right

A same-project name the project does not declare, such as lnt for lint, is a typo. Running the task without it quietly skips the check the user meant to run first. Failing before any task runs shows the mistake in the config where it was made.

graph/unknown-same-project-dependency-refused · correctness
✓
✗

Nx Step 1: succeeded, expected a failure

✓✓✓✓
^biuld, a name no project declares, fails the run instead of passing with no edge
Why this is right

A ^name some project declares may simply have no holder among this project’s dependencies, which is fine. A ^name no project anywhere declares, like ^biuld, can only be a typo. Passing the run with no edge hides that the dependency the user wanted is never built.

graph/caret-name-no-project-declares-refused · correctness
✓✓✓
✗

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 runs
Why this is right

A ring of ^build edges has no valid order: each build needs another that needs it back. Starting any of them means it reads its dependency’s output before that exists. Failing before any build runs names the cycle, instead of a run that hangs or builds from stale files.

graph/task-cycle-through-four-projects-refused · correctness
✓✓✓✓✓✓
A ^build ring that passes through a project without a build still fails
Why this is right

^build reaches past a project without a build to the builds beyond it. Around this ring that walk comes back to the start, so each build waits on itself through the empty project. It is a real cycle, and the run must refuse it before any build runs, as it would a direct one.

graph/task-cycle-through-taskless-refused · correctness
✓
✗

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 build
Why this is right

A 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.

graph/package-ring-through-taskless-runs · correctness
✓✓✓
✗

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 runs
Why this is right

app1’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.

graph/package-cycle-below-a-holder-runs · correctness
✓✓
✗

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 runs
Why this is right

A 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.

graph/cycle-reached-through-another-task-refused · correctness
✓✓✓✓✓✓
build.* on a task named build.all runs the other matches, not itself
Why this is right

A pattern like build.* names the other tasks in its family. It also matches the name of the task that declares it, and read as an edge, that is a cycle the user never wrote. The declaring task is left out, so the pattern means what it plainly says.

graph/pattern-skips-the-declaring-task · correctness
✓✓––––
A dependsOn pattern that matches no task runs the task alone
Why this is right

A pattern names a family of tasks that may be empty in a given project. A shared preset can list codegen.* for every project, including ones with no codegen. Failing there would force each project to opt out by hand, so the task simply runs alone.

graph/pattern-matching-nothing-runs · correctness
✓✓––––
A ^ pattern no project’s task matches runs the task alone
Why this is right

A ^ pattern names a family of tasks that dependencies may or may not have. When no project matches, there is nothing to wait for, and the task runs alone. Unlike an exact name nobody declares, a pattern that matches nothing is a normal case for shared config, not a typo.

graph/caret-pattern-matching-nothing-runs · correctness
✓✓––––
^build.* runs every match of the nearest dependency holding one, and stops there
Why this is right

^build.* runs the matching tasks of the nearest dependency that has any, as ^build does for one name. It runs every match there, since each may produce something the task needs. It does not reach further down, where the dependency’s own config decides what its tasks need.

graph/caret-pattern-stops-at-nearest-holder · efficiency
✓✓––––
^build.* passes through a dependency with no match to the one beyond it
Why this is right

A dependency with no matching task is a step on the way, not the end of the walk. ^build.* passes through it to the project beyond that has matches, as an exact ^build would. Stopping there would run the task before the build it needs, on missing or stale files.

graph/caret-pattern-through-taskless · correctness
✓✓––––
^build.* across a package cycle does not wrap back to the asking project’s own tasks
Why this is right

^ means the tasks of dependencies, never of the asking project itself. Across a package cycle, walking a’s dependencies leads back to a, but its own build.inner is still no dependency’s task. Including it would add an edge the user never wrote and run work nobody asked for.

graph/caret-pattern-skips-own-project-on-package-cycle · correctness
✓
✗

Nx Step 1: ran a#build.inner, expected it replayed or left out

––––
The . in build.x* is a dot: it never matches buildAx1
Why this is right

In a task pattern the . is a plain character; task names use it to group families like build.x1. Reading it as a regular-expression wildcard would also match buildAx1 and run a task the pattern was never meant to reach. Only * is a wildcard.

graph/pattern-dot-is-literal · correctness
✓✓––––
A task named both exactly and by a pattern in one dependsOn runs once
Why this is right

A 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.

graph/pattern-and-exact-name-run-once · correctness
✓✓––––
A task named like a route, t--src/app/[id]/page, is one name in dependsOn
Why this is right

An exact name in dependsOn is matched as written, whatever characters it holds. Generated task names often follow file routes and contain [, ] or /. Reading such a name as a pattern links the wrong task or none, and the dependant runs without what it needs.

graph/bracket-task-name-is-literal · correctness
✓
✗

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 class
Why this is right

Only * is a wildcard in a task pattern; brackets are plain characters. Routes like src/app/[id]/page are common in task names, and reading [id] as a character class would match i/page instead. The pattern then links the wrong task, and the one the user meant runs late or not at all.

graph/bracket-in-pattern-is-literal · correctness
✓
✗

Nx Step 1: did not run app#t--src/app/[id]/page

––––
A task the config names constructor runs like any other
Why this is right

A task name is just a string, and constructor is as valid as build. A runner that keeps tasks in a plain object may confuse it with the object’s own property and fail or run the wrong thing. The declared task must run like any other.

graph/object-property-task-name-runs · correctness
✓✓✓✓✓✓
^build:prod runs the dependency’s build:prod first and reruns on its change
Why this is right

Names with colons, like build:prod, are common for task variants and must work in ^ edges. The dependency’s build:prod runs first, and a change to its inputs reaches the dependant’s cache key. Otherwise the dependant could replay output built from the old dependency.

graph/colon-task-caret-keys-dependant · correctness
✓✓✓
✗

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 it
Why this is right

Some runners read a colon as a separator, such as between project and target. A task named a:b:c must still be one name, run as declared. Splitting it would run a different task, here a, and report a result for work nobody asked for.

graph/multi-colon-task-name · correctness
✓✓✓
✗

moon Step 1: exited 2, expected success

✓✓
A git worktree checked out inside the workspace adds no project to a run
Why this is right

A 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.

graph/worktree-inside-adds-no-project · correctness
✓
✗

Nx Step 2: exited 1, expected success

✓✓✓✓
A concurrency of 1 runs independent tasks one at a time
Why this is right

A 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.

graph/concurrency-one-serializes-tasks · correctness
✓✓✓✓✓✓
A directory selector selects the same projects from a subdirectory as from the root
Why this is right

A 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.

graph/select-dir-from-subdirectory · correctness
✓✓
✗

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 it
Why this is right

A 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.

graph/server-after-its-deps-until-stopped · correctness
✓✓✓✓––
A task that needs a server runs while the server is up, and the run ends when the task does
Why this is right

Depending 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.

graph/server-up-while-its-dependant-runs · correctness
✓✓
✗

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 runs
Why this is right

When 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.

graph/server-cleans-up-when-the-run-ends · correctness
✓
✗

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 exiting
Why this is right

A 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.

graph/server-ignoring-sigterm-does-not-hang-the-end · correctness
✓✓
✗

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 exiting
Why this is right

A run is not over until every process it started has ended. With server & wait, the shell dies on SIGTERM, but the server it started ignores the signal and keeps the output pipe open. A runner that waits for that pipe to close hangs, and one that never kills the child leaves it running.

graph/server-child-ignoring-sigterm-does-not-hang-the-end · correctness
✓✓
✗

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 runs
Why this is right

A 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.

graph/one-slot-does-not-deadlock · correctness
✓✓
✗

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 time
Why this is right

A 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.

graph/one-slot-runs-dependants-one-at-a-time · correctness
✓✓
✗

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 them
Why this is right

An 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.

graph/server-over-a-server · correctness
✓✓
✗

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 count
Why this is right

Servers 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.

graph/every-selected-server-starts · correctness
✓✓
✗

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 line
Why this is right

A 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.

graph/dependant-waits-for-ready · correctness
✓✓––––
A server-style task that finishes with exit 0 before its ready line lets its dependants run
Why this is right

A 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.

graph/setup-exiting-0-before-ready-lets-dependants-run · correctness
✓
✗

Nx Step 1: exited 1, expected success

––––
An exclude alone keeps the workspace-root project, as it keeps every other member
Why this is right

An exclude filter removes what it names and keeps everything else. The workspace-root project is a project like any other, so excluding c keeps it. Dropping it silently skips root tasks such as code generation or a lint over shared config.

graph/exclude-keeps-root-project · correctness
✓✓✓✓✓–
Running a task in a named project that does not declare it fails
Why this is right

Naming 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.

graph/selected-project-lacks-task · correctness
✓
✗

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 error
Why this is right

Across 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.

graph/selected-projects-some-lack-task · correctness
✓✓✓
✗

moon Step 1: exited 1, expected success

✓–
Two tasks with no edge between them run at the same time
Why this is right

Tasks 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.

graph/independent-tasks-run-at-once · efficiency
✓✓✓✓✓
✗

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 runner
Why this is right

Args after the task belong to the task’s command, whatever they look like. --help, --version and -v are also flags of every runner, and a runner that reads them itself prints its own help or version and runs nothing, often with exit 0. The user asked the task for its help or a verbose run and got neither, with a green exit hiding it.

graph/args-named-like-runner-flags-reach-task · correctness
✓✓✓✓✓✓
A dependency reached by ^build and by b#build builds once, first
Why this is right

Naming 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.

graph/caret-and-explicit-edge-run-once · correctness
✓✓✓✓✓✓
Explicit project#task edges chain: each one’s own edges run first, and nothing else
Why this is right

An 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.

graph/explicit-edges-chain-across-projects · correctness
✓✓✓✓✓✓
Two excludes alone each take their project out of every project
Why this is right

An 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.

graph/select-two-excludes · correctness
✓✓✓✓✓–
An exclude alone that names no project runs every project, or refuses
Why this is right

Excluding 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.

graph/select-exclude-matching-nothing · correctness
✓✓✓✓✓–
The three tasks one task releases run at the same time, and the task over them reads all three
Why this is right

When 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.

graph/fan-out-runs-at-once · efficiency
✓✓✓✓✓✓

Config

8 cases
100%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.

CasevxNxTurborepomoonVite TaskNix
* alone in dependsOn fails the run
Why this is right

A 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.

config/bare-wildcard-star-refused · correctness
✓
✗

Nx Step 1: succeeded, expected a failure

––––
^* alone in dependsOn fails the run
Why this is right

A 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.

config/bare-wildcard-caret-star-refused · correctness
✓
✗

Nx Step 1: succeeded, expected a failure

––––
** alone in dependsOn fails the run
Why this is right

A 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.

config/bare-wildcard-globstar-refused · correctness
✓
✗

Nx Step 1: succeeded, expected a failure

––––
dependsOn naming a task the project lacks fails the run
Why this is right

A 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.

config/depends-on-missing-task · correctness
✓
✗

Nx Step 1: succeeded, expected a failure

✓✓✓–
dependsOn naming a project that does not exist fails the run
Why this is right

A 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.

config/depends-on-missing-project · correctness
✓
✗

Nx Step 1: succeeded, expected a failure

✓✓✓–
dependsOn naming a task another project lacks fails the run
Why this is right

Naming lib#gen says lib produces something this task reads. When lib declares no such task, nothing produces it, and a run that carries on reads a missing or stale file. Refusing the run turns a silent wrong build into an error that names the bad edge.

config/depends-on-missing-task-of-project · correctness
✓
✗

Nx Step 1: succeeded, expected a failure

✓✓✓–
A time limit no timer can hold is refused, not turned into an instant kill
Why this is right

A 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.

config/timeout-beyond-any-timer-refused · correctness
✓––
✗

moon Step 1: succeeded, expected a failure

––
A project whose package.json does not parse fails the run
Why this is right

A 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.

config/malformed-manifest-refused · correctness
✓✓✓
✗

moon Step 2: succeeded, expected a failure

✓
✗

Nix Step 2: succeeded, expected a failure

Failures

73 cases
100%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.

CasevxNxTurborepomoonVite TaskNix
A failure at the bottom of a chain runs nothing above it
Why this is right

When 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.

failure/skip-cascades-down-a-chain · correctness
✓✓✓✓✓✓
A failed dependency stops the task behind a replayed one from running
Why this is right

app’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.

failure/failed-dependency-stops-what-is-behind-a-replay · correctness
✓✓✓✓✓
✗

Nix Step 1: exited 100, expected success

A server whose own build fails never starts, nor does what needs it
Why this is right

A 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.

failure/server-behind-a-failed-build-never-starts · correctness
✓✓✓✓––
Asking for two tasks, one of which no project declares, fails the run
Why this is right

A 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.

failure/unknown-task-beside-a-known-one-fails · correctness
✓
✗

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 run
Why this is right

The 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.

failure/task-only-outside-selection-fails · correctness
✓
✗

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 cleanup
Why this is right

Closing 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.

failure/sighup-grandchild-cleanup · correctness
✓
✗

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 run
Why this is right

Running 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.

failure/undeclared-task · correctness
✓
✗

Nx Step 1: succeeded, expected a failure

✓✓✓✓
A task stopped by SIGTERM mid-run fails the run and is run again next time, never replayed
Why this is right

A 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.

failure/sigterm-mid-task-saves-nothing · correctness
✓✓
✗

Turborepo Step 1: succeeded, expected a failure

✓✓
✗

Nix Step 1: did not finish within 20 s

A task that exits non-zero fails the run
Why this is right

A 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.

failure/exit-code-fails-run · correctness
✓✓✓✓✓✓
A failed task is not replayed as a success next time
Why this is right

Only 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.

failure/never-cached · correctness
✓✓✓✓✓✓
A failed dependency stops the projects that need it from running
Why this is right

A 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.

failure/dependents-skipped · correctness
✓✓✓✓✓✓
Fixing a failed task’s input makes the next run pass and build
Why this is right

A 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.

failure/fixed-then-passes · correctness
✓✓✓✓✓✓
A task killed by a signal fails the run and is not cached
Why this is right

A 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.

failure/killed-by-signal · correctness
✓✓✓✓✓✓
A task that writes to stderr and exits 0 succeeds
Why this is right

Many 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.

failure/stderr-is-not-failure · correctness
✓✓✓✓✓✓
Asking for a task no project declares fails the run
Why this is right

A 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.

failure/missing-task-in-filter · correctness
✓
✗

Nx Step 1: succeeded, expected a failure

✓✓✓✓
Tasks that depend on each other in a cycle fail the run before any runs
Why this is right

A 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.

failure/task-cycle-refused · correctness
✓✓✓✓✓✓
Ctrl-C (SIGINT) ends the run, its task, and what the task backgrounded
Why this is right

Ctrl-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.

failure/sigint-reaps-task-tree · correctness
✓✓
✗

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 backgrounded
Why this is right

SIGTERM 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.

failure/sigterm-reaps-task-tree · correctness
✓✓
✗

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 backgrounded
Why this is right

A 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.

failure/sighup-reaps-task-tree · correctness
✓✓
✗

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 backgrounded
Why this is right

The 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.

failure/sigquit-reaps-task-tree · correctness
✓
✗

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 SIGINT
Why this is right

A task should receive the signal the user sent, unchanged. Cleanup code is often bound to one signal, such as a Node process.on("SIGINT") handler or a shell trap ... INT. If Ctrl-C is forwarded as SIGTERM or SIGKILL, that handler never runs, and temp files, locks or child processes are left behind.

failure/sigint-reaches-task-as-itself · correctness
✓
✗

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 SIGTERM
Why this is right

A 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.

failure/sigterm-reaches-task-as-itself · correctness
✓
✗

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 dependants
Why this is right

Once 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.

failure/stop-starts-no-dependant-of-a-task-that-exits-0 · correctness
✓✓
✗

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 SIGTERM
Why this is right

A 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.

failure/stop-escalates-past-an-ignoring-task · correctness
✓✓
✗

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 have
Why this is right

A 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.

failure/stop-waits-for-a-task-cleanup · correctness
✓✓
✗

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 cleanup
Why this is right

One Ctrl-C should reach a task once. Many cleanup handlers run once and then reset, like process.once or a trap that clears itself, so a second copy of the signal kills the task in the middle of its cleanup. A runner that signals the process group and then the task again leaves the cleanup half done.

failure/stop-cleanup-not-cut-short · correctness
✓
✗

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 died
Why this is right

Every 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.

failure/stop-grandchild-cleanup-gets-its-grace · correctness
✓
✗

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 outputs
Why this is right

An 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.

failure/stop-trapped-exit-0-not-cached · correctness
✓✓
✗

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 run
Why this is right

A 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.

failure/stop-keeps-what-finished · efficiency
✓✓
✗

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 closes
Why this is right

nohup sets SIGHUP to ignored so that a long job survives a closed terminal or a dropped SSH session. The ignore is inherited, and a runner that installs its own SIGHUP handler over it stops the run anyway. The user asked for the job to keep going, so the run finishes and exits 0.

failure/nohup-runs-through-a-hang-up · correctness
✓
✗

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 output
Why this is right

A 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.

failure/killed-dependency-builds-no-stale-dependant · correctness
✓✓✓✓✓
✗

Nix Step 2: succeeded, expected a failure

A concurrency of 0 is refused or read as a limit, never a run that waits for good
Why this is right

A 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.

failure/zero-concurrency-does-not-hang · correctness
✓✓✓
✗

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 running
Why this is right

The 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.

failure/second-stop-kills-at-once · correctness
✓✓
✗

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 finishes
Why this is right

A 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.

failure/stdin-read-sees-eof · correctness
✓
✗

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 cached
Why this is right

A 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.

failure/timeout-kills-an-overrunning-task · correctness
✓––
✗

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 succeeds
Why this is right

A 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.

failure/timeout-spares-a-task-within-it · correctness
✓––✓––
A timed-out task that traps SIGTERM and exits 0 still fails, and its partial output is not cached
Why this is right

A 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.

failure/timeout-trapped-exit-0-still-fails · correctness
✓––
✗

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 hang
Why this is right

A 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.

failure/timeout-escalates-past-an-ignoring-task · correctness
✓––
✗

moon Step 1: left running: sleep 60

––
A timeout kills what the task backgrounded, not only its shell
Why this is right

A timeout ends the task, and the task is its whole process tree, not only its shell. A command like sh -c "server & wait" starts a process that outlives its shell if only the shell is killed. That orphan keeps running after every timeout, holding ports and CPU into the next run.

failure/timeout-reaps-what-the-task-backgrounded · correctness
✓––
✗

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 died
Why this is right

A 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.

failure/timeout-reaps-a-backgrounded-child-ignoring-sigterm · correctness
✓––
✗

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 cached
Why this is right

A 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.

failure/retry-passes-a-flaky-task · correctness
✓––✓––
A task that fails every attempt runs once plus its retries, then fails the run
Why this is right

Retries 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.

failure/retries-exhausted-fail · correctness
✓––✓––
A task with retries that passes first time runs once
Why this is right

A 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.

failure/retries-unused-on-success · efficiency
✓––✓––
A task killed by a signal the run did not send is retried like any failure
Why this is right

A 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.

failure/retry-after-a-signal-death · correctness
✓––✓––
Ctrl-C during a task with retries does not attempt it again
Why this is right

When 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.

failure/stop-ends-retries · correctness
✓––✓––
Asking for constructor, a task no project declares, fails the run
Why this is right

A task exists only if a config declares it. A name every JavaScript object carries, like constructor, must not be found by accident in a lookup table. Otherwise the run would pass or crash on a task nobody declared, instead of reporting the unknown name.

failure/object-property-task-name-refused · correctness
✓✓✓✓✓✓
SIGINT to the run reaches a server past its ready line as SIGINT
Why this is right

A 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.

failure/sigint-reaches-ready-server-as-itself · correctness
✓
✗

Nx Step 1: packages/app/got.txt is missing, expected "SIGINT\n"

––––
SIGTERM to the run reaches a server past its ready line as SIGTERM
Why this is right

A 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.

failure/sigterm-reaches-ready-server-as-itself · correctness
✓
✗

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 running
Why this is right

SIGTERM 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.

failure/sigterm-ends-a-ready-server-and-its-dependant · correctness
✓✓––––
SIGTERM ends the child of a ready server that traps TERM, INT and HUP
Why this is right

A 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.

failure/sigterm-reaps-a-ready-server-child-that-ignores-it · correctness
✓✓––––
One Ctrl-C lets a ready server finish its cleanup while its dependant is stopped
Why this is right

One 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.

failure/stop-cleanup-of-a-ready-server-not-cut-short · correctness
✓
✗

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 command
Why this is right

A 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.

failure/stop-ends-a-runtime-probe · correctness
✓✓–✓––
A run whose task fails beside a server fails promptly and stops the server
Why this is right

Once 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.

failure/server-stopped-when-its-dependant-fails · correctness
✓✓
✗

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 it
Why this is right

A 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.

failure/server-exiting-at-once-fails-the-run · correctness
✓
✗

Nx Step 1: succeeded, expected a failure

✓✓––
A server whose shell exits leaves nothing it started holding its port
Why this is right

When a server’s shell exits, everything it started must end with it. A process left behind keeps the port bound, so the next dev fails with an address-in-use error that has no visible cause. The run fails, and nothing from it is left running.

failure/server-shell-exit-takes-its-child · correctness
✓
✗

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 run
Why this is right

A 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.

failure/server-death-skips-what-needs-it · correctness
✓✓✓✓––
Ctrl-C before a server’s turn leaves it unstarted
Why this is right

After 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.

failure/stop-before-the-server-starts · correctness
✓✓
✗

Turborepo Step 1: succeeded, expected a failure

✓––
SIGINT to the run reaches a running server as SIGINT
Why this is right

A 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.

failure/sigint-reaches-server-as-itself · correctness
✓
✗

Nx Step 1: packages/app/got.txt is "SIGTERM\n", expected "SIGINT\n"

✓✓––
SIGTERM to the run reaches a running server as SIGTERM
Why this is right

A 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.

failure/sigterm-reaches-server-as-itself · correctness
✓
✗

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 can
Why this is right

Ctrl-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.

failure/stop-reaps-a-server-child-that-ignores-it · correctness
✓✓✓
✗

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 dependant
Why this is right

A 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.

failure/server-dying-before-ready-fails · correctness
✓✓––––
A server whose command does not exist fails the run at once, not after waiting for its ready line
Why this is right

A 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.

failure/missing-server-binary-fails-fast · correctness
✓✓––––
Ctrl-C while a server is still starting ends it and starts none of its dependants
Why this is right

Ctrl-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.

failure/stop-while-a-server-starts · correctness
✓✓––––
A server that prints its ready line as Ctrl-C stops it does not start its dependants
Why this is right

Readiness 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.

failure/server-ready-after-the-stop-is-not-ready · correctness
✓✓––––
A server that never prints its ready line fails at its time limit, and is killed
Why this is right

A 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.

failure/never-ready-server-times-out · correctness
✓–––––
A server that is ready inside its time limit keeps running past it
Why this is right

For 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.

failure/ready-server-outlives-its-time-limit · correctness
✓–––––
A never-ready server that ignores SIGTERM is killed at its time limit anyway
Why this is right

A 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.

failure/never-ready-server-ignoring-sigterm-killed · correctness
✓–––––
A never-ready server that exits 0 on its time limit’s SIGTERM still fails
Why this is right

A 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.

failure/never-ready-server-trapping-exit-0-fails · correctness
✓–––––
A never-ready server’s backgrounded child that ignores SIGTERM dies with it at its time limit
Why this is right

At 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.

failure/never-ready-server-child-killed · correctness
✓–––––
A never-ready server’s SIGTERM cleanup runs to its end at the time limit
Why this is right

Killing 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.

failure/never-ready-cleanup-finishes · correctness
✓–––––
Asked to continue, a run goes on past a failed task and still fails
Why this is right

Asked 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.

failure/continue-runs-past-a-failure · correctness
✓–✓
✗

moon Step 1: did not run app#build

––
A task that ran past its dependency’s failure is not replayed once the dependency is fixed
Why this is right

When 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.

failure/continue-saves-nothing-built-on-a-failure · correctness
✓–✓
✗

moon Step 1: did not run app#build

––
A task that closes its output and keeps working is waited for until it exits
Why this is right

A 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.

failure/task-closing-its-output-still-awaited · correctness
✓✓✓✓✓✓

Affected

97 cases
100%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.

CasevxNxTurborepomoonVite TaskNix
A change selects a project whose task names the changed project’s task
Why this is right

app 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.

affected/change-reaches-task-edge-dependent · correctness
✓
✗

Nx Step 4: did not run app#build

✓✓––
A changed root file no member owns selects the workspace-root project
Why this is right

A 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.

affected/root-file-selects-root-project · correctness
✓✓✓✓––
A changed member file selects the member, not the workspace-root project holding it
Why this is right

A 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.

affected/member-file-not-root-project · efficiency
✓✓
✗

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 projects
Why this is right

Changed 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.

affected/non-ascii-names-select · correctness
✓✓✓✓––
A committed change in a project whose directory is named café selects it
Why this is right

A project’s directory may have a non-ASCII name like café, and a change inside it must select it. If git’s escaped path is compared with the decoded directory name, the two never match. The changed project is then left out of an affected run, and CI goes green without testing it.

affected/non-ascii-project-directory · correctness
✓✓✓✓––
A committed dependency change selects the projects whose dependencies hold it, through a linked workspace too, and no other
Why this is right

The 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.

affected/lockfile-change-selects-its-closure · correctness
✓
✗

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 it
Why this is right

A changed-projects check must follow the same edges a key does. The edit lies under vendor/, which belongs to no project, but lib installs that directory. If the check only maps changed paths to the project holding them, it finds nothing and CI skips lib, so a broken dependency merges untested.

affected/file-dir-dependency-edit-selects-dependent · correctness
✓
✗

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 it
Why this is right

The edit is under other’s directory, but lib is the project that installs it. A changed-projects check that maps a path only to the project holding it selects other and skips lib. CI then never tests the project the change reaches, and a pull request that breaks lib shows green.

affected/file-dep-inside-other-project-selects-dependent · correctness
✓
✗

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 project
Why this is right

Installing a local dependency from another project’s directory claims that directory, not the whole project. An edit to other’s source, next to it, reaches only other. Selecting lib too, or every project, makes each pull request touching other run tests that cannot fail from it.

affected/beside-file-dep-selects-owner-only · efficiency
✓✓✓✓––
A committed change to a workspace package the root depends on selects a project reading it through the root
Why this is right

A changed-projects check must see the same dependencies a build does. app reads lib through the root, so a change to lib reaches app. If the check only reads each project’s own manifest it selects lib alone, and CI never tests app against the change, which then breaks on main.

affected/root-workspace-dependency-change-selects-every-reader · correctness
✓
✗

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 it
Why this is right

A 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 app through a change in lib passes CI. The break then shows up on main or in production, far from the commit that caused it.

affected/change-reaches-dependents · correctness
✓
✗

Nx Step 3: did not run app#test

✓✓––
A change in a project nothing depends on selects only that project
Why this is right

Selecting 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.

affected/leaf-change-stays-put · efficiency
✓✓✓✓––
With nothing changed, an affected run runs nothing and succeeds
Why this is right

When 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.

affected/nothing-changed · correctness
✓✓✓✓––
An uncommitted edit counts as a change
Why this is right

A 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.

affected/uncommitted-change · correctness
✓✓✓✓––
A new file git does not track yet counts as a change
Why this is right

A 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.

affected/untracked-file · correctness
✓✓✓✓––
A deleted file counts as a change to its project
Why this is right

Removing 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.

affected/deleted-file · correctness
✓
✗

Nx Step 3: did not run app#test

✓✓––
A change to a root file no task reads selects nothing
Why this is right

A 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.

affected/root-file-reaches-nobody · efficiency
✓✓✓✓––
An uncommitted edit selects its project and every project that depends on it
Why this is right

An 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 app, built on the edited lib, was never tested.

affected/uncommitted-reaches-dependents · correctness
✓
✗

Nx Step 2: did not run app#test

✓✓––
Changing a project’s task definition selects it and its dependents
Why this is right

A 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.

affected/changed-task-definition · correctness
✓
✗

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 it
Why this is right

When 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.

affected/removed-tasks-select-dependents · correctness
✓✓✓
✗

moon Step 3: did not run app#build

––
A base several commits back selects what every commit since changed
Why this is right

A 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.

affected/several-commits · correctness
✓
✗

Nx Step 5: did not run app#test

✓✓––
A branch base selects what this branch changed, not what the base branch did since
Why this is right

Comparing 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.

affected/branch-base-is-merge-base · efficiency
✓✓✓✓––
On a merge commit, the first parent as base selects what the merge brought in
Why this is right

CI 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 HEAD^1 gives exactly what the merge brought in. Main’s own commits must not be counted, or each pull request reruns unrelated tests.

affected/merge-commit-first-parent · correctness
✓✓✓✓––
A tag works as a base, as a branch does
Why this is right

Releases 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.

affected/tag-base · correctness
✓✓✓✓––
An annotated tag works as a base: the commit it points at is compared
Why this is right

An 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.

affected/annotated-tag-base · correctness
✓✓✓✓––
In a depth-2 clone, the previous commit as base selects the change and its dependents
Why this is right

CI 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.

affected/shallow-clone · correctness
✓
✗

Nx Step 6: did not run app#test

✓✓––
In a depth-1 clone, the previous commit as base is fetched and selects the change
Why this is right

CI 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.

affected/depth-1-clone · correctness
✓
✗

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 changed
Why this is right

A pull request job usually checks out one commit, with the base branch and merge base only on origin. Comparing with origin/main must fetch the history needed to find the merge base. Failing breaks the job; selecting everything turns each pull request into a full run.

affected/depth-1-branch-base · correctness
✓
✗

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 in
Why this is right

A common pull request checkout is the merge commit fetched alone, with neither parent present. Its first parent is the base, so comparing with HEAD^1 must fetch it and select what the merge added. Failing or selecting everything loses the affected run on every pull request.

affected/depth-1-merge-checkout · correctness
✓
✗

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 project
Why this is right

Marking a file assume-unchanged tells git status not to look at it; it does not freeze the file for the build. A task reads the file on disk, so an edit there changes its result. Trusting the flag hides the edit, and the project’s tests do not run on code it now reads.

affected/assume-unchanged-edit · correctness
✓
✗

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 project
Why this is right

Skip-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.

affected/skip-worktree-edit · correctness
✓
✗

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 project
Why this is right

A 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.

affected/assume-unchanged-deleted · correctness
✓
✗

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 nothing
Why this is right

A 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.

affected/sparse-absent-file · efficiency
✓✓✓✓––
When only a project without the task changed, the run succeeds and runs nothing
Why this is right

In 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.

affected/changed-project-lacks-task · correctness
✓✓✓✓––
A changed project lacking the task, depending on one that has it, runs nothing and succeeds
Why this is right

A 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.

affected/changed-dependent-lacks-task · correctness
✓✓✓✓––
When only a project declaring no tasks at all changed, the run succeeds and runs nothing
Why this is right

A 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.

affected/changed-project-without-tasks · correctness
✓✓✓✓––
An affected run of a task no project declares fails, whatever changed
Why this is right

A task no project declares can never run, whatever changed. The run must fail, so a typo like tset is caught at once. Exiting 0 because the change happened to select nothing turns a broken CI step into a green one that silently tests nothing.

affected/unknown-task-refused · correctness
✓
✗

Nx Step 3: succeeded, expected a failure

✓
✗

moon Step 3: succeeded, expected a failure

––
A project that is a clean submodule is not selected
Why this is right

A 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.

affected/clean-submodule · efficiency
✓✓✓✓––
An edit inside a submodule project selects it
Why this is right

A 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.

affected/edit-in-submodule · correctness
✓
✗

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 it
Why this is right

A 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.

affected/untracked-in-submodule · correctness
✓
✗

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 not
Why this is right

Moving 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.

affected/submodule-pointer-moved · correctness
✓
✗

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 changes
Why this is right

Setting ignore = all for a submodule in .gitmodules quiets git status; it does not stop tasks from reading the files. An edit inside the submodule still changes what its project produces. Trusting the setting hides the change, and the project’s tests do not run.

affected/submodule-ignore-all · correctness
✓
✗

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 project
Why this is right

A 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.

affected/edit-in-nested-submodule · correctness
✓✓
✗

Turborepo Step 4: did not run a#test

✓––
A new git repository created inside a project selects that project
Why this is right

A 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.

affected/embedded-repo-in-project · correctness
✓✓✓✓––
Edits in two projects select both
Why this is right

A 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.

affected/changes-in-two-projects · correctness
✓✓✓✓––
A change staged in git’s index but not committed counts as a change
Why this is right

Files staged with git add are on disk and about to be committed, so tasks read them like any other edit. A comparison that looks only at the working tree against the index, or only at commits, misses them. A developer who stages before checking would see an empty run and commit untested work.

affected/staged-change · correctness
✓✓✓✓––
A file deleted from the working tree, not committed, selects its project
Why this is right

A 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.

affected/uncommitted-delete · correctness
✓✓✓✓––
A file renamed inside a project selects that project alone
Why this is right

A rename changes the project’s file set: a task over * sees a new name, and an import of the old path now fails. The project must be selected, but nothing outside it changed, so other projects stay out. Missing the rename hides a broken import; selecting everything wastes the run.

affected/rename-within-project · correctness
✓✓✓✓––
A file moved from one project to another, not yet committed, selects both
Why this is right

Moving 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.

affected/move-between-projects · correctness
✓✓
✗

Turborepo Step 2: did not run c#test

✓––
A committed move of a file from one project to another selects both
Why this is right

Once 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.

affected/committed-move-between-projects · correctness
✓✓✓✓––
A new file with a non-ASCII name selects its project
Why this is right

A 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.

affected/non-ascii-file-name · correctness
✓✓✓✓––
A changed file whose name holds a quote or a backslash selects its project
Why this is right

A 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.

affected/quote-backslash-file-name · correctness
✓✓✓✓––
New files git ignores, an install among them, select nothing
Why this is right

Files git ignores, such as an installed node_modules or a local build cache, are local state, not changes to the source. Counting them would select a project after every install, on every machine differently. Affected runs would then select much more than the change touched, and their selection would depend on whose machine ran them.

affected/ignored-files-select-nothing · efficiency
✓✓✓✓––
An edit to a file committed under a node_modules directory selects its project
Why this is right

A file committed to the repository is a source, even if it sits under a directory named node_modules, as test fixtures often do. Only an untracked install there is the package manager’s local state. Skipping the committed file by its path would hide an edit that its project’s tests read.

affected/tracked-file-under-node-modules · correctness
✓
✗

Nx Step 4: did not run b#test

✓✓––
A change in app-e2e selects it, not app or app-e2e-utils beside it
Why this is right

A project owns a path only when the path continues with a / after the project’s directory. Matching on a bare string prefix makes packages/app claim packages/app-e2e/x, so an edit there also selects app. Worse, a longest-prefix bug can give the file to the wrong project and skip the one that changed.

affected/sibling-prefix-dirs · correctness
✓✓✓✓––
A project nested in another owns its files: an edit there selects it, not its parent
Why this is right

When 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.

affected/nested-project-owns-its-files · efficiency
✓✓
✗

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 else
Why this is right

A 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.

affected/many-commits · correctness
✓✓✓✓––
A commit of six thousand long-named files, over a megabyte of paths, selects its project
Why this is right

Large 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.

affected/six-thousand-files · correctness
✓✓✓✓––
Deleting a whole project selects none of the projects that remain
Why this is right

When 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.

affected/deleted-project · efficiency
✓
✗

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 lives
Why this is right

A 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.

affected/moved-project · correctness
✓
✗

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 both
Why this is right

When 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 ** now reads less, so its result can change. Selecting only the new project misses that the parent’s inputs shrank, which can hide a broken reference.

affected/new-nested-project-selects-parent · correctness
✓
✗

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 nothing
Why this is right

A 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.

affected/unknown-base · correctness
✓✓✓✓––
A base spelled like a git option is never passed to git as one
Why this is right

The 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 --output=<file> must be treated as a revision, never handed to git as an option. Passed through, it makes git write or overwrite a file in the checkout.

affected/option-like-base · correctness
✓✓✓✓––
A base naming a directory inside a commit (HEAD:packages) selects nothing unchanged
Why this is right

A base like HEAD:packages names a directory inside a commit, not a commit, and its paths lack the packages/ prefix. Compared as if it were a commit, every project looks changed and the whole workspace runs. The tool should resolve what the base means, or refuse it, rather than widen the selection.

affected/base-naming-a-path · efficiency
✓
✗

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 changed
Why this is right

A base written as ^main asks for what this branch has that main does not. Reading it as main itself counts main’s own later commits as this branch’s change. A pull request would then rerun the tests of projects only main touched, which is time spent on code this branch did not change.

affected/exclusion-base · efficiency
✓
✗

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 nothing
Why this is right

If 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.

affected/broken-repository · correctness
✓✓✓✓––
An edit or a bump in a submodule selects its project though git is set to ignore it
Why this is right

A user may set diff.ignoreSubmodules to quiet git’s diff, but the task still reads the submodule’s files. An edit inside the submodule, or a bump to a new commit, changes what the task sees. Trusting the quieted diff skips the project, and its tests do not run on the new code.

affected/submodule-diff-ignored · correctness
✓
✗

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 out
Why this is right

Dependents reach a project only through the tasks they wait on, here its build. When the edited file is one only the project’s own test reads, and build excludes it, nothing a dependent builds on changed. Rerunning the dependents’ tests on every test-file edit wastes CI time for no new result.

affected/test-file-edit-skips-dependents · efficiency
✓✓✓✓––
A change no requested task reads runs nothing and succeeds
Why this is right

An 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.

affected/change-reaching-no-requested-task · efficiency
✓✓✓
✗

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 cold
Why this is right

Selection 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.

affected/unchanged-dependency-still-built · correctness
✓✓✓✓––
An input glob spelled ./src/** selects on an edit under src/
Why this is right

Users write input globs as ./src/** as often as src/**, and both mean the same files. A matcher that compares the changed path with the literal pattern finds no match for the ./ form. The edit is then missed, and the task and its dependents do not run on the new source.

affected/dot-slash-input-glob · correctness
✓
✗

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 nothing
Why this is right

An exclude is part of what a task declares it reads, whatever its spelling. !./src//**/*.test.txt excludes the same files as !src/**/*.test.txt, so an edit to one of them reaches nothing. Failing to apply the exclude reruns the task and its dependents for a file it does not read.

affected/dot-slash-exclude-glob · efficiency
✓✓✓
✗

moon Step 3: exited 1, expected success

––
An input naming a bare directory (src) selects on an edit under it
Why this is right

Naming a directory such as src as an input means the files in it, and the cache key already reads it that way. Selection must use the same meaning, or an edit under the directory selects nothing. The task and its dependents would then not run, even though their cache key changed.

affected/bare-directory-input · correctness
✓✓✓✓––
A task that only aggregates others runs when a task it depends on is affected
Why this is right

A task with no command of its own often groups others, like an all or ci task. It is affected whenever a task it waits on is affected. Dropping it because it reads no files would leave the grouped work out of the affected run, and CI would not check what the change reached.

affected/aggregate-task-kept · correctness
✓
✗

Nx Step 3: did not run app#all

✓✓––
A task that only aggregates others is left out when nothing it depends on changed
Why this is right

A 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.

affected/aggregate-task-unreached · efficiency
✓✓✓✓––
A task named toString is declared only where it is written
Why this is right

A task can be named anything, including toString or constructor. Looking it up on a plain object finds the inherited member in every project, so projects that never declared the task appear to have it. The run then schedules tasks that do not exist, or fails on them.

affected/task-named-like-object-member · correctness
✓
✗

Nx Step 3: exited 1, expected success

✓✓––
Any change in a project selects its task that is never cached
Why this is right

A 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.

affected/uncached-task-reads-whole-project · correctness
✓✓✓✓––
A dependent whose task waits on nothing of the change is left out
Why this is right

A 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.

affected/dependent-without-task-edge · efficiency
✓✓✓✓––
A task with no edge to other projects runs only where the change is
Why this is right

A task with no edge to other projects reads only its own project’s files. A change in lib cannot alter what app’s style sees. Running it in every dependent project because of the package graph spends CI time on checks whose result is already known.

affected/edge-free-task-stays-put · efficiency
✓✓✓✓––
A task reaches a change through either arm of a diamond of task edges
Why this is right

Task 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.

affected/task-diamond-reached · correctness
✓
✗

Nx Step 3: did not run app#top, app#seed

✓✓––
A task whose edges never reach the changed task is left out
Why this is right

A 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 side, left and base.

affected/task-diamond-unreached · efficiency
✓✓✓✓––
A change to a root file a task names as an input selects that task
Why this is right

A 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.

affected/root-input-selects-reader · correctness
✓✓✓✓––
A file in one project that another names as a root input selects both
Why this is right

A 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.

affected/root-input-in-another-project · correctness
✓✓✓✓––
Editing the root package.json in a way no task reads selects nothing
Why this is right

An edit to the root package.json that no task reads, such as its description, changes no input, no dependency and no command. Treating every root manifest edit as a change to everything turns small housekeeping commits into full runs. Inputs are declared, so the tool can tell nothing reads the change.

affected/root-manifest-edit · efficiency
✓✓✓✓––
A root file one task reads does not select its project’s other, never-cached task
Why this is right

A root input belongs to the task that names it, not to every task in that task’s project. A never-cached lint reads its own project, so a root file only the build names does not reach it. It must still run when its own project changes, which the case checks after.

affected/root-input-not-uncached-sibling · efficiency
✓✓✓✓––
Each task’s own root config file selects that task, not its sibling
Why this is right

Each 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 build when only the test config changed. A root file no task names, like other.cfg, selects nothing.

affected/root-input-selects-its-task-only · efficiency
✓✓✓✓––
An edit inside a nested repository a root input reaches selects the task naming it
Why this is right

A 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.

affected/root-input-in-nested-repo · correctness
✓
✗

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

––
A change to a file another task of the project reads does not select its task that reads no files
Why this is right

A 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 test declares, so the tool knows what reads it, and it cannot make build stale. Selecting build anyway reruns work on every commit that touches the project, while the task in another project that reads its own changed file is still selected.

affected/task-reading-no-files-not-selected · efficiency
✓✓
✗

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 affected
Why this is right

Running a build leaves its outputs in the tree, often without a .gitignore line for them. Those files are what the task produced, not a change anyone made. A tool that reads them as new sources selects every project that was built once, and an affected run turns into a full one.

affected/own-untracked-output-selects-nothing · efficiency
✓✓✓✓––

Cache

42 cases
100%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.

CasevxNxTurborepomoonVite TaskNix
A cached test behind a dev server runs again when the server’s sources change
Why this is right

A 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.

cache/server-source-change-reruns-dependant · correctness
✓✓
✗

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 run
Why this is right

An 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.

cache/uncached-upstream-own-write-settles · efficiency
✓✓✓✓✓
✗

Nix Step 2: exited 100, expected success

A cached task depending on an uncached one whose written file git ignores replays on the second run
Why this is right

A 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.

cache/uncached-upstream-ignored-write-replays · efficiency
✓✓
✗

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 stored
Why this is right

A 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.

cache/from-member-dir-shares-root-cache · efficiency
✓✓✓✓✓✓
A task with caching off runs on every run
Why this is right

Turning 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.

cache/uncached-runs-every-time · correctness
✓✓✓✓✓✓
Changing a task’s command runs it again
Why this is right

The 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.

cache/command-change-reruns · correctness
✓✓✓✓✓✓
Changing a dependency’s command rebuilds the projects that read its output
Why this is right

A 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.

cache/dependency-command-change-cascades · correctness
✓✓✓✓
✗

Vite Task Step 3: did not run ui#build, app#build

✓
Declaring a new output runs the task again so the cache holds it
Why this is right

An 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.

cache/outputs-change-rekeys · correctness
✓✓✓✓✓✓
Committing a change keeps the entry the uncommitted run saved
Why this is right

A 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.

cache/survives-checkout-copy · efficiency
✓✓✓✓✓✓
An edit that keeps a source’s length, right after a run, runs the task again
Why this is right

A 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.

cache/same-size-edit-reruns · correctness
✓✓✓✓✓✓
Writing a source again with the same bytes replays the task
Why this is right

A 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.

cache/identical-rewrite-replays · efficiency
✓✓✓✓✓✓
Adding an empty file under the inputs runs the task again
Why this is right

Which files exist under the inputs is input too, even when a file is empty. A command such as ls, a bundler’s glob or a test runner looking for test files reads the list of names. If an empty file changed no key, the runner would replay output that misses the new file, and nothing would warn.

cache/empty-file-added-reruns · correctness
✓✓✓✓✓✓
Changing one byte in the middle of a binary input runs the task again
Why this is right

Inputs 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.

cache/binary-byte-edit-reruns · correctness
✓✓✓✓✓✓
Changing the last byte of a 2 MB input runs the task again
Why this is right

A 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.

cache/large-file-tail-edit-reruns · correctness
✓✓✓✓✓✓
A task reading every project file replays though a sibling task wrote its gitignored output: ignored in the workspace’s .gitignore
Why this is right

A 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 dist, ignored in the workspace’s .gitignore. Git does not list it, and a runner that hashes it anyway reruns the reader after every build, so the test never replays. Ignores count wherever git reads them.

cache/ignored-output-not-input-root · efficiency
✓✓
✗

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 project
Why this is right

A 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 dist, ignored in a .gitignore between the root and the project. Git does not list it, and a runner that hashes it anyway reruns the reader after every build, so the test never replays. Ignores count wherever git reads them.

cache/ignored-output-not-input-between · efficiency
✓✓
✗

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 .gitignore
Why this is right

A 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 dist, ignored in the project’s own .gitignore. Git does not list it, and a runner that hashes it anyway reruns the reader after every build, so the test never replays. Ignores count wherever git reads them.

cache/ignored-output-not-input-project · efficiency
✓✓
✗

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/exclude
Why this is right

A 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 dist, ignored in .git/info/exclude. Git does not list it, and a runner that hashes it anyway reruns the reader after every build, so the test never replays. Ignores count wherever git reads them.

cache/ignored-output-not-input-info-exclude · efficiency
✓✓
✗

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 changes
Why this is right

Leaving 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 src/a.txt changes, the build’s key changes and the reader must run again on the new output. Otherwise the test passes against the old build while the source has moved on.

cache/ignored-output-reader-sees-new-build · correctness
✓✓✓✓✓
✗

Nix Step 3: report/a.txt is missing, expected "A2\n"

Staging and then committing an edit keeps the entry the unstaged run saved
Why this is right

Staging 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 git add and again after the commit, so every edit is built three times before it is pushed.

cache/staging-keeps-entry · efficiency
✓✓✓✓✓✓
A same-size edit right after a commit runs the task again
Why this is right

Right 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.

cache/same-size-edit-after-commit · correctness
✓✓✓✓✓✓
Staging and committing a deleted input replays what the run after the deletion stored
Why this is right

Deleting 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.

cache/staged-deletion-keeps-entry · efficiency
✓✓✓✓✓✓
In a repository with no commit yet the run caches, and the first commit keeps that entry
Why this is right

A 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.

cache/no-commit-yet-then-first-commit · efficiency
✓
✗

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 another
Why this is right

Which 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 lib, should get a replay. A runner that folds the selection into the key misses whenever the filter differs, so local runs and CI jobs with other filters never share results.

cache/selection-shares-entry · efficiency
✓✓✓✓✓–
A task an affected run selects replays what a run of every project stored
Why this is right

A 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.

cache/affected-shares-entry · efficiency
✓✓✓✓––
Other forwarded args run a cached task again, and the first args again leave their output
Why this is right

Forwarded 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.

cache/args-change-reruns · correctness
✓✓✓✓✓✓
A bare -- with no args replays what a run without it stored
Why this is right

A bare -- with nothing after it passes no args, exactly as a run without it. The command the task runs is the same, so the stored result is the same. A runner that keys the separator itself misses whenever a script or alias adds -- by habit, and rebuilds work it has already done.

cache/empty-args-are-no-args · efficiency
✓✓✓✓✓✓
New forwarded args for a task replay its dependency, which never sees them
Why this is right

Args 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.

cache/args-leave-dependency-replayed · efficiency
✓✓
✗

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 cached
Why this is right

When 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.

cache/losing-cache-runs-the-task · correctness
✓✓✓✓✓✓
A cached task run with other forwarded args runs again instead of replaying
Why this is right

Arguments passed through to a command change what it does. The output saved for --a is wrong for --b, so the args must be part of the cache key. Without that, a different command line replays the earlier result, and the user gets output for flags they did not pass.

cache/forwarded-args-key-the-task · correctness
✓✓✓✓✓✓
A server only cached tasks need is not started when they all replay
Why this is right

A 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.

cache/server-not-started-for-cached-dependants · efficiency
✓
✗

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 saved
Why this is right

Forcing 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.

cache/force-runs-and-refreshes · correctness
✓✓✓✓––
A run that only reads the cache replays a hit, and saves nothing for a miss
Why this is right

A 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.

cache/read-only-replays-and-saves-nothing · correctness
✓–✓✓––
A planned run runs no task, writes no output, and saves nothing
Why this is right

A 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.

cache/dry-run-runs-and-saves-nothing · correctness
✓–✓––✓
A linked git worktree of the same commit replays what the main checkout built
Why this is right

A 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.

cache/linked-worktree-replays-main-checkout · efficiency
✓✓✓
✗

moon Step 3: wt/.ran exists, expected none

✓✓
Putting back the outputs a task first declared replays the entry saved for them
Why this is right

An 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.

cache/outputs-revert-replays · efficiency
✓✓✓✓✓✓
Running a task with one set of args, then another, then each again replays both
Why this is right

Forwarded 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.

cache/each-args-keep-their-entry · efficiency
✓✓✓✓✓✓
A command of multi-byte characters runs byte-exact, and changing its last character runs it again
Why this is right

A command is text of any script, and echo ああ must print exactly that. A runner that counts characters as bytes cuts the command inside a character or drops its tail, so the shell runs something else, or two commands differing only at the end share one key. The edit then replays the old output under a green run.

cache/non-ascii-command-edit-reruns · correctness
✓✓✓✓✓✓
A forwarded arg of multi-byte characters reaches the command byte-exact and keys the task
Why this is right

Args 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.

cache/non-ascii-args-forwarded-and-keyed · correctness
✓✓✓✓✓✓
A forced run after an input edit saves under the new inputs, and the old inputs never replay its result
Why this is right

Forcing 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.

cache/forced-run-after-edit-saves-under-new-inputs · correctness
✓
✗

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 bytes
Why this is right

A 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 git add, so marking the conflict resolved rebuilds a task whose inputs did not change.

cache/conflicted-input-keys-as-resolved · efficiency
✓✓✓✓✓✓

Inputs

303 cases
100%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.

CasevxNxTurborepomoonVite TaskNix
Editing a root file a workspace-root task reads runs it again
Why this is right

The 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.

inputs/root-task-reads-root-files · correctness
✓✓✓✓✓✓
Editing a member’s file leaves a workspace-root task over ** replayed
Why this is right

A 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 ** does not read the members’ files. Rerunning it on every member edit would rebuild the root on nearly every change in the workspace.

inputs/root-task-ignores-members · efficiency
✓✓
✗

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 again
Why this is right

The 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.

inputs/root-glob-over-nested-repo · correctness
✓✓✓✓✓✓
A second run over an unchanged tree replays instead of running
Why this is right

When 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.

inputs/unchanged-replays · efficiency
✓✓✓✓✓✓
Editing an input runs the task again
Why this is right

A 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.

inputs/edit · correctness
✓✓✓✓✓✓
A same-size edit right after a commit runs the task again
Why this is right

Change 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.

inputs/same-size-edit · correctness
✓✓✓✓✓✓
A new file under an input glob runs the task again
Why this is right

A glob names a set of files, and adding a file changes that set. A build that concatenates, bundles or tests everything under src/ gives different output once a new file exists. Keying only on the files seen before would replay a bundle that is missing the new module.

inputs/new-file · correctness
✓✓✓✓✓✓
Deleting an input runs the task again
Why this is right

Removing 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.

inputs/deleted-file · correctness
✓✓✓✓✓✓
Renaming an input runs the task again
Why this is right

A 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.

inputs/renamed-file · correctness
✓✓✓✓✓✓
An edit deep in a nested input directory runs the task again
Why this is right

A 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.

inputs/nested-dir · correctness
✓✓✓✓✓✓
An edit to an input named with spaces, $ and quotes runs the task again
Why this is right

File names with spaces, quotes or $ are legal and occur in real repos, for example among assets and fixtures. If such a name is mangled when it is listed, quoted or passed through a shell, the file drops out of the key. Edits to it then never rerun the task, and the build ships its old content.

inputs/odd-file-name · correctness
✓✓✓✓✓✓
An edit to an input with a non-ASCII name runs the task again
Why this is right

Non-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.

inputs/unicode-file-name · correctness
✓✓✓✓✓✓
Undoing an edit replays the first run’s outputs, not the edited ones
Why this is right

A 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.

inputs/revert-replays-old · correctness
✓✓✓✓✓✓
A file outside the task’s inputs changes nothing
Why this is right

Declared 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.

inputs/outside-inputs-ignored · efficiency
✓✓✓✓✓✓
A file a ! input pattern takes back changes nothing
Why this is right

A ! pattern is the user stating that a file does not affect the task. Notes, another tool’s fixtures or generated docs often sit inside a source folder. Ignoring the exclusion reruns the task on edits the user has said cannot change its output, and the cache loses its value for that folder.

inputs/negated-glob · efficiency
✓✓✓✓✓✓
A gitignored file under an input glob changes nothing
Why this is right

Files 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.

inputs/gitignored-not-input · efficiency
✓✓
✗

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 input
Why this is right

A 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.

inputs/untracked-file · correctness
✓✓✓✓✓✓
Making an input executable runs the task again
Why this is right

The 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 chmod +x. The cache then replays an output whose script is not executable, and the failure shows only when someone tries to run it.

inputs/executable-bit · correctness
✓
✗

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"

✓
Two dependencies that swap contents, at the same output path, run the project built on them again
Why this is right

After 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.

inputs/dependencies-swapped-rerun-consumer · correctness
✓✓✓✓
✗

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 output
Why this is right

A 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.

inputs/dependency-edit-cascades · correctness
✓✓✓✓
✗

Vite Task Step 3: did not run ui#build, app#build

✓
Editing one project does not rerun a project that does not depend on it
Why this is right

A 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.

inputs/sibling-untouched · efficiency
✓✓✓✓
✗

Vite Task Step 3: did not run app#build

✓
An edit to a file outside a dependency’s build inputs does not rebuild its dependents
Why this is right

A 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.

inputs/dependency-non-input-edit · efficiency
✓✓✓✓✓✓
A project nested inside another keeps its files out of the outer project’s inputs
Why this is right

A project nested in another’s folder owns its own files. If the outer project’s ** also counts them, every edit inside the inner project reruns the outer one too. Project boundaries are what keep one team’s change from invalidating another team’s cache.

inputs/nested-project-excluded · efficiency
✓✓
✗

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

A task that lists no inputs never replays a stale result when a file it reads changes
Why this is right

When 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.

inputs/unlisted-file-never-stale · correctness
✓
✗

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 changes
Why this is right

A 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: MODE moves from dev to prod and a dev build ships as prod. Rerunning the task, or hiding the variable from it, both keep the output true.

inputs/unlisted-env-never-stale · correctness
✓
✗

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 change
Why this is right

Whatever 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.

inputs/default-inputs-edit · correctness
✓✓✓✓✓✓
An edit deep under a brace alternative ending in ** runs the task again
Why this is right

A brace stands for its alternatives, so {src/**,lib/**} means src/** and lib/**, each at full depth. A tool that matches ** inside a brace only one level deep misses nested files. An edit deep under src then replays output built from the old file.

inputs/brace-globstar-alternative · correctness
✓✓✓✓✓✓
An input {src,lib} keys every file under both directories
Why this is right

Each brace alternative here names a directory, and a directory input keys its whole tree. {src,lib} should mean the same as listing src and lib. A tool that matches only the directory entries keys nothing inside, and edits under them replay stale output.

inputs/brace-of-directories · correctness
✓
✗

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 src
Why this is right

./src and src name the same directory, inside a brace or not. A tool that compares the alternative to paths without dropping the ./ matches nothing. Edits under src then change no key, and the old output replays. Writing ./ is a style choice and must not change which files are keyed.

inputs/brace-dot-slash-alternative · correctness
✓
✗

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 top
Why this is right

. is the project directory, so {.,src}/*.txt covers .txt files at the project’s top and in src. A tool that drops or mismatches the . alternative misses the top-level file. Edits to it then replay a stale result. The pattern is valid and common in shell-style globs.

inputs/brace-dot-alternative · correctness
✓
✗

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 nothing
Why this is right

A 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.

inputs/brace-unnamed-file-ignored · efficiency
✓✓✓✓✓✓
A , inside a class in a brace alternative is part of the name, not a cut
Why this is right

Inside a character class, a , is a character to match, not a brace separator. A parser that splits on it first breaks a[,]b into two broken alternatives. The file a,b.txt then matches nothing, and edits to it replay stale output. Classes are parsed before alternatives are cut.

inputs/brace-class-comma · correctness
✓✓✓✓✓✓
A { inside a class in a brace alternative is part of the name
Why this is right

Inside a character class, { is a character to match, not the start of a nested brace. A parser that reads it as syntax mangles the pattern. The file a{b.txt drops out of the key, and its edits replay a stale result. Classes are parsed before braces, so their contents are plain characters.

inputs/brace-class-open-brace · correctness
✓✓✓✓✓✓
A ! brace alternative with a , in a class takes that file back
Why this is right

A negation is parsed the same way as a positive glob, so a[,]b still names a,b.txt. If the negation is split at the comma, it matches nothing and the file stays an input. Every edit to it then reruns a task the user said it does not affect. Negations follow the same grammar as the globs they narrow.

inputs/brace-class-comma-negated · efficiency
✓✓✓✓✓✓
An input src/\{b\}.txt keys the file named with the braces
Why this is right

A backslash escapes a brace, so src/\{b\}.txt names the file {b}.txt literally. Braces in file names are rare but legal. A tool that drops the escapes keys b.txt instead, and edits to the real file replay a stale result. The escape is the only way to name such a file in a glob.

inputs/escaped-brace-literal · correctness
✓✓✓
✗

moon Step 1: exited 1, expected success

✓✓
An input src/\{b\}.txt does not key src/b.txt
Why this is right

An escaped brace is literal text, not an alternative, so src/\{b\}.txt does not name src/b.txt. The escape is how the user says exactly which file they mean. Keying the other file reruns the task when nothing it reads changed. Reading it as a brace undoes the escape.

inputs/escaped-brace-not-alternative · efficiency
✓✓✓
✗

moon Step 1: exited 1, expected success

✓✓
An input written ./src/** keys the files under src
Why this is right

./src/** and src/** name the same files; the ./ prefix is a common way to write a relative path. A tool that matches the glob against paths without ./ finds nothing. The key then never moves, and every edit under src replays the old output.

inputs/dot-slash-input · correctness
✓
✗

Nx Step 3: did not run a#build

✓✓✓✓
A negation written !./gen/** takes gen back out of the inputs
Why this is right

A negation written !./gen/** takes back the same files as !gen/**. If the ./ makes it match nothing, gen stays in the inputs. Every regeneration then reruns a task the user said does not read it. Generated files are often the very thing such a negation is written to exclude, so the rerun repeats on every generator run.

inputs/dot-slash-negation · efficiency
✓
✗

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 tree
Why this is right

src/. is the directory src itself, so */. names each top directory and its whole tree. A tool that treats the trailing . as a literal file name matches nothing. The task’s key then ignores every source edit and replays the old output. . means the directory in any path.

inputs/trailing-dot-directory · correctness
✓
✗

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 back
Why this is right

A negation !gen/. names the directory gen and takes its whole tree back. If the trailing . makes the negation match nothing, generated files stay in the key. Every regeneration then reruns a task the user excluded them from. Its positive form keys the whole tree, so its negation takes the whole tree back.

inputs/trailing-dot-negation · efficiency
✓✓✓✓✓
✗

Nix Step 1: exited 100, expected success

An input src/**/ (a trailing slash) keys the files under src
Why this is right

A trailing slash marks a pattern as a directory, so src/**/ covers the directories under src and the files in them. A tool that matches only directory entries keys no file. The key never moves, and edits under src replay stale output. The slash narrows the match to directories; it does not empty it.

inputs/trailing-slash-pattern · correctness
✓✓✓✓
✗

Vite Task Step 1: exited 1, expected success

✓
An input naming a directory, src, keys every file in its tree
Why this is right

A user who names a directory, src, means the files in it. A tool that treats the name as a glob matching only the directory entry keys nothing inside. Every edit under src then replays the old output, with no warning. Naming a directory is the simplest input there is, so it must mean its contents.

inputs/literal-directory · correctness
✓✓✓✓
✗

Vite Task Step 3: did not run a#build

✓
An input naming a directory with a trailing slash, src/, keys its tree
Why this is right

src/ is a common way to name a directory, and it means every file in its tree. Read as a bare glob, it matches no file and the key never moves. Every edit under src then replays the old output under a green run. The slash only says the path is a directory; it does not change which files are in it.

inputs/literal-directory-trailing-slash · correctness
✓✓✓✓✓✓
A negation naming a directory, !gen, takes its whole tree back
Why this is right

A negation naming a directory, !gen, takes back every file in that tree, just as the positive form keys it. If it only removed the directory entry, the generated files would stay in the key. Each regeneration would then rerun a task the user excluded them from.

inputs/negated-literal-directory · efficiency
✓✓
✗

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 names
Why this is right

A character class [ab] matches one of the listed characters, as in any glob. app/[ab]/*.js therefore covers app/a and app/b. A tool that reads the brackets literally keys nothing, and edits to those files replay stale output. Classes are part of every common glob syntax.

inputs/bracket-class · correctness
✓✓✓✓✓✓
An input app/[ab]/*.js does not key a directory the class leaves out
Why this is right

A character class matches only the characters it lists, so app/[ab]/ leaves app/c out. A file outside the class is not what the task reads. Keying it reruns the task on edits that cannot change its output. The class is how the user narrows the set, and the cache should honour it.

inputs/bracket-class-excludes · efficiency
✓✓✓✓✓✓
An input app/\[id\]/** keys the route directory named [id]
Why this is right

File-system routers name directories with brackets, such as app/[id], and an escaped \[id\] names that directory literally. Read as a class, the pattern matches app/i/… and never the route. An edit to the route then replays the old output. Without the escape, a bracketed route cannot be named at all.

inputs/escaped-route-directory · correctness
✓✓✓
✗

moon Step 1: exited 1, expected success

✓✓
An input app/\[id\]/*.js does not key the sibling app/i a class would match
Why this is right

An escaped \[id\] names the literal directory [id], not a class of the letters i and d. A sibling app/i is not what the task reads. Keying it reruns the task on edits that cannot change its output. The escape is how the user says the brackets are part of the name, and the match should honour that.

inputs/escaped-route-not-class · efficiency
✓✓✓
✗

moon Step 1: exited 1, expected success

✓✓
A ! input listed before the glob it narrows still takes its file back
Why this is right

Inputs 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.

inputs/negation-listed-first · efficiency
✓✓✓✓✓✓
A task whose inputs cover its own outputs still replays on the next run
Why this is right

A broad input such as ** often covers the task’s own output directory. Keying a task on what it wrote moves its key after every run, so it never replays. The cache then fills with entries nothing uses, and every run does the full work. Declared outputs are what the task makes, never what it reads.

inputs/own-outputs-not-inputs · efficiency
✓✓
✗

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 other
Why this is right

Ignored files are local state, but a file committed to the repository is source, whatever directory it sits in. An install under node_modules is untracked; a committed test fixture there is not. Skipping it by directory name replays tests against the old fixture.

inputs/tracked-node-modules · correctness
✓
✗

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 right

A committed file under a dot directory like .github is part of the repository, and ** covers it. Shell-style globbing skips dot directories unless asked. A task that reads such a file would then replay stale output after it changes. ** in an input means every file under it.

inputs/dot-directory-input · correctness
✓✓✓✓✓✓
A dotfile under an input glob (src/.env) is an input
Why this is right

A dotfile such as src/.env under an input glob is a file the task can read. Shell-style globbing skips dotfiles by default, and a tool that copies that rule drops it from the key. Editing it then replays output built from the old values. Dotfiles are often config, the files most likely to change the output.

inputs/dotfile-input · correctness
✓✓✓✓✓✓
A committed tsconfig.tsbuildinfo is an input of **
Why this is right

A build-state file like tsconfig.tsbuildinfo is ignored only when the repository ignores it; committed, it is source. A tool that skips it by name second-guesses the repository. Edits to it then replay a stale result, and nothing says the file was skipped.

inputs/tracked-build-state · correctness
✓✓✓✓✓✓
A file whose name sorts after every other is still an input
Why this is right

A 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.

inputs/highest-sorting-name · correctness
✓✓✓
✗

moon Step 1: exited 1, expected success

✓✓
Projects named like pkg-b, pkg.c and pkg0 keep their files out of pkg’s inputs
Why this is right

A project’s inputs stop at its own directory, and pkg-b, pkg.c and pkg0 are other directories that only share a name prefix. A prefix match on paths pulls their files into pkg’s key. Edits in those projects then rerun pkg for nothing. Paths must be compared by whole segments, not by string prefix.

inputs/prefix-named-siblings · efficiency
✓✓
✗

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 input
Why this is right

Only a nested project’s own directory is cut out of the outer project. inner-extra merely starts with the nested project’s name, so it stays the outer project’s input. A prefix match cuts it out too, and edits there replay the outer task’s stale output.

inputs/nested-project-prefix-sibling · correctness
✓✓✓✓✓✓
The outer project’s own directory beside a nested project stays its input
Why this is right

Cutting a nested project out of its parent must remove only that directory. lib sits at the same depth as inner and belongs to the outer project. A tool that drops more than the nested directory loses it from the key, and edits there replay stale output.

inputs/nested-project-same-depth · correctness
✓✓✓✓✓✓
A gitignored file named outright in the inputs is an input
Why this is right

A 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.

inputs/gitignored-file-named · correctness
✓
✗

Nx Step 3: did not run a#build

✓✓✓✓
A gitignored directory named outright in the inputs keys every file under it
Why this is right

A 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.

inputs/gitignored-directory-named · correctness
✓
✗

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 again
Why this is right

A 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.

inputs/named-directory-appears · correctness
✓✓✓✓
✗

Vite Task Step 3: did not run a#build

✓
Creating a named input file that did not exist runs the task again
Why this is right

A 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.

inputs/named-file-appears · correctness
✓✓✓✓✓✓
Input globs into directories that do not exist are no error
Why this is right

Inputs 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.

inputs/glob-into-missing-directory · correctness
✓✓✓✓✓✓
An input fix* keys the files it matches, not the tree of a directory it matches
Why this is right

A glob like fix* matches names; a directory it happens to match is not an input of its whole tree, unlike a directory named outright. The task reads fix.txt, not the fixtures beside it. Keying that tree reruns the task on fixture edits it never reads.

inputs/glob-matching-directory · efficiency
✓✓✓✓✓
✗

Nix Step 3: ran a#build, expected it replayed or left out

Each ** in src/**/x/** spans any depth
Why this is right

Each ** in a pattern matches any number of directories. src/**/x/** must reach a file under src/d1/d2/x/y. A tool that limits one of the globstars to a single level misses deep files, and edits there replay stale output. Deep source trees are common, so the match must not stop early.

inputs/two-globstars · correctness
✓✓✓✓✓✓
An input src/**/x/** does not key a file with no x directory above it
Why this is right

A pattern src/**/x/** requires an x directory somewhere in the path. A file with no x above it is not what the task reads. Matching it anyway, for example by collapsing the pattern to src/**, reruns the task on unrelated edits. The middle segment is what the user wrote to narrow the set.

inputs/two-globstars-narrow · efficiency
✓✓✓✓✓✓
Two tasks of one project with different negations each key their own set
Why this is right

Inputs belong to a task, not a project: two tasks of one project can read different files. Here lint reads notes that build excludes. A tool that shares one task’s file set with the other leaves lint replaying output built from the old notes. Each task’s inputs are resolved on their own.

inputs/sibling-tasks-different-negations · correctness
✓✓✓✓✓✓
A sibling task keying a file does not pull it into a task that takes it back
Why this is right

Inputs belong to a task, so a file one task reads does not become another’s input. build excludes the notes that lint reads. If file sets leaked between sibling tasks, every notes edit would rerun the build for nothing. The order the tasks run in must not matter either.

inputs/sibling-task-negation-holds · efficiency
✓✓✓✓✓✓
Editing a declared file at the workspace root runs the task again
Why this is right

Shared 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.

inputs/root-file-input · correctness
✓✓✓✓✓✓
A root file no root input names changes nothing
Why this is right

A 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.

inputs/root-input-undeclared-file · efficiency
✓✓✓✓✓✓
A root input reaching into another project’s directory keys that file
Why this is right

A 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.

inputs/root-input-into-sibling-project · correctness
✓✓✓✓✓✓
A gitignored file under a root input glob changes nothing
Why this is right

Ignored files are local state, and a broad root glob such as shared/** skips them like any other glob. An ignored scratch file in a shared directory is not part of the config. Keying it reruns tasks on local changes and gives the same commit different keys on different machines.

inputs/root-input-gitignored · efficiency
✓✓
✗

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 back
Why this is right

A 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.

inputs/root-input-negation · efficiency
✓✓✓✓✓✓
A root input naming a directory keys every file in its tree
Why this is right

A 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.

inputs/root-input-bare-directory · correctness
✓✓✓✓
✗

Vite Task Step 3: did not run a#build

✓
A gitignored root file named outright in the root inputs is an input
Why this is right

A 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.

inputs/root-input-gitignored-named · correctness
✓
✗

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 it
Why this is right

A 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.

inputs/root-output-not-own-input · efficiency
✓
✗

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 outputs
Why this is right

A 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.

inputs/project-output-not-root-input · efficiency
✓
✗

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 right

Escaped brackets in a root input name a literal directory such as [id], as they do in a project input. Read as a class, \[id\] matches i or d instead and never the route. Edits to the route then replay output built from the old page. The escape is how a bracketed name is written at all.

inputs/root-input-escaped-route · correctness
✓✓✓
✗

moon Step 1: exited 1, expected success

✓✓
A variable keying an uncached dependency reruns its cached dependant when it changes
Why this is right

A 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 API_URL. If its key ignored the variable because it never caches, the dependant would replay output built for the old URL.

inputs/uncached-upstream-env · correctness
✓
✗

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 replay
Why this is right

An uncached dependency always runs, but its key is still defined by what it declares. With API_URL unchanged, that key is unchanged, so the dependant can replay. Treating every uncached task as changed would rerun its whole downstream tree on every run.

inputs/uncached-upstream-env-unchanged · efficiency
✓✓
✗

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 edited
Why this is right

An 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.

inputs/uncached-upstream-root-file · correctness
✓
✗

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 result
Why this is right

A 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.

inputs/uncached-upstream-unlisted-file · correctness
✓
✗

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 replay
Why this is right

Empty 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.

inputs/uncached-upstream-empty-inputs · efficiency
✓✓
✗

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 it
Why this is right

A dependency’s key carries its inputs to the tasks after it, inside one project as across projects. Here test lists only package.json and reads the fixtures through the fixtures task’s output. Only that dependency’s key can carry the fixture edit; without it, tests replay against old fixtures.

inputs/same-project-dependency-key · correctness
✓
✗

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 dependant
Why this is right

A file outside the dependency’s inputs, like fixtures.txt beside fixtures/, changes neither the dependency’s key nor the dependant’s. Both should replay. Rerunning on a name that only resembles an input wastes the run. Inputs are matched by path, not by name likeness, and the dependant inherits only the dependency’s key.

inputs/same-project-dependency-outside · efficiency
✓✓✓✓✓✓
A committed file another task declares as its output stays readable while that task runs
Why this is right

A tool that clears a task’s outputs before running it must not delete a committed file another task reads in parallel. Here index.json is both committed source and a declared output. Deleting it mid-run makes the reader fail or read nothing, a failure that depends on timing and is hard to reproduce.

inputs/committed-file-under-running-output · correctness
✓✓✓✓✓
✗

Nix Step 1: exited 100, expected success

A task rewriting its own input ahead of a reader leaves the reader’s output current
Why this is right

The 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 a.txt and stores the new output under that key. A later run with the old source then replays output built from the new one, so the build ships text the source no longer holds.

inputs/formatter-rewrites-own-input · correctness
✓
✗

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 changes
Why this is right

A 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.

inputs/named-dependency-output-cascades · correctness
✓
✗

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 back
Why this is right

A key taken over the old bytes must not be stored with an output built from the new ones. Here another task edits src/a.txt while the reader runs, so the reader’s output reflects the edit but its key does not. Once the file is put back, that key matches again and replays the edit that is gone. A runner must re-check its inputs before storing.

inputs/edited-mid-run-not-replayed · correctness
✓
✗

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 back
Why this is right

A 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.

inputs/upstream-edited-mid-run-chain · correctness
✓
✗

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 again
Why this is right

A 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.

inputs/runtime-output-change · correctness
✓✓–✓––
A key command printing the same as before lets the task replay
Why this is right

A 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.

inputs/runtime-output-same · efficiency
✓✓–✓––
A key command that exits non-zero fails the run without running the task
Why this is right

Without 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.

inputs/runtime-command-fails · correctness
✓
✗

Nx Step 1: succeeded, expected a failure

–✓––
A key command two projects share is run once per run
Why this is right

A 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.

inputs/runtime-shared-once · efficiency
✓
✗

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 removed
Why this is right

A 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 .env.local must then run the task again; otherwise the build keeps an old API address or a value from a file that no longer exists.

inputs/runtime-ignored-env-file · correctness
✓✓–✓––
A key command reading a dependency’s output answers after that dependency ran
Why this is right

A 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.

inputs/runtime-reads-upstream-output · correctness
✓
✗

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 changes
Why this is right

A 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 src/a.txt changes, the build runs and the test must run too. Otherwise the test result replayed is for the old source, and a broken change passes.

inputs/reader-of-dependency-output · correctness
✓✓✓✓✓✓
A task reading a same-project dependency’s output replays when nothing changed
Why this is right

When 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.

inputs/reader-of-dependency-output-replays · efficiency
✓✓
✗

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 error
Why this is right

A 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.

inputs/unsearchable-untracked-directory · correctness
✓✓✓✓✓✓
An input list holding only a ! pattern never replays a stale result
Why this is right

An input list of only !vendor/** can be read as everything but vendor, or as nothing at all. Read as everything but vendor, the edit in src is an input and the task reruns. Read as nothing, the task stops tracking its own source and replays the old build after every edit. Refusing the list is fine; replaying is not.

inputs/negation-only-never-stale · correctness
✓✓
✗

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 off
Why this is right

Two ! cancel out, so !!vendor/** keeps vendor and leaves ** intact. A tool that strips one ! and hands !vendor/** to a glob library inverts the set: only vendor stays keyed. An edit in src then replays the old build. Refusing the pattern as unclear is fine; dropping the sources is not.

inputs/double-negation-never-stale · correctness
✓✓✓✓✓✓
A directory named like an ignored one, my_node_modules, is an input
Why this is right

Skipping directories such as node_modules must match the whole name, not part of it. A directory called my_node_modules is ordinary source the user owns and the task reads. A tool that matches by substring drops it from the key, and edits there replay the old output.

inputs/node-modules-lookalike-directory · correctness
✓✓✓✓✓✓
A project nested several directories down keeps its files out of the outer project’s inputs
Why this is right

A nested project owns its files at any depth, not only directly under the outer project. If the outer project’s ** counts them, every edit inside the nested project reruns the outer task as well. The outer task must still rerun when its own files change, which the last step checks.

inputs/nested-project-deep-excluded · efficiency
✓✓
✗

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 it
Why this is right

Naming 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.

inputs/ignored-directory-named-skips-nested-project · efficiency
✓✓
✗

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 it
Why this is right

Directory names are compared whole, not as text prefixes: genx is not inside gen. The genx folder is the outer project’s own source, named in its inputs. A tool that tests paths by string prefix treats it as part of a nested project at gen and drops it from the key, so edits there replay the old output.

inputs/nested-project-sibling-accepted · correctness
✓✓✓✓✓✓
A glob over the whole project with the nested project taken back keys the rest
Why this is right

Taking a nested directory out with !gen/** must leave the rest of the project keyed, including genx beside it. The exclusion names one directory, not every name that starts the same way. A tool that matches the negation by prefix drops genx too, and its edits replay the old output.

inputs/nested-project-negated-accepted · correctness
✓✓✓✓✓✓
A nested project named pkg* does not take its sibling pkg-b out of the outer inputs
Why this is right

A nested project’s directory is a path, not a glob, even when its name holds *. Read as a pattern, pkg* matches pkg-b, so the outer project loses pkg-b from its inputs and edits there replay the old output. Names with glob characters are legal and must be matched literally.

inputs/nested-project-glob-character-name · correctness
✓✓✓
✗

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 it
Why this is right

A file the task writes is its output, never its input, even when an input glob such as ** covers it. If the key includes it, each run changes the key of the next, so the second run misses. The task then reruns on every run and the cache never helps it.

inputs/root-output-in-own-project-not-input · efficiency
✓
✗

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 replays
Why this is right

A 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.

inputs/own-output-named-as-input · efficiency
✓✓
✗

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 it
Why this is right

An input naming the directory gen covers what is inside it, not files whose names start with gen. A tool that matches by text prefix also keys gen-a.txt, a file the task never reads. Edits to that file then rerun the task for nothing, on every run that follows one.

inputs/literal-directory-prefix-sibling · efficiency
✓✓✓✓
✗

Vite Task Step 5: did not run a#build

✓
A gitignored file gen named in the inputs is keyed beside a tracked gen-notes.txt
Why this is right

Naming 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 gen, so an edit to it must rerun the task. A tool that drops ignored files, or confuses gen with gen-notes.txt, replays the old output.

inputs/ignored-literal-beside-prefix-sibling · correctness
✓
✗

Nx Step 3: did not run a#build

✓✓✓✓
A key command reading a file an uncached dependency rewrites answers after it ran
Why this is right

A 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.

inputs/runtime-after-uncached-upstream · correctness
✓
✗

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 again
Why this is right

A key command’s output is bytes, and every byte must count. Decoded as text, \351 and \350 both become the same replacement character, so the key does not change. The second value then replays the first value’s build. Refusing such output is fine; merging two values into one key is not.

inputs/runtime-output-not-utf8 · correctness
✓✓–
✗

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 rerun
Why this is right

Repository git settings are the user’s to choose and must not change what a task runner sees. A tool that parses the text of git status loses changes once colour, short format or quoted paths are on, or once a name holds quotes or a tab. Every edit then looks like no change, and the cache replays old output.

inputs/git-status-config-quoted-name · correctness
✓✓✓✓✓✓
An edit to a committed file inside the task’s own output directory runs it again
Why this is right

A 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.

inputs/committed-file-under-own-output-dir · correctness
✓✓✓
✗

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 appears
Why this is right

A 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.

inputs/sparse-path-materialised · correctness
✓✓✓✓✓✓
Under * text=auto, changing an input from CRLF to LF runs the task again
Why this is right

Under * text=auto, git normalises line endings, so a CRLF file and its LF version are one blob to git. The task reads the bytes on disk, and those differ. A tool that keys on git’s blob id sees no change and replays the CRLF build, here a wrong byte count. Windows checkouts and editors switch endings all the time.

inputs/crlf-to-lf-under-text-attribute · correctness
✓✓
✗

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 again
Why this is right

A root .gitattributes applies to every project, also when a run selects only one. git compares the file after that rule, so CRLF and LF read as one blob while the task reads different bytes. A tool that trusts git here, or reads attributes only from the selected project, replays the build made from the CRLF file.

inputs/crlf-to-lf-under-root-attribute-scoped · correctness
✓✓
✗

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 again
Why this is right

Text 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.

inputs/crlf-to-lf-under-global-attributes · correctness
✓✓
✗

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 again
Why this is right

core.autocrlf makes git convert line endings, so it reports no change when CRLF becomes LF. The task reads the file on disk, whose bytes did change. A tool that trusts git here replays the CRLF build, and whether a developer gets a stale result depends on their own git config.

inputs/crlf-to-lf-under-autocrlf · correctness
✓✓
✗

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 again
Why this is right

With core.trustctime=false git ignores the change time, so a same-size rewrite that keeps the mtime looks clean to git. The task reads new bytes all the same. A tool that trusts git’s clean answer replays the old output. The setting is common where backup or indexing tools touch the change time.

inputs/same-stat-rewrite-trustctime-false · correctness
✓
✗

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 again
Why this is right

With core.checkStat=minimal git compares only size and whole-second mtime. A same-size edit with the same mtime then looks clean to git while the bytes differ. A tool that trusts git’s answer replays the old output. Such edits come from tools that restore timestamps, such as archive extractors and sync tools.

inputs/same-stat-rewrite-checkstat-minimal · correctness
✓
✗

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 version
Why this is right

A 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.

inputs/root-output-into-consumer-project · correctness
✓✓✓✓✓
✗

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 build
Why this is right

File 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.

inputs/non-utf8-name-tracked · correctness
✓
✗

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 build
Why this is right

File 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.

inputs/non-utf8-name-untracked · correctness
✓
✗

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 build
Why this is right

Root 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.

inputs/non-utf8-root-input · correctness
✓
✗

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 task
Why this is right

A 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 b build.

inputs/non-utf8-name-other-project-runs · correctness
✓✓
✗

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 other
Why this is right

U+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.

inputs/replacement-character-name · correctness
✓✓✓✓✓✓
A file dropped by hand into a dependency’s output directory does not rerun its dependants
Why this is right

A 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.

inputs/hand-file-in-dependency-output · efficiency
✓✓✓✓✓✓
A new file under a root input glob runs the task again
Why this is right

A root input glob names a set of files, and adding a file to it is a change. A new file under shared config/ often changes how every task that reads it builds. A tool that keys only the root files it saw before replays the old output without the new config.

inputs/root-input-new-file · correctness
✓✓✓✓✓✓
A task whose inputs cover another task’s output never replays a build of that output’s old bytes
Why this is right

When 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.

inputs/input-glob-over-sibling-task-output · correctness
✓
✗

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 task
Why this is right

Projects 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.

inputs/project-cycle-edit-reruns-only-it · efficiency
✓✓✓
✗

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 run
Why this is right

A 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.

inputs/upstream-deletes-input · correctness
✓✓✓✓✓
✗

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, rerun
Why this is right

A 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.

inputs/linked-input-directories · correctness
✓
✗

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, rerun
Why this is right

Links 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.

inputs/linked-directory-below-glob-prefix · correctness
✓
✗

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 reruns
Why this is right

A brace pattern like {lib,src}/** expands into several directories, and any of them can be a link. A tool that follows links for plain globs but not for brace alternatives misses edits behind a linked one. The run then replays old output for files the task reads.

inputs/linked-brace-alternative · correctness
✓
✗

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 reruns
Why this is right

Naming 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.

inputs/linked-directory-in-ignored-input · correctness
✓
✗

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, and then a new file, behind a linked input directory rerun
Why this is right

Files 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.

inputs/linked-directory-new-file · correctness
✓
✗

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 input made unreadable after an edit fails the task or rebuilds it, never replays
Why this is right

A 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.

inputs/unreadable-input-never-stale · correctness
✓✓✓✓✓✓
Editing an input git was told to assume unchanged runs the task again
Why this is right

--assume-unchanged is a promise to git’s status, used to make it faster, not a statement about the build. The bytes on disk are what the task reads. A tool that trusts the flag and keys the committed blob replays stale output after every local edit to that file.

inputs/assume-unchanged-edit · correctness
✓✓✓✓✓✓
Editing an input git was told to skip in the work tree runs the task again
Why this is right

--skip-worktree tells git to stop looking at a file, often for local config overrides or sparse checkouts. It says nothing about what the build reads. Keying the committed blob instead of the bytes on disk replays output built from content that is not there.

inputs/skip-worktree-edit · correctness
✓✓✓✓✓✓
A same-size edit that keeps the file’s modification time runs the task again
Why this is right

Size and modification time are a hint for when to look again, not the content. Copying with cp -p or touch -r can leave both as they were after a real edit. A tool that trusts them alone replays output built from the old bytes under a green run. The content is what the task reads.

inputs/stat-identical-rewrite · correctness
✓
✗

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 again
Why this is right

ext3, 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.

inputs/whole-second-rewrite · correctness
✓
✗

Nx Step 4: did not run a#build

✓✓✓✓
An input edited while the task runs is not replayed under the key of the bytes before
Why this is right

The 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.

inputs/edited-while-running · correctness
✓
✗

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 (untracked) runs its task again
Why this is right

A 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.

inputs/project-in-untracked-repo · correctness
✓✓✓
✗

moon Step 5: did not run b#build

✓✓
An edit inside a nested repository (untracked) under a project’s input glob runs its task again
Why this is right

An 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.

inputs/untracked-repo-in-project · correctness
✓✓✓
✗

moon Step 5: did not run a#build

✓✓
A file a nested repository tracks despite its own ignore rule is an input
Why this is right

An 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 .gitignore without checking that repository’s index drops the file, and edits to it replay stale output.

inputs/nested-repo-ignored-but-tracked · correctness
✓
✗

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 result
Why this is right

Another 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.

inputs/glob-into-nested-project · correctness
✓
✗

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 result
Why this is right

An 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.

inputs/file-in-nested-project · correctness
✓
✗

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 result
Why this is right

A 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.

inputs/brace-into-nested-project · correctness
✓
✗

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 them
Why this is right

A 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.

inputs/negation-across-two-globs · efficiency
✓✓✓✓✓✓
An input src/* keys the dotfile src/.env
Why this is right

An input glob lists the files a task reads, so src/* covers every file in src, dotfiles included. Shell-style globbing hides dotfiles unless asked, and a .env the task reads would then change no key. Editing it would replay output built from the old values under a green run.

inputs/single-star-dotfile · correctness
✓✓✓✓✓✓
A cached task declaring empty inputs replays after a project file changes
Why this is right

Empty 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.

inputs/empty-inputs-replays · efficiency
✓✓
✗

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 colon
Why this is right

A 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.

inputs/colon-name-by-wildcard · correctness
✓✓✓
✗

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 committed
Why this is right

An 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.

inputs/colon-name-literal · correctness
✓✓✓✓
✗

Vite Task Step 2: exited 1, expected success

✓
A !src/a:b.txt input takes the file with the colon back
Why this is right

A 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.

inputs/colon-name-negated · efficiency
✓✓✓
✗

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 keyed
Why this is right

A file name can hold a backslash or a double quote, and it must be keyed by its real bytes. Listed without -z, git C-quotes such a name, and a tool that reads the quoted form looks up a path that does not exist. The file drops out of the key, so edits to it replay a stale result.

inputs/backslash-name · correctness
✓✓✓✓
✗

Vite Task Step 1: did not run a#build

✓
An input whose name opens with a byte-order mark is keyed, untracked and committed
Why this is right

A 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.

inputs/leading-bom-name · correctness
✓✓✓✓✓✓
A file in a new untracked directory is an input; an ignored install inside it is not
Why this is right

A 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 node_modules install inside the same directory is local state, not source, so it must not key the task and rerun it after every install.

inputs/new-untracked-directory · correctness
✓✓
✗

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 nothing
Why this is right

.git/info/exclude is an ignore rule like .gitignore, only kept out of the committed tree. Ignored files are local state: scratch files, editor temp files, local logs. Keying a task on them reruns it whenever such a file changes, and the same commit gets different keys on different machines.

inputs/git-info-exclude · efficiency
✓✓
✗

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

Named pipes under the input and output globs neither hang the run nor stop a replay
Why this is right

A 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.

inputs/named-pipe-no-hang · correctness
✓
✗

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 holds
Why this is right

When 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.

inputs/file-replaced-by-directory · correctness
✓✓✓✓✓✓
A project nested in foo keeps its files out of foo’s inputs beside a sibling foo-bar
Why this is right

A nested project’s files belong to it alone, so an edit there must not rerun the project around it. Here foo-bar sorts between foo and foo/, and a sorted scan that stops at it never sees foo/inner as nested. An edit in inner then reruns foo too, work nothing asked for.

inputs/nested-project-interloping-sibling · efficiency
✓✓
✗

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 it
Why this is right

Each 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.

inputs/doubly-nested-project · efficiency
✓✓
✗

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 stats
Why this is right

An index entry with no stat data says nothing about the file on disk. git read-tree leaves the index that way, and a tool that reads it as a sign of a clean file keys the committed blob, not the edited bytes. A same-size edit then replays the old output under a green run.

inputs/index-without-stats · correctness
✓✓✓✓✓✓
An edit made before git read-tree wiped the index stats still runs the task again
Why this is right

An edit made before git read-tree is still an edit: the bytes on disk differ from the last build. Reading the tree drops the index’s stat data, so a tool comparing stats against the index cannot tell. It must look at the file itself, or the next run replays output built from the old bytes.

inputs/edit-after-read-tree · correctness
✓✓✓✓✓✓
A gitignored input named outright keys its creation, each edit, and its removal
Why this is right

Naming an ignored file such as .env.local in the inputs asks for it, even though broad globs skip ignored files. Its creation, each edit and its removal all change what the task reads. Skipping any of them replays a result built from other values, such as a bundle pointed at the wrong API.

inputs/gitignored-named-file-lifecycle · correctness
✓
✗

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 gen
Why this is right

A glob whose fixed prefix is an ignored directory asks for that directory by name. Generated sources often live in an ignored gen/, and the build reads them. Skipping its files replays output built from the old generated code after every regeneration.

inputs/gitignored-directory-glob · correctness
✓
✗

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 gen
Why this is right

An input gen/*.js names files in the ignored directory gen, so the matching files are inputs. The ignore rule decides what git tracks, not what the task reads. Leaving the match out replays a stale result after the generator rewrites it. The glob still limits the key to the files it matches.

inputs/gitignored-directory-narrow-glob · correctness
✓
✗

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 match
Why this is right

Naming an ignored directory with a narrow glob asks only for what that glob matches. A JSON file beside the .js match is outside it and stays local state. Keying the whole ignored directory would rerun the task on every unrelated generated file. The narrow glob is how the user says which generated files matter.

inputs/gitignored-directory-narrow-glob-rest · efficiency
✓✓✓✓✓✓
An input naming a generated client under node_modules keys it
Why this is right

A generator such as prisma generate writes its client under node_modules, and a build that compiles against it reads it. Naming that path asks for it even though node_modules is ignored. Skipping it replays output checked against the old client, and the mismatch shows up only at runtime.

inputs/named-path-under-node-modules · correctness
✓
✗

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 it
Why this is right

An entry naming an ignored directory asks for the whole directory; nothing under it is dropped by name. A node_modules inside gen is part of what was asked for. Skipping it by its usual meaning replays a stale result after that code changes. Explicit config beats the usual skip rules.

inputs/node-modules-under-named-ignored-directory · correctness
✓
✗

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 names
Why this is right

A 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.

inputs/brace-of-gitignored-files · correctness
✓
✗

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 directories
Why this is right

{gen,out}/** means gen/** and out/**, and each alternative names an ignored directory outright. Both trees are what the task reads. A tool that treats the brace differently from the two separate globs keys only one of them, and edits to the other replay stale output.

inputs/brace-of-gitignored-directories · correctness
✓
✗

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 git
Why this is right

git lists the .gitkeep, so gen looks tracked, but the files beside it are ignored and still what the task reads. A tool that takes git’s listing of a tracked directory as complete misses them. Each regeneration of gen/client.ts then replays output built from the old client.

inputs/ignored-directory-kept-by-gitkeep · correctness
✓
✗

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 one
Why this is right

One force-added file makes git list part of the ignored out, but the untracked files beside it are still under the glob. A tool that trusts git’s listing for the directory sees only the committed file. Edits to the rest then replay a stale result. The glob, not git’s listing, decides what is an input.

inputs/ignored-directory-with-force-added-file · correctness
✓
✗

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 back
Why this is right

A 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.

inputs/self-rewrite-not-replayed · correctness
✓
✗

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 result
Why this is right

A 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.

inputs/edited-and-reverted-while-running · correctness
✓
✗

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 replayed
Why this is right

Another 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.

inputs/sibling-swaps-input-mid-run · correctness
✓
✗

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 back
Why this is right

The 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.

inputs/tracked-input-edited-by-dependency · correctness
✓
✗

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 back
Why this is right

An 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.

inputs/untracked-input-edited-by-dependency · correctness
✓
✗

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 the command adds under an input glob is not replayed once it is gone
Why this is right

A key must describe what the task read. Here the command created src/new.txt after the key was taken and read it into the output. Stored under that key, the next run replays content from a file that no longer exists. A clean build of the same sources would not produce it.

inputs/added-while-running · correctness
✓
✗

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 gone
Why this is right

The 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.

inputs/added-in-new-directory-while-running · correctness
✓
✗

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 gone
Why this is right

A 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.

inputs/named-input-added-while-running · correctness
✓
✗

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 task writing an ignored file, or one outside its inputs, still replays on the next run
Why this is right

A 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.

inputs/ignored-file-written-by-task-replays · efficiency
✓✓
✗

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 input
Why this is right

Git hooks run with GIT_INDEX_FILE set to the outer repository’s index. A tool that passes it on reads the wrong index when it lists a project inside a nested repository. The force-added file then drops out of the key, and a build started from a hook, such as pre-commit, replays a stale result.

inputs/hook-index-project-in-nested-repo · correctness
✓
✗

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 input
Why this is right

Git hooks can run with GIT_DIR pointing at the outer repository. Passed through to the nested repository’s listing, it makes git describe the wrong repository. A file the nested repository tracks despite its ignore rule then goes unkeyed, and a pre-commit build replays stale output.

inputs/hook-git-dir-project-in-nested-repo · correctness
✓
✗

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 input
Why this is right

A project can vendor a repository of its own under an input glob. Under a hook’s GIT_INDEX_FILE, a tool that lists it with the outer index loses the files that repository force-added. Edits to them replay a stale result only when run from a hook, which makes the bug hard to see.

inputs/hook-index-nested-repo-in-project · correctness
✓
✗

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 input
Why this is right

The variables a hook sets describe the outer repository, not one vendored inside a project. With GIT_DIR passed on, listing the vendored repository reads the outer one instead. A file it force-added despite its ignore rule falls out of the key, and edits to it replay old output.

inputs/hook-git-dir-nested-repo-in-project · correctness
✓
✗

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 name
Why this is right

A brace with one alternative reads as src/b.txt to one glob library and as the literal name src/{b}.txt to another. Whichever file a tool keys, the other can change unseen and replay a stale result. Refusing the spelling, or keying both files, is right; replaying old bytes is not.

inputs/one-alternative-brace-never-stale · correctness
✓
✗

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 it
Why this is right

Root inputs belong to the task that declares them, not to its project. Editing other/thing.json changes an input of y only, so y must rerun and see the new value. A tool that misses the root file, or keys it on the wrong task, replays stale output for y under a green run. Each task’s key folds exactly its own declared inputs.

inputs/root-inputs-per-task · correctness
✓✓✓✓✓✓
Two tasks of one project with different root inputs: editing one’s root file replays the other
Why this is right

Editing a root file that only y reads should not rerun x. A tool that pools root inputs across a project’s tasks makes every edit to any shared file rerun them all. In a large workspace that costs minutes on each config change. Each task depends only on the inputs it declares.

inputs/root-inputs-per-task-other-replays · efficiency
✓✓✓✓
✗

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 version
Why this is right

A task that reads a file its dependency generates must see each new version of that file. Here b reads shared/gen.txt, which a writes from its sources. A tool that keys b only on its own files misses the change upstream and replays stale output after an edit, or after the edit is undone. The dependant’s key must change whenever its dependency’s inputs do.

inputs/root-output-read-by-dependent · correctness
✓
✗

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 run
Why this is right

A 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 config.json just before the reader. Keyed from the file as the run found it, the reader replays the previous seed’s output, so the build ships old config under a green run.

inputs/uncached-writer-ahead-of-reader · correctness
✓
✗

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 bytes
Why this is right

A committed, clean file can still change during a run. Here config.json is clean in git at the start, but an uncached task overwrites it before the reader runs. Keying the reader from git’s committed content instead of the bytes it reads replays the output of a different config, and the run stays green.

inputs/uncached-writer-rewrites-committed-input · correctness
✓
✗

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 bytes
Why this is right

A package.json named in the inputs is an input like any other, even though a tool also reads it as a manifest. Here an uncached task rewrites it before the reader. Keying from the copy read at startup replays output built from an older manifest, so a stale version field ships under a green run.

inputs/uncached-writer-rewrites-manifest · correctness
✓
✗

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 bytes
Why this is right

An 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 lib’s uncached gen rewrites lib/config.json before app reads it. Keying app from the bytes at the start of the run replays output made from a different config.

inputs/uncached-writer-in-dependency-root-input · correctness
✓
✗

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 wrote
Why this is right

Ordering 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.

inputs/uncached-writer-through-quiet-task · correctness
✓
✗

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 bytes
Why this is right

A cached task can write files too, and a reader further down is keyed on what it wrote. Here fmt rewrites config.json, and two tasks that do nothing sit between it and the reader. Keying the reader from the file as the run found it, before fmt ran, replays output built from the previous seed under a green run.

inputs/cached-rewriter-two-quiet-tasks-up · correctness
✓
✗

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 dependant
Why this is right

A task that is not cached still has inputs, and its dependants still see them change. Here ui#bare is uncached and app#test depends on it. Its key comes from its inputs whether it caches or not, and the dependant folds that key, so editing ui reruns app#test. Otherwise the tests replay green against code they never ran on.

inputs/uncached-dependency-source-edit-reruns · correctness
✓✓✓✓
✗

Vite Task Step 3: did not run app#test

✓
Editing a dependency’s sources reruns a cached dependant reached through an uncached task
Why this is right

A chain of dependencies passes through an uncached task without breaking. Here app#test depends on ui#build, which is uncached and depends on ui#source. If the uncached task in the middle cuts the chain of keys, an edit to ui’s sources leaves app#test replaying a result from before the edit, and a failing test goes unseen.

inputs/through-uncached-dependency-source-edit-reruns · correctness
✓✓✓✓
✗

Vite Task Step 3: did not run app#test

✓
Editing a project nothing depends on lets a task with an uncached dependency replay
Why this is right

An 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 app#test for it spends CI time on every change anywhere, and makes the cache useless above any uncached task.

inputs/uncached-dependency-unrelated-edit-replays · efficiency
✓✓✓✓✓
✗

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 files
Why this is right

A project directory can contain characters that are special in globs, such as [ and ]. Joined unescaped with the input patterns, packages/[lib] reads as a class matching one letter, the inputs match nothing, and every edit replays the first build. The project’s own path is taken literally.

inputs/glob-syntax-project-dir · correctness
✓
✗

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 task
Why this is right

Files 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.

inputs/nested-git-worktree-edit · correctness
✓✓✓
✗

moon Step 4: packages/a/dist/o is "11", expected "12"

✓✓
An executable bit still reruns the task when git’s core.fileMode is false
Why this is right

File mode is an input when the command reads it. With git’s core.fileMode off, git reports no mode change, but the command here checks whether the script is executable and writes a different result. A key from git’s view replays the wrong output after a chmod. Each spelling of off is tried because git accepts them all.

inputs/executable-bit-under-filemode-false · correctness
✓
✗

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 no
Why this is right

File mode is an input when the command reads it. With git’s core.fileMode off, git reports no mode change, but the command here checks whether the script is executable and writes a different result. A key from git’s view replays the wrong output after a chmod. Each spelling of off is tried because git accepts them all.

inputs/executable-bit-under-filemode-no · correctness
✓
✗

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 off
Why this is right

File mode is an input when the command reads it. With git’s core.fileMode off, git reports no mode change, but the command here checks whether the script is executable and writes a different result. A key from git’s view replays the wrong output after a chmod. Each spelling of off is tried because git accepts them all.

inputs/executable-bit-under-filemode-off · correctness
✓
✗

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 0
Why this is right

File mode is an input when the command reads it. With git’s core.fileMode off, git reports no mode change, but the command here checks whether the script is executable and writes a different result. A key from git’s view replays the wrong output after a chmod. Each spelling of off is tried because git accepts them all.

inputs/executable-bit-under-filemode-0 · correctness
✓
✗

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 empty
Why this is right

File mode is an input when the command reads it. With git’s core.fileMode off, git reports no mode change, but the command here checks whether the script is executable and writes a different result. A key from git’s view replays the wrong output after a chmod. Each spelling of off is tried because git accepts them all.

inputs/executable-bit-under-filemode-empty · correctness
✓
✗

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 00
Why this is right

File mode is an input when the command reads it. With git’s core.fileMode off, git reports no mode change, but the command here checks whether the script is executable and writes a different result. A key from git’s view replays the wrong output after a chmod. Each spelling of off is tried because git accepts them all.

inputs/executable-bit-under-filemode-00 · correctness
✓
✗

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 again
Why this is right

A 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.

inputs/from-member-dir-upstream-change-reruns · correctness
✓✓✓✓
✗

Vite Task Step 3: did not run app#build

✓
A project’s external dependency changing in the lockfile runs its task again
Why this is right

An 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.

inputs/lockfile-dependency-change-reruns · correctness
✓
✗

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 again
Why this is right

What a project can import through a linked workspace package includes that package’s own dependencies. Here c reads y through b, so a new y reaches c even though c’s manifest never names it. A runner that keys c only on its direct dependencies replays a build made with the old y, and the change ships in b but not in c.

inputs/lockfile-linked-workspace-dependency-change-reruns · correctness
✓
✗

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 task
Why this is right

A lockfile holds every project’s dependencies, but each task uses only its own project’s share. A change to y, which a never installs, cannot change a’s output. A runner that keys every task on the whole lockfile rebuilds every project after any dependency bump, and in a large repo almost no change replays at all.

inputs/lockfile-change-outside-closure-replays · efficiency
✓✓✓✓✓
✗

Nix Step 1: exited 100, expected success

A catalog entry pointed elsewhere runs the task of the project that takes it, and no other
Why this is right

The 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.

inputs/catalog-change-reruns-its-readers · correctness
✓
✗

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 task
Why this is right

An 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 node_modules, stays the same. A runner that reruns after each install throws away the cache on every CI job, since most jobs install first.

inputs/reinstall-without-change-replays · efficiency
✓✓✓✓✓
✗

Nix Step 1: exited 100, expected success

Swapping which packages two dependency names install runs the task again
Why this is right

The same packages are installed, but the name foo now resolves to the other one. A key that collects only the set of installed versions sees no change, though every import of foo now loads different code. The runner then replays output built with the old mapping. Each dependency name and what it resolves to must count together.

inputs/lockfile-alias-swap-reruns · correctness
✓
✗

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 them
Why this is right

Packages the root installs sit where every project resolves them, so a project can read them without naming them. Swapping which package the root’s foo and bar install changes what such a project loads. If the root’s dependencies are left out of the key, the task replays output built with the old packages under a green run.

inputs/lockfile-root-alias-swap-reruns · correctness
✓
✗

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 it
Why this is right

A file: dependency is a local directory, and the lockfile names only its path. So when a file inside it changes, the lockfile stays the same while the installed code does not. A runner that keys on the lockfile alone replays the old build. The directory’s content is the dependency’s version and must be part of the key.

inputs/file-dir-dependency-edit-reruns · correctness
✓
✗

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 it
Why this is right

A local directory dependency belongs to the projects that install it, not to the whole workspace. other does not depend on it, so an edit there cannot change other’s output. Treating the edit as a change for every project reruns unrelated builds each time the vendored code moves, which wastes CI time with nothing gained.

inputs/file-dir-dependency-edit-spares-others · efficiency
✓✓✓✓✓
✗

Nix Step 1: exited 100, expected success

A file added to a local directory dependency runs the task of the project depending on it
Why this is right

A 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.

inputs/file-dir-dependency-new-file-reruns · correctness
✓
✗

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 it
Why this is right

A 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.

inputs/file-dir-dependency-linked-file-reruns · correctness
✓
✗

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 it
Why this is right

An install copies a local directory dependency without its node_modules, so files there never reach the project. A change inside it changes nothing the task reads. A runner that hashes it reruns the task whenever the dependency’s own tools install or update, which can be on every run.

inputs/file-dir-dependency-own-node-modules-ignored · efficiency
✓✓✓✓✓
✗

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 it
Why this is right

A local dependency kept in another project’s directory is still the dependency of the project that installs it. When its content changes, lib’s installed copy changes, and lib’s build must run again. A runner that counts the files only for the project holding them replays lib’s old build.

inputs/file-dep-inside-other-project-reruns · correctness
✓
✗

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 dependency change of a workspace nested in another’s directory runs the nested project’s task again
Why this is right

A workspace package can sit inside another project’s directory, and it keeps its own dependencies. When n’s dependency changes, n’s installed code changes, and its task must run again. A runner that finds a project’s dependencies by directory may give them to the outer project, and replay n’s old build.

inputs/nested-workspace-dependency-change-reruns · correctness
✓
✗

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 task
Why this is right

A 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.

inputs/nested-workspace-dependency-change-spares-outer · efficiency
✓✓✓✓✓
✗

Nix Step 1: exited 100, expected success

An override that forces a different transitive dependency runs the task of the project reaching it
Why this is right

An override in the root manifest can swap a package deep in a project’s dependency tree. Here b reaches z through yy, and the override replaces it. The project’s own manifest is unchanged, so a runner that keys only on direct dependencies replays b’s build with the old z. The resolved tree is what the task loads.

inputs/override-reruns-reaching-project · correctness
✓
✗

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 package
Why this is right

An override changes only the projects whose dependency tree contains the package it forces. a never reaches z, so its installed code is the same as before. A runner that treats any change to the root manifest as a change for every project rebuilds the whole workspace for a fix that touches one tree.

inputs/override-spares-unreached-project · efficiency
✓✓✓✓✓
✗

Nix Step 1: exited 100, expected success

Respelling an override so it resolves the same package replays every task
Why this is right

What a task loads is the package an override resolves to, not how the override is written. file:./vendor/z2 and file:vendor/z2 install the same directory. A runner that keys on the manifest text reruns every task after a cosmetic edit. Keying on the resolution keeps the cache, and still sees a real change.

inputs/override-respelled-same-resolution-replays · efficiency
✓✓✓✓✓
✗

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 again
Why this is right

A package the root depends on is reachable by every project, so a project that reads it depends on it even without naming it. When lib’s source changes, app’s output changes too. A runner that leaves the root’s workspace dependencies out of app’s key replays app’s old build.

inputs/root-workspace-dependency-change-reruns · correctness
✓
✗

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 it
Why this is right

The 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.

inputs/outside-file-dependency-edit-reruns · correctness
✓
✗

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 it
Why this is right

Only 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.

inputs/outside-file-dependency-edit-spares-others · efficiency
✓✓✓✓✓
✗

Nix Step 1: exited 100, expected success

Pointing a file: dependency at another directory beside the repository runs the task again
Why this is right

Pointing 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.

inputs/outside-file-dependency-retarget-reruns · correctness
✓
✗

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

Pointing a dependency name at another package, the import name unchanged, runs the task again
Why this is right

The 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.

inputs/alias-retarget-reruns · correctness
✓
✗

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

With * text=auto in .gitattributes, a same-size edit to a CRLF file runs the task again
Why this is right

Under * text=auto git may store a CRLF file with LF, but the task reads the CRLF bytes on disk, so those bytes are the input. An edit that keeps the size, with a future mtime, can look clean to git. A runner that trusts git’s stored blob replays the old build, though the file the task reads has changed.

inputs/crlf-text-auto · correctness
✓✓✓✓✓✓
With * text in .gitattributes, a same-size edit to a CRLF file runs the task again
Why this is right

Under * text git treats every file as text and stores it with LF, while the CRLF bytes stay on disk. The task reads the disk, so those bytes are the input. A runner that hashes git’s normalised form, or trusts a stat that did not move, misses a same-size edit and replays output built from the old file.

inputs/crlf-text · correctness
✓✓✓✓✓✓
With *.txt text eol=crlf in .gitattributes, a same-size edit to a CRLF file runs the task again
Why this is right

With eol=crlf, git writes CRLF on checkout but stores LF, so what is on disk and what git holds differ by design. The task reads the disk, so those bytes are the input. A same-size edit that git’s stat does not catch must still run the task again, or the runner replays output built from the old text.

inputs/crlf-eol-crlf · correctness
✓✓✓✓✓✓
Under a project’s own *.md text rule, a same-size edit to a CRLF file runs the task again
Why this is right

A project can set its own line-ending rules in its own .gitattributes, and they apply to its files as the root’s do. Under *.md text git may store a CRLF file with LF, yet the task reads the CRLF bytes on disk. A runner that trusts git’s stored form, or reads only the root’s rules, can miss a same-size edit and replay the old build.

inputs/project-gitattributes-crlf · correctness
✓✓✓✓✓✓
Under * text=auto, rewriting a file holding a lone CR from LF to CRLF runs the task again
Why this is right

Whether 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.

inputs/text-auto-crlf-rewrite-lone-cr · correctness
✓✓✓✓✓✓
Under * text=auto, rewriting a file holding a NUL past its first 8000 bytes from LF to CRLF runs the task again
Why this is right

Git 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.

inputs/text-auto-crlf-rewrite-late-nul · correctness
✓✓✓✓✓✓
Under * text=auto, rewriting a file holding a control byte from LF to CRLF runs the task again
Why this is right

A control byte does not make git call a file binary, so under * text=auto it is normalised like text. Rewritten from LF to CRLF, the bytes on disk change while git may store the same blob. A runner that trusts git’s stored form replays output built from the LF file, and the task’s real input never counts.

inputs/text-auto-crlf-rewrite-control-byte · correctness
✓✓✓✓✓✓
Under * text=auto, rewriting a file holding plain text from LF to CRLF runs the task again
Why this is right

Under * text=auto, git stores plain text with LF whatever is on disk. A rewrite from LF to CRLF then leaves git’s blob the same while every line the task reads changes. A runner that keys on that blob replays the old output, and a tool that cares about line endings, such as a shell script or a snapshot test, sees the wrong file.

inputs/text-auto-crlf-rewrite-plain · correctness
✓✓
✗

Turborepo Step 4: did not run a#build

✓✓✓
Under * text=auto, dropping the CR from a binary file’s CRLF runs the task again
Why this is right

Under * text=auto git decides by content whether a file is text, and a file holding NUL bytes is binary. Dropping a CR from it changes the bytes the task reads, even though a text-mode view of it might not. A runner that normalises line endings before hashing would see no change and replay output built from the old file.

inputs/text-auto-binary-crlf-to-lf · correctness
✓✓✓✓✓✓
An edit to a committed input with a non-ASCII name runs the task again
Why this is right

A 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.

inputs/non-ascii-tracked-name · correctness
✓✓✓✓✓✓
An input src/*:bar.txt keys foo:bar.txt and foo::bar.txt: a : is part of a name
Why this is right

In a glob, a : is an ordinary character of a file name, not syntax. A runner that reads src/*:bar.txt as something else, such as a prefix or a path list, matches no file, and the task is keyed on nothing. Edits to foo:bar.txt then replay the old output. Both foo:bar.txt and foo::bar.txt match and must count.

inputs/colon-in-glob-is-a-name · correctness
✓✓✓
✗

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 name
Why this is right

A , means something in a glob only inside braces; elsewhere it is part of the name. A runner that splits src/a,b.ts into two patterns keys on src/a and b.ts, neither of which exists, so the file named a,b.ts is no input. Editing it then replays the old output, and the build ships stale code.

inputs/comma-in-literal-is-a-name · correctness
✓✓
✗

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 them
Why this is right

Paths with @scope, a+b and (marketing) are common: package scopes, and route groups in web frameworks. A glob engine that reads +, @ or ( ) as pattern syntax matches nothing under them. The task is then keyed without its pages or modules, and an edit there replays the old build. These characters must match themselves.

inputs/scope-plus-group-names · correctness
✓
✗

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 edited
Why this is right

File names may hold $, quotes, spaces or tabs, and route files such as $id.tsx are common. If the runner passes names through a shell, or splits a list on white space, such a file is mangled or lost. Its edits then replay the old output. Names must be handled as bytes from the file system to the key, committed or not.

inputs/shell-special-names · correctness
✓✓✓✓✓✓
A file or directory whose name holds a newline is an input, untracked or committed
Why this is right

A 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.

inputs/newline-in-names · correctness
✓✓✓
✗

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 unkeyed
Why this is right

An unclosed [ opens no character class, so src/[invalid is the name of a file. A runner may key that file, or refuse the glob as malformed; both tell the truth. What it must not do is match nothing and run with the input silently left out, since an edit to the file would then replay the old build.

inputs/unclosed-bracket-literal · correctness
✓✓✓✓✓✓
Only a file named .gitignore holds ignore rules: foo.gitignore and .ignore do not
Why this is right

Git reads ignore rules only from files named .gitignore, plus .git/info/exclude and the user’s global file. A file named foo.gitignore or .ignore is just a file. A runner that treats them as rules drops inputs git would track, and edits to those files replay the old output. The ignore set must match git’s.

inputs/only-gitignore-holds-rules · correctness
✓✓✓✓✓✓
An uncommitted .gitignore edit that stops ignoring a file, then ignores it again: an edit it ignores replays the task
Why this is right

Ignore rules decide what is local state, and the rules in force are the ones on disk now, committed or not. While gen.txt is ignored, editing it changes nothing the key holds, so the task replays. A runner that reads ignore rules from the last commit keys a file the developer has just ignored, and reruns after every regeneration.

inputs/gitignore-edit-ignored · efficiency
✓✓
✗

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 again
Why this is right

Once an uncommitted .gitignore edit stops ignoring gen.txt, the file is an input like any other, and its edits must run the task again. A runner that reads only committed rules still ignores it. It then replays output built from an old gen.txt until someone commits the .gitignore change.

inputs/gitignore-edit-keyed · correctness
✓✓✓✓✓✓
A * .gitignore that whitelists one file: an edit it ignores replays the task
Why this is right

A .gitignore of * with !keep.txt is a common way to keep one file in a cache or build folder and ignore the rest. Everything else there is local state, so an edit to junk.txt changes no input and the task replays. A runner that keys the whole folder reruns each time a tool writes its cache.

inputs/self-ignoring-whitelist-ignored · efficiency
✓✓
✗

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 again
Why this is right

A * .gitignore with !keep.txt ignores everything in its folder except keep.txt, which stays an input. Its edits must run the task again. A runner that sees the * and drops the whole directory loses the one file that was meant to count, and replays output built from the old keep.txt.

inputs/self-ignoring-whitelist-keyed · correctness
✓✓✓✓✓✓
A project’s !important.log under the root’s *.log: an edit it ignores replays the task
Why this is right

A project’s own .gitignore can re-include a file the root ignores, since git applies the deeper rule last. Other .log files stay ignored as the root says, so an edit to other.log replays the task. A runner that applies the negation too widely keys every log file and reruns after each log write.

inputs/project-negation-over-root-rule-ignored · efficiency
✓✓
✗

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 again
Why this is right

Git applies the deeper .gitignore last, so a project’s !important.log re-includes a file the root’s *.log ignores. That file is an input, and an edit to it must run the task again. A runner that reads only the root’s rules drops it from the key and replays output built from the old file.

inputs/project-negation-over-root-rule-keyed · correctness
✓✓✓✓✓✓
A root .gitignore with *.log and !keep.log: an edit it ignores replays the task
Why this is right

A root .gitignore with *.log and !keep.log ignores every log but one. A new debug.log is local noise, so writing it changes no input and the task replays. A runner that does not honour *.log keys every debug log, and a cached task reruns each time a tool writes one.

inputs/root-negation-ignored · efficiency
✓✓
✗

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 again
Why this is right

In a root .gitignore with *.log and !keep.log, the later negation wins for keep.log. That file is meant to count, so writing it must run the task again. A runner that applies *.log and skips the negation leaves keep.log out of the key and replays output built without it.

inputs/root-negation-keyed · correctness
✓✓✓✓✓✓
A root rule two directories into a project, under an input **: an edit it ignores replays the task
Why this is right

A root rule can name a path deep inside a project, such as packages/a/src/deep/*.tmp. Git applies it there, so x.tmp is local state even under an input of **. A runner that applies root rules only at the top of each project, or not inside it, keys the temp file and reruns the task each time it changes.

inputs/root-rule-deep-in-project-ignored · efficiency
✓✓
✗

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 again
Why this is right

A root rule like packages/a/src/deep/*.tmp ignores only what it names. A new y.txt in the same directory is an input of the ** glob, so it must run the task again. A runner that treats the whole directory as ignored, because one rule points into it, replays output that misses the new file.

inputs/root-rule-deep-in-project-keyed · correctness
✓✓✓✓✓✓
A new directory’s own .gitignore: an edit it ignores replays the task
Why this is right

A new, untracked directory can bring its own .gitignore, and git applies it at once. A file it ignores, such as secret.txt, is local state and no input, so the task replays. A runner that reads ignore files only in tracked directories keys the secret, reruns on each change to it, and may store it.

inputs/new-dir-own-gitignore-ignored · efficiency
✓✓
✗

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 again
Why this is right

A new directory’s .gitignore ignores only what it names. open.txt beside it is a new input of src/**, so the task must run again. A runner that treats an untracked directory holding a .gitignore as wholly ignored replays output that misses the new file, and nothing warns.

inputs/new-dir-own-gitignore-keyed · correctness
✓✓✓✓✓✓
A nested .gitignore, which ignores only below it: an edit it ignores replays the task
Why this is right

A .gitignore applies to its own directory and below, so src/a/.gitignore ignores src/a/x.txt. That file is local state, and so is an empty directory, since git tracks no directories. Neither changes an input, so the task replays. A runner that keys empty directories, or misses the nested rule, reruns for nothing.

inputs/nested-gitignore-scope-ignored · efficiency
✓✓
✗

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 again
Why this is right

A nested .gitignore ignores only below its own directory. src/b/x.txt has the same name as the file src/a/.gitignore ignores, but lies outside its scope, so it is an input. A runner that applies the rule by name across the project drops it from the key and replays output that misses the file.

inputs/nested-gitignore-scope-keyed · correctness
✓✓✓✓✓✓
A FIFO under an input glob does not hang the run
Why this is right

A 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.

inputs/fifo-under-glob · correctness
✓✓✓✓✓
✗

Nix Step 3: exited 1, expected success

A file added with git add -N keys its bytes: an edit to it runs the task again
Why this is right

git add -N records a path in the index with the empty blob, before its content is staged. That blob says nothing about the bytes on disk. A runner that trusts the index for the file keys every version of it as empty, so edits replay the old build. Such a file must be hashed from disk, like an untracked one.

inputs/intent-to-add · correctness
✓✓✓✓✓✓
A task keyed on a root file and no project file runs again when the root file changes
Why this is right

A 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.

inputs/root-input-alone-keys · correctness
✓✓✓✓✓
✗

Nix Step 1: exited 100, expected success

A task ordered after a dependency, but not keyed on it, replays when only the dependency changes
Why this is right

The 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.

inputs/dependency-inputs-off-replays · efficiency
✓✓–✓✓–
A negation with a trailing slash, !src/gen/, takes that directory’s tree out of the key
Why this is right

A trailing slash only says the path is a directory: !src/gen/ takes back every file under it, as src/ keys every file under src. Read as a bare pattern, it matches nothing and the generated files stay in the key. Each regeneration then reruns a task the user excluded them from, while edits outside it must still rerun.

inputs/negated-trailing-slash-directory · efficiency
✓✓✓
✗

moon Step 3: ran a#build, expected it replayed or left out

✓✓
A !**/*.bak input still keys a file named plain bak
Why this is right

An exclusion by suffix means files ending in .bak, not every name holding those letters. A file named plain bak, with no dot, is a source like any other, and the user never took it back. A tool that matches the suffix loosely replays a stale result when that file changes, under a green run.

inputs/negated-suffix-keeps-bare-name · correctness
✓✓✓✓✓✓
A key command that leaves a background child running does not hold up the run
Why this is right

A 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.

inputs/runtime-command-child-does-not-hold-the-run · correctness
✓
✗

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 again
Why this is right

A directory name is just a name, and :top is a legal one. Git reads a leading : in a path argument as special syntax, so a tool that passes the name to git unquoted lists the wrong files or none. The edit then falls outside the key and the stale result is replayed.

inputs/project-directory-starting-with-colon · correctness
✓✓
✗

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 it
Why this is right

A * in a directory name is a literal character. A tool that passes the name to git as a pattern matches big too, so an edit in big changes the key of *g and runs it for nothing. The project’s files are the ones under its own directory, whatever its name looks like.

inputs/project-directory-with-glob-character · efficiency
✓✓
✗

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 directories
Why this is right

Next.js routes live in folders like app/[id], so a glob must escape the brackets to name the folder. {src,app/\[id\]} names two directories, each a tree of inputs. A tool that stops expanding the brace once it sees an escape matches neither, so an edit to the route replays a stale build.

inputs/brace-holding-escaped-brackets · correctness
✓
✗

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 cases
100%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.

CasevxNxTurborepomoonVite TaskNix
A PATH=… prefix on a command decides where that command’s program is found
Why this is right

In sh, PATH=dir tool looks tool up in dir: the assignment holds for that one command, lookup included. A runner that resolves the program itself, with the PATH it was started with, finds no tool or a different one. The task then fails, or runs a program the command never named, though the same line works in a terminal.

env/command-prefix-path-finds-command · correctness
✓✓✓✓✓✓
Listing the same declared variables in another order still replays
Why this is right

Declared 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.

env/declared-order-irrelevant · efficiency
✓✓✓✓✓✓
A declared variable reaches the command
Why this is right

Declaring 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.

env/declared-reaches-task · correctness
✓✓✓✓✓✓
Changing a declared variable runs the task again
Why this is right

A 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 MODE=prod ships a development build to production with nothing in the run to say so.

env/declared-change-reruns · correctness
✓✓✓✓✓✓
Going back to an earlier value restores that value’s outputs
Why this is right

A 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.

env/back-to-old-value · correctness
✓✓✓✓✓✓
An unset variable and an empty one are different values
Why this is right

A command can tell an unset variable from an empty one, and many scripts do, for example with ${VAR-default}. If the key treats both the same, a run with an empty value replays output made with the default. The tree then holds a result the current environment would not produce.

env/unset-vs-empty · correctness
✓
✗

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 variables
Why this is right

Environment variable names are case-sensitive on Linux and macOS, so MODE and mode are separate values. A key that folds case can take the wrong one, or miss a change to one because the other stayed the same. Either way the output can come from a value the command never saw.

env/name-case-sensitive · correctness
✓✓✓✓✓✓
A variable the task does not declare changes nothing
Why this is right

Only 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.

env/undeclared-change-ignored · efficiency
✓✓✓✓✓✓
A .env file at the workspace root does not set a task’s variable
Why this is right

A task’s environment is the one the shell that started the run gave it. A runner that also reads a .env at the root sets values nobody passed, from a file the task never declared. The same command then builds one thing on a laptop that has the file and another on CI that lacks it, and a local secret can end up in a build.

env/dotenv-not-loaded · correctness
✓
✗

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 variable
Why this is right

Files such as .env.local and .env.production are a framework’s convention, not the runner’s. If the runner picks one by NODE_ENV, setting one variable silently swaps in a whole file of others. A developer’s local overrides then leak into builds that differ from CI for reasons no config shows. A task that wants these files can load them itself.

env/dotenv-local-and-node-env-not-loaded · correctness
✓
✗

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 variable
Why this is right

Where a run starts must not change what a task sees. If a .env in the current directory were read, the same task run from the root and from inside the project would build two different things. Worse, each could replay the other’s cache entry, since nothing declared says the file was read. The shell’s environment is the only source.

env/dotenv-in-cwd-not-loaded · correctness
✓
✗

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 sees
Why this is right

A value set in the shell is an explicit request for this run; a file on disk is not. If a .env overrode it, MODE=from-shell on the command line would be silently ignored. CI jobs that set variables per job would build with whatever a committed or leftover .env says. The explicit value must win, whether or not the runner reads .env at all.

env/shell-value-beats-dotenv · correctness
✓✓✓✓✓✓
Editing a .env never replays a result built from its old value
Why this is right

Whatever a runner does with .env, it must not replay a result built from a value that has since changed. A runner that loads .env into a declared variable has made the file an input, so its key must change when the file does. Otherwise an edit to .env replays output built for the old value. A runner that never loads the file passes too.

env/dotenv-edit-not-stale · correctness
✓✓✓✓✓✓
A bin a project’s dependency ships is on that project’s task PATH
Why this is right

A 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 node_modules/.bin, and the task’s PATH must include it. Without it the command fails with command not found, or runs a global copy of the tool at a version the lockfile never pinned.

env/dependency-bin-on-path · correctness
✓✓✓
✗

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 command
Why this is right

The task’s PATH decides what the names in its command resolve to, not which shell parses the command. If a dependency’s bin named sh became the shell, any package could take over every command of the projects that depend on it. The real command would not run, yet the task could still exit 0. The shell is the runner’s choice, not a PATH lookup.

env/dependency-sh-not-the-shell · correctness
✓✓✓✓✓✓
A project under a directory holding : does not run a file the split PATH entry names relative to it
Why this is right

A directory name is data and must not change how PATH is read. Joined into PATH, packages/x:y/node_modules/.bin splits in two at the :, and the second half is relative, so it resolves against the task’s directory. A file planted at y/node_modules/.bin would then run in place of a real tool. A runner must leave out an entry PATH cannot express.

env/path-delimiter-dir-runs-no-relative-bin · correctness
✓
✗

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 rewritten
Why this is right

Forwarded 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 --fooBar, and casts 0010 to 10. The tool then gets a different request from the one typed, and fails or behaves differently with no visible cause.

env/args-flags-verbatim · correctness
✓✓✓✓✓✓
An empty arg, spaces, a newline, quotes and $HOME each reach the command as one arg, unexpanded
Why this is right

Each 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, sp ace becomes two args and $HOME expands to a path. A test filter or message passed through the runner then means something else, and text such as && can even start a second command.

env/args-shell-text-verbatim · correctness
✓
✗

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 command
Why this is right

Everything after the first -- belongs to the task, and the runner reads none of it. A runner that still acts on --filter, --all or --base there changes which tasks run, while the tool that should get them never does. The command looks right and does something else. An inner -- must reach the command too, since many tools give it a meaning.

env/args-runner-flags-verbatim · correctness
✓✓✓✓✓✓
Forwarded args reach the requested task, not its dependency of the same task name
Why this is right

Args are given for the task that was asked for; a dependency was not asked for, only pulled in. If --x also reached b#show, a flag meant for one project, such as a watch or update-snapshots flag, would change every dependency sharing the task name. Those tasks could fail on an option they do not accept, or write files nobody asked for.

env/args-skip-same-named-dependency · correctness
✓✓
✗

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 unset
Why this is right

A 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 MODE=MODE and no MODE look the same. The second run then replays unset where the command should print MODE. Name and value must be encoded so that no value can stand in for absence.

env/value-equal-to-name-vs-unset · correctness
✓✓✓✓✓✓
Moving a value from one declared variable to another, or swapping two, runs the task again
Why this is right

A variable’s value means something only with its name. A key that folds values without their names cannot tell A=1 from B=1, or A=1 B=2 from A=2 B=1. The runner then replays a build made for one environment in the other, such as a staging URL in a production build. Each name and value must be keyed as a pair.

env/value-moved-between-names · correctness
✓✓✓✓✓✓
A declared value holding =, newlines, a tab and non-ASCII reaches the task byte for byte
Why this is right

A declared variable must reach the task exactly as the shell set it. Values with =, newlines, tabs or non-ASCII text are common: keys, certificates, multi-line settings. A runner that splits on = or a newline, or re-encodes the value, hands the task a different value from the one that is keyed, and the build is wrong under a matching key.

env/value-reaches-task-byte-exact · correctness
✓✓✓✓✓✓
A PATH with one more directory, not declared, replays the task
Why this is right

PATH 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.

env/path-change-replays · efficiency
✓✓✓✓✓
✗

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 children
Why this is right

TMPDIR, 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.

env/temp-dirs-reach-task · correctness
✓✓
✗

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 either
Why this is right

A 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 sh became the shell, a package could take over the server, and the tests run against it would test something else.

env/server-shell-not-dependency-sh · correctness
✓✓
✗

Turborepo Step 2: exited 1, expected success

✗

moon Step 2: did not finish within 60 s

––
A variable passed through reaches the task
Why this is right

A 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.

env/pass-through-reaches-task · correctness
✓✓✓✓✓–
A change to a variable passed through, not keyed, replays the task
Why this is right

The 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.

env/pass-through-change-replays · efficiency
✓✓✓✓✓–
Switching a declared variable back and forth replays each value’s own result
Why this is right

Each 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.

env/each-value-keeps-its-entry · efficiency
✓✓✓✓✓✓
A task runs by name a program the task it depends on built
Why this is right

One 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.

env/dependency-program-on-path · correctness
–––––✓
A task whose two dependencies ship a program of one name fails instead of picking one
Why this is right

When 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.

env/two-dependencies-one-program-refused · correctness
–––––
✗

Nix Step 1: succeeded, expected a failure

An IFS in the runner’s environment does not change how a task’s shell splits words
Why this is right

A task command is a shell command, and a fresh sh sets IFS to space, tab and newline whatever its environment says. A tool that hands the command to a shell that kept an inherited IFS splits words differently. The same command under the same key then prints different output depending on who started the run.

env/inherited-ifs-does-not-split-words · correctness
✓✓✓✓✓✓
A declared variable named __proto__ reaches the task and keys it
Why this is right

__proto__ is a legal variable name. A tool that builds the task’s environment as a plain JavaScript object sets the object’s prototype instead of a variable, so the task never sees it while the key still counts it. The task then runs without a value the key says it had.

env/variable-named-proto-reaches-task · correctness
✓✓✓
✗

moon Step 2: did not run a#build

✓✓

Outputs

224 cases
100%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.

CasevxNxTurborepomoonVite TaskNix
Outputs written ./dist/** are stored and restored
Why this is right

Output globs are project-relative paths, and ./dist/** names the same files as dist/**. If the ./ makes the glob match nothing, the cache stores an empty entry. A later replay then counts as a hit and restores nothing, leaving dist missing for the next step.

outputs/dot-slash-output · efficiency
✓✓✓✓✓✓
An output naming a directory, dist, stores and restores its whole tree
Why this is right

An output naming a directory, dist, means everything the task wrote there. A tool that stores only the directory entry restores an empty dist on a replay. The next step then finds no build output, though the run reported a cache hit. A hit that restores nothing is worse than a miss, because nothing looks wrong.

outputs/literal-directory-output · correctness
✓✓✓✓
✗

Vite Task Step 3: packages/a/dist/a.txt is missing, expected "a\n"

✓
A dotfile under an output glob is restored with the rest
Why this is right

An 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.

outputs/dotfile-output · correctness
✓✓✓✓✓✓
An output app/\[id\]/page.js restores the route and leaves the sibling app/i alone
Why this is right

An escaped output app/\[id\]/page.js names the route file literally. Read as a class, it matches app/i/page.js instead, so a restore can overwrite or delete that sibling and miss the route. A replay must bring back the route and leave unrelated files alone.

outputs/escaped-route-output · correctness
✓✓✓
✗

moon Step 1: exited 1, expected success

✓✓
An output at the workspace root is restored there on a replay
Why this is right

An 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.

outputs/root-output-restored · correctness
✓✓✓✓✓✓
An outer project’s **/*.js output never restores a nested project’s file over its newer build
Why this is right

An output glob such as **/*.js covers the project’s own files, not those of a project nested inside it. If the outer entry stores the nested project’s built file, replaying the outer task writes those old bytes over the nested project’s newer build. The tree then holds a stale file from a task that did not make it.

outputs/outer-output-skips-nested-project · correctness
✓✓✓✓
✗

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 there
Why this is right

A task owns only the files in its project, and an output glob that climbs out with .. must not give it more. Tools clear outputs before a run and before a restore. Following .. there deletes another directory’s files, which may be source or another team’s work. Ignoring or refusing such a glob is safe; deleting is not.

outputs/parent-relative-output-deletes-nothing · correctness
✓✓✓✓✓✓
An output glob into a sibling whose name extends the project’s, ../a-extra/**, deletes nothing
Why this is right

A sibling whose name starts with the project’s, like a-extra beside a, is still another directory. A boundary check that compares paths as text prefixes thinks packages/a-extra lies inside packages/a. The clean before a run or a restore then deletes files the task does not own.

outputs/sibling-prefix-output-deletes-nothing · correctness
✓✓✓✓✓✓
An output directory that is a link out of the project never has the link’s target emptied
Why this is right

Clearing an output directory must not follow a link out of the project. If dist points elsewhere, clearing dist/** through the link deletes another directory’s files, which may be the only copy of something. Refusing the run is acceptable; emptying the link’s target is not.

outputs/output-dir-linked-out-of-project · correctness
✓✓✓✓✓✓
A link a task leaves in its output directory is cleared, and the file it points at survives
Why this is right

A 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.

outputs/linked-file-in-output-dir · correctness
✓✓✓
✗

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 restored
Why this is right

A 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.

outputs/backslash-directory-name · correctness
✓✓✓✓
✗

Vite Task Step 1: exited 1, expected success

✓
A file under an output directory that no output pattern matches survives a rebuild and a replay
Why this is right

dist/**/*.js names the build’s files; other files under dist are not the task’s to delete. A user may keep notes, fixtures or another tool’s files there. Clearing the whole directory before a run or a replay deletes them, and nothing brings them back.

outputs/unmatched-file-in-output-dir-kept · correctness
✓✓✓✓✓✓
An output naming a directory with a trailing slash, dist/, stores and restores its tree
Why this is right

Writing a directory as dist/ is a common way to say the whole tree. A tool that reads it as a pattern matching nothing stores an empty entry. The next replay then restores nothing, so the task has to run again for work that was already done, or the tree is left without its build.

outputs/literal-directory-trailing-slash-output · efficiency
✓✓✓✓✓✓
A root output naming a directory, generated, stores and restores its tree
Why this is right

A root output that names a directory, like generated, means everything under it. If the tool stores only the directory, or nothing, a later run cannot restore the files. The task then runs again for work already done, or a replay reports success while generated stays empty.

outputs/root-output-directory-literal · efficiency
✓✓✓✓
✗

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 replay
Why this is right

A replay writes back what the task produced, not the committed state of every file under its output glob. Here out/LICENSE is committed source the task never writes. If the entry holds a copy of it, a replay puts the old LICENSE back over the one just committed, and the user’s change is silently undone.

outputs/committed-output-outside-inputs · correctness
✓
✗

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 replay
Why this is right

The 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.

outputs/committed-output-mode-change-kept · correctness
✓
✗

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 replay
Why this is right

Git 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.

outputs/force-added-ignored-output-kept · correctness
✓
✗

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 back
Why this is right

A 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.

outputs/codegen-rewrites-committed-output · correctness
✓✓✓
✗

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 it
Why this is right

A 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.

outputs/committed-root-output-survives · correctness
✓✓✓✓✓
✗

Nix Step 3: gen/b.ts is missing, expected "b"

Renaming an input and back leaves only the outputs that name produces
Why this is right

Renaming 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 a.js and b.js side by side, and a bundle or test picks up a file with no source.

outputs/rename-input-back-restores-own-outputs · correctness
✓
✗

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 rebuilt
Why this is right

Outputs 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.

outputs/non-utf8-output-name · correctness
✓
✗

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 replay
Why this is right

A 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.

outputs/stray-under-empty-output-wiped · correctness
✓
✗

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 replay
Why this is right

A 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.

outputs/stray-under-empty-root-output-wiped · correctness
✓
✗

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 index
Why this is right

A 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.

outputs/committed-file-kept-when-git-fails · correctness
✓✓✓✓✓
✗

Nix Step 2: packages/a/out/keep.txt is missing, expected "keep\n"

An output **/*.js never deletes or captures a nested project’s built files
Why this is right

Project boundaries are hard: a nested project’s build output belongs to that project. An outer output glob like **/*.js must not capture it into the outer task’s cache entry or clear it before a run. Otherwise building the outer project deletes or overwrites files the inner project made, local edits included.

outputs/wide-glob-spares-nested-project · correctness
✓
✗

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 intact
Why this is right

Output 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.

outputs/repeated-output-glob · correctness
✓✓✓✓✓✓
A replayed app/** leaves the route app/[id] a later task adds, and drops a stray beside it
Why this is right

In an output glob, app/\[id\] is the literal directory [id], as file-system routes are often named. Read as a character class, it sets aside app/i instead. A replay then keeps the stray file and deletes the real route a later task added, and the app ships without that page.

outputs/dependency-tree-keeps-route-added-by-dependant · correctness
✓
✗

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 bytes
Why this is right

A 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 dist is empty, and the next step, a deploy or a test, reads nothing or fails far from the cause. The bytes must match exactly, or a hit ships something no build produced.

outputs/restored-on-hit · correctness
✓✓✓✓✓✓
Deleted outputs are restored, not rebuilt
Why this is right

Deleted 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.

outputs/restored-on-hit-without-rerun · efficiency
✓✓✓✓✓✓
An output edited by hand is put back on the next run
Why this is right

The 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.

outputs/edited-output-restored · correctness
✓✓✓
✗

moon Step 3: packages/a/dist/out.txt is "tampered\n", expected "a\n"

✓✓
A rebuild leaves no output the new inputs no longer produce
Why this is right

Outputs 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 dist forever. Such leftovers are easy to miss: tests may even pass because they still import the old file. Clearing declared outputs before the rebuild avoids it.

outputs/stale-output-removed-on-rebuild · correctness
✓
✗

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 added
Why this is right

Restoring 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 b.txt; once its source is removed, the older entry comes back and b.txt must go. Leaving it mixes two builds in one directory, a tree no single run made, and it ships.

outputs/stale-output-removed-on-restore · correctness
✓
✗

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 restore
Why this is right

Declared outputs are what the task wrote, so a restore leaves exactly that. A stray file dropped into dist by hand or by another tool would otherwise ship in the package or the deploy. Declaring dist/** makes the whole directory the task’s; a file meant to stay belongs outside it or behind a ! entry.

outputs/stray-file-removed-on-restore · correctness
✓
✗

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 restored
Why this is right

Only declared outputs are stored and restored. Here the command also writes tmp/scratch.txt, which no output names, so a replay must not create it. Restoring undeclared files means the cache guesses, and a guess brings temp files or local state from one machine onto another. Explicit config beats inference.

outputs/undeclared-not-restored · correctness
✓✓✓✓✓✓
A ! output pattern keeps that file out of the cache
Why this is right

A ! entry says a path under an output directory is not the task’s output, often local state such as a log or a machine’s settings. It must not be stored, so it is neither shared through a remote cache nor restored on another machine. Restoring it brings back a stale or private file the user excluded on purpose.

outputs/negated-output · correctness
✓✓✓✓✓✓
An executable output comes back executable
Why this is right

File 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.

outputs/executable-bit-restored · correctness
✓✓✓✓✓✓
Nested output directories and binary bytes restore intact
Why this is right

Outputs 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.

outputs/nested-and-binary · correctness
✓✓✓✓✓✓
A dependency’s deleted outputs are back before a rebuilding dependent reads them
Why this is right

When a dependant rebuilds, its dependencies’ outputs must be on disk first, even if those dependencies hit the cache. Here lib/dist was deleted and app changed; app reads lib’s output while it builds. Skipping lib’s restore because lib has nothing to do makes app fail, or build against files that are not there.

outputs/dependency-restored-for-dependent · correctness
✓✓✓✓✓✓
Two projects with the same command and the same sources keep their own outputs
Why this is right

The 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.

outputs/same-command-two-projects · correctness
✓✓✓✓✓–
A replayed task that lists no outputs leaves a file it wrote, and the user then edited, alone
Why this is right

A 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 dist/report.txt after the run, so a replay leaves it alone. Running the command again may rewrite it; that is what the command does.

outputs/unlisted-not-clobbered · correctness
✓
✗

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 deleted
Why this is right

A task that declares no outputs restores nothing on a replay. The user deleted report.txt, and nothing declared it an output, so a replay that recreates it puts back bytes the user removed on purpose. A task that wants the file restored declares it, and then the cache knows the file is the task’s.

outputs/unlisted-not-conjured · correctness
✓✓✓✓✓
✗

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 hit
Why this is right

A 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 dist/empty or a packager that copies the tree as is. If a hit drops them, the build passes on the machine that ran it and fails on every machine that replayed it.

outputs/empty-dirs-restored · correctness
✓✓✓
✗

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 run
Why this is right

Removing 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.

outputs/empty-dir-removed-alone-restored · correctness
✓✓✓
✗

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 hit
Why this is right

Output patterns say exactly what a task owns, and a hit restores only that. Here the pattern is dist/*.txt, which matches files, not the empty directory beside them. A tool that brings back everything the task left on disk restores paths the config never claimed, and can overwrite what another step put there. Explicit config beats what the tool saw on disk.

outputs/empty-dir-outside-glob-not-restored · correctness
✓✓✓✓✓✓
A file dropped into an output directory the task left empty is gone after the next run
Why this is right

A 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.

outputs/stale-file-in-empty-output-dir · correctness
✓✓
✗

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 hit
Why this is right

A 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.

outputs/old-empty-dirs-not-cached · correctness
✓
✗

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 hit
Why this is right

A 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.

outputs/stray-empty-dir-removed · correctness
✓
✗

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 file
Why this is right

When something blocks an output path, the run must still end with the file the task makes. Here a directory stands where dist/real.txt belongs. A tool that cannot replace it either fails a build that should pass, or keeps the directory and reports success. Either way the tree is not what the task wrote.

outputs/dir-where-file-belongs · correctness
✓✓✓✓
✗

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 them
Why this is right

Directory modes are part of what a task produces, so a hit must bring them back. A chmod 700 dist/keys restored as 0755 lets every user on the machine read what the task kept private. A 0555 directory restored writable lets a later step change files meant to stay fixed. A fresh run and a hit should leave the same permissions.

outputs/dir-modes-restored · correctness
✓
✗

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 too
Why this is right

Directory 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.

outputs/dir-modes-restored-large · correctness
✓
✗

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 modes
Why this is right

When a restore remakes only part of a tree, the directories it recreates need the same modes a full run gives them. Here dist/keys was deleted and must come back as 0700. A tool that recreates it with default permissions exposes the key file inside it. A partial repair should leave the same tree as a full one.

outputs/dir-modes-partial-restore · correctness
✓
✗

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 kept
Why this is right

An 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.

outputs/file-in-read-only-dir-restored · correctness
✓
✗

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-only
Why this is right

A 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.

outputs/restore-into-read-only-dir · correctness
✓
✗

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 hit
Why this is right

Every 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.

outputs/stray-after-hit-removed · correctness
✓
✗

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 them
Why this is right

A brace pattern like {dist,lib/esm}/** names exactly two trees, and a hit restores those two and nothing else. Missing one leaves half a package behind a green run. Restoring lib/cjs, which the pattern does not name, brings back files the config never claimed. The pattern is the contract, read the way a shell reads it.

outputs/brace-over-two-dirs · correctness
✓✓✓
✗

moon Step 1: exited 1, expected success

✓✓
A brace of two directory names restores both trees
Why this is right

A 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 {dist,lib}/**.

outputs/brace-of-dir-names · correctness
✓✓
✗

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 trees
Why this is right

./dist and dist name the same directory, inside a brace as anywhere else. A tool that treats the ./ alternative as a literal name finds nothing and leaves that tree out of the cache. The build then replays with dist missing under a green run. A harmless spelling difference should not change what is cached.

outputs/brace-with-dot-slash · correctness
✓
✗

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 else
Why this is right

When two output patterns cover parts of one directory, a hit restores what either one matches. That includes dotfiles and empty directories under out/d3/**, which glob code often skips, and excludes out/d1/.hid/x, which neither pattern names. Restoring less leaves a broken build behind a green run; restoring more brings back files nobody claimed.

outputs/two-patterns-one-tree · correctness
✓✓✓
✗

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 alone
Why this is right

**/*.js means the build’s own JavaScript, not the packages installed beside it. A clean or restore that reaches into node_modules deletes or reverts installed code, here a local patch to a dependency. The next build or test then runs against packages the developer never chose. Installed packages are local state unless a task names them.

outputs/wide-glob-spares-node-modules · correctness
✓✓✓✓✓✓
An install task that names node_modules as its output gets it restored
Why this is right

When 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.

outputs/named-node-modules-restored · correctness
✓
✗

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 wrote
Why this is right

A task that declares no outputs has said it leaves nothing worth keeping, so a hit restores nothing. Here the task writes dist/out.txt anyway, and a replay must not bring it back. Storing files the config never named means guessing what a task owns, which can replay stale bytes over files another step wrote. If the file matters, the fix is to declare it.

outputs/declared-none-restores-nothing · correctness
✓✓✓✓✓✓
A task that declares no outputs still replays on an unchanged tree
Why this is right

A 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.

outputs/declared-none-replays · efficiency
✓✓✓✓✓✓
An output rewritten by hand to bytes of the same length is put back
Why this is right

An 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.

outputs/same-size-rewrite-restored · correctness
✓✓✓
✗

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 mtime
Why this is right

Some 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.

outputs/fixed-mtime-round-trip · correctness
✓✓
✗

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 it
Why this is right

File modes are part of the output, so a mode changed by hand must be put back. Here the task makes dist/out.txt 0644 and someone marks it executable. A tool that compares only bytes calls the tree current and ships the wrong mode. Packages and deploy steps can act on the executable bit, so the run must leave the mode the task set.

outputs/mode-only-change-restored · correctness
✓✓✓
✗

moon Step 4: packages/a/dist/out.txt has mode 755, expected 644

✓✓
Output files named like git’s own (.gitkeep, .gitignore) restore
Why this is right

Files like .gitkeep and .gitignore inside the outputs are the task’s own files, and a hit must restore them. A tool that skips git-looking names leaves them out of the cache. The replayed tree then differs from a fresh run: an empty folder drops out of a package, or an ignore rule a later step relied on is gone. The patterns decide what is an output, not the names.

outputs/dotfiles-restored · correctness
✓✓✓✓✓✓
A .git directory a task writes inside its outputs restores where it stands
Why this is right

Only the workspace’s own .git is git’s metadata; a .git directory under dist is just files the task wrote. A tool that skips every .git leaves it out of the cache, so a hit comes back without it. Tasks that build a repo fixture or a checkout then fail after a replay but pass after a run. The output patterns decide what is kept, not the name.

outputs/nested-dot-git-restored · correctness
✓✓
✗

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 file
Why this is right

No 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.

outputs/file-in-place-of-output-directory-kept · correctness
✓
✗

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 tree
Why this is right

The 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.

outputs/background-writer-not-saved · correctness
✓
✗

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 directory that is a symlink is cached through it
Why this is right

The user declared dist as the output, and dist is a link to build inside the same project. That changes where the bytes live, not what the task writes, so outputs are stored by following the link and restored through it. A tool that skips links caches an empty result, and the next replay leaves build empty under a green run.

outputs/linked-output-dir · correctness
✓
✗

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 ** pattern
Why this is right

The user declared dist as the output, and dist is a link to build inside the same project. That changes where the bytes live, not what the task writes, so outputs are stored by following the link and restored through it. A tool that skips links caches an empty result, and the next replay leaves build empty under a green run.

outputs/linked-output-dir-beside-wide-glob · correctness
✓
✗

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 it
Why this is right

The user declared dist as the output, and dist is a link to build inside the same project. That changes where the bytes live, not what the task writes, so outputs are stored by following the link and restored through it. A tool that skips links caches an empty result, and the next replay leaves build empty under a green run.

outputs/linked-output-dir-file-glob · correctness
✓
✗

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 directory that is a link into .git never empties the repository
Why this is right

Here the output directory itself is a link into .git. A tool may refuse the run or write the build’s files through the link; both keep the repository. Clearing dist/** by following the link is never acceptable, because it deletes git’s objects and refs, and with them any work not pushed.

outputs/output-dir-linked-to-git · correctness
✓✓✓✓✓✓
Two unordered tasks declaring the same output pattern: neither loses its file
Why this is right

Two tasks that declare the same output pattern both claim every file under dist, with no order between them. A tool that clears a task’s outputs before running it, or restores its entry as the whole of dist, deletes the other task’s file. The run still reports green, and the missing file shows up only later. Refusing the pair up front is as good as keeping both.

outputs/unordered-overlap-identical-glob · correctness
✓
✗

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 file
Why this is right

dist/** covers dist/app.js, so the second task’s file sits inside the first task’s outputs. When the first task reruns or restores, a tool that resets all of dist to its own entry wipes dist/app.js without any error. The bundle then goes missing from a green build. Refusing the pair is as good as keeping both; dropping one silently is not.

outputs/unordered-overlap-glob-over-literal · correctness
✓✓✓✓✓
✗

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 file
Why this is right

Naming dist outright claims everything below it, including dist/sub/deep.txt, which another task writes. A tool that replaces the whole directory on a rerun or restore deletes that file, and nothing reports it. A later step that reads it fails, far from the cause. The overlap must be refused, or both files kept.

outputs/unordered-overlap-dir-over-file · correctness
✓✓✓
✗

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 file
Why this is right

dist/**/*.js also matches dist/x.js, the other task’s file, while dist/*.js covers only its own level. Overlap is easy to miss when two patterns look different. A tool that clears or restores by pattern can delete the other task’s file under a green run. Refusing the pair, or keeping both files, are the two safe answers.

outputs/unordered-overlap-globs-meeting · correctness
✓
✗

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 file
Why this is right

dist/{a,b}.txt names dist/a.txt, which the other task writes, as well as its own task’s file. A tool that clears or restores everything a pattern names can delete or roll back the other task’s output while the run stays green. A brace set hides overlap from a quick read, so the tool has to expand it. It may refuse the pair or keep both files, never lose one.

outputs/unordered-overlap-brace-over-literal · correctness
✓✓✓✓✓
✗

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 side
Why this is right

Two tasks writing dist/vx-linux and dist/vx-darwin share a directory but no file, a common layout for per-platform builds. A tool that treats a shared parent directory as a clash refuses a build that works. One that clears the directory before a task runs loses the other binary. Each task should keep and restore exactly its own file.

outputs/disjoint-distinct-literals · correctness
✓✓✓✓✓✓
Two tasks writing sibling subdirectories of one directory build, cache and restore side by side
Why this is right

dist/a/** and dist/b/** are sibling trees with nothing in common, a normal layout with one subdirectory per task. A tool that judges overlap by the shared dist parent refuses a build that works. One that restores dist as a whole erases the sibling. Both tasks must build, cache and restore side by side.

outputs/disjoint-disjoint-subdirs · correctness
✓✓✓✓✓✓
A pattern and a file it does not match in one directory build, cache and restore side by side
Why this is right

dist/vx-* does not match dist/other.txt, so the two tasks own different files in one directory. A tool that compares directory prefixes instead of matching the pattern sees a clash that is not there and refuses a working build. A restore of either task must leave the other file in place. Overlap is judged by what the patterns match.

outputs/disjoint-glob-beside-unmatched-literal · correctness
✓✓✓✓✓✓
A one-level pattern beside another task’s subdirectory build, cache and restore side by side
Why this is right

dist/*.js matches files directly in dist, not in dist/sth, so the other task’s dist/sth/y.js is outside it. A tool that reads * as crossing directories sees overlap and refuses a working build, or deletes the other task’s file when it restores. * stops at a slash, as in a shell.

outputs/disjoint-one-level-glob-beside-subtree · correctness
✓✓✓✓✓✓
A pattern whose ! entry leaves out the other task’s file build, cache and restore side by side
Why this is right

The !dist/y.txt entry takes the other task’s file out of dist/**, so the two tasks no longer share a path. Negations are how a config narrows a wide pattern on purpose. A tool that ignores them refuses a build that works, or clears dist/y.txt when the first task restores. Explicit config beats the broad match.

outputs/disjoint-negation-takes-back · correctness
✓✓✓✓✓✓
A file output and another task’s file whose name extends it build, cache and restore side by side
Why this is right

dist/app.js and dist/app.js.map are two files, even though one name starts with the other. A tool that checks overlap by string prefix takes them for one path and refuses a common layout: a bundle and its source map built by separate tasks. Paths should be compared by whole segments, not by characters.

outputs/disjoint-file-and-its-map · correctness
✓✓✓✓✓✓
Editing one of two tasks that write distinct files into one directory reruns only it
Why this is right

Tasks that write different files are independent, so editing one task’s sources reruns only that task. Here both write into dist, but only x changed. A tool that ties the two together through the shared directory rebuilds y for nothing on every edit. Each task’s key should depend on its own inputs only.

outputs/disjoint-edit-reruns-one · efficiency
✓✓✓✓
✗

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 tree
Why this is right

When 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 dist wipes the second task’s files, then replays the second without putting them back. The build is green and incomplete. Here the second task declares only dist/individual, inside the first task’s dist.

outputs/addition-subdir-warm · correctness
✓✓✓
✗

moon Step 1: exited 1, expected success

✓✓
A task that adds a subdirectory of the task before it: an unchanged tree replays all three
Why this is right

With 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 dist/individual, inside the first task’s dist.

outputs/addition-subdir-warm-replays · efficiency
✓✓✓
✗

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 bytes
Why this is right

The 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 dist/individual, inside the first task’s dist.

outputs/addition-subdir-upstream-edit · correctness
✓✓✓
✗

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’s
Why this is right

Editing 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 dist/individual, inside the first task’s dist.

outputs/addition-subdir-dependant-edit · correctness
✓✓✓
✗

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 file
Why this is right

Undoing 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 dist/individual, inside the first task’s dist.

outputs/addition-subdir-dependant-revert · correctness
✓✓✓
✗

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 tree
Why this is right

When 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 dist wipes the second task’s files, then replays the second without putting them back. The build is green and incomplete. Here both tasks declare all of dist, so only what each one wrote tells their files apart.

outputs/addition-same-tree-warm · correctness
✓✓✓
✗

moon Step 1: exited 1, expected success

✓✓
A task that declares the same tree of the task before it: an unchanged tree replays all three
Why this is right

With 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 dist, so only what each one wrote tells their files apart.

outputs/addition-same-tree-warm-replays · efficiency
✓✓✓
✗

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 bytes
Why this is right

The 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 dist, so only what each one wrote tells their files apart.

outputs/addition-same-tree-upstream-edit · correctness
✓
✗

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’s
Why this is right

Editing 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 dist, so only what each one wrote tells their files apart.

outputs/addition-same-tree-dependant-edit · correctness
✓✓✓
✗

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 file
Why this is right

Undoing 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 dist, so only what each one wrote tells their files apart.

outputs/addition-same-tree-dependant-revert · correctness
✓✓✓
✗

moon Step 1: exited 1, expected success

✓✓
A file a later task deletes from an earlier task’s outputs stays deleted on warm runs
Why this is right

When 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.

outputs/addition-removal-not-undone · correctness
✓
✗

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 pinned
Why this is right

When 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.

outputs/addition-same-size-rewrite · correctness
✓✓✓
✗

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 file
Why this is right

dist/ with a trailing slash names the same directory as dist, so it claims dist/app.js, the other task’s file. A tool that treats the slash as part of a name misses the overlap, then clears or restores dist and deletes that file under a green run. Spelling must not change what a pattern covers. Refusing the pair or keeping both files are both fine.

outputs/unordered-overlap-dir-slash-over-file · correctness
✓✓✓
✗

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 file
Why this is right

./dist/** and dist/** are the same pattern, so both tasks claim the whole of dist. A tool that compares the spellings as text sees two different trees and lets each task clear or restore dist over the other. One task’s file then vanishes from a green build. Paths must be normalised before they are compared.

outputs/unordered-overlap-dot-slash-spelling · correctness
✓
✗

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 file
Why this is right

dist/a?.txt matches dist/ab.txt, the other task’s file, because ? stands for any one character. A tool that skips ? when checking overlap lets one task’s restore delete or roll back the other’s output. The run stays green and the file is wrong. Every glob character counts when deciding who owns a path.

outputs/unordered-overlap-question-mark-over-literal · correctness
✓✓✓✓✓
✗

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 file
Why this is right

dist/vx-* matches dist/vx-linux-x64, which another task writes. Release layouts often look like this, with an all-platforms task beside per-platform ones. A tool that misses the match lets one task’s clean or restore delete the other’s binary, and a release ships without it. Refusing the pair or keeping both files are the safe outcomes.

outputs/unordered-overlap-prefix-wildcard-over-literal · correctness
✓✓✓✓✓
✗

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 file
Why this is right

dist/** contains dist/extra/**, so the inner task’s files sit inside the outer task’s outputs. A tool that restores the outer task’s entry as the whole of dist deletes dist/extra/y.txt without an error. Nesting is a common way to add files to a build, which makes the silent loss easy to hit. The pair must be refused, or both files kept.

outputs/unordered-overlap-subtree-over-nested-subtree · correctness
✓✓✓✓✓
✗

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 file
Why this is right

dist/** covers every .d.ts file that dist/**/*.d.ts claims, as when a type-declaration task writes into a bundler’s tree. If either task clears or restores by its own pattern without regard to the other, the types or the bundle go missing behind a green run. The overlap is real even though the patterns differ. Refusing the pair or keeping both files are both acceptable.

outputs/unordered-overlap-subtree-over-extension-glob · correctness
✓✓✓✓✓
✗

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 file
Why this is right

**/*.d.ts matches every declaration file in the project, including dist/y.d.ts, which another task writes. A tool that checks the wide pattern only near where its own files sit misses the overlap. One task’s clean or restore then deletes the other’s file and the run stays green. A pattern claims everything it matches, wherever that is.

outputs/unordered-overlap-anywhere-glob-over-subtree · correctness
✓✓✓✓✓
✗

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 file
Why this is right

*/app.js matches dist/app.js as well as lib/app.js, so two tasks claim the same file. A tool that treats the wildcard directory as one fixed name misses this. A restore of the first task can then overwrite or delete the second task’s dist/app.js under a green run. Refusing the pair or keeping both files are the safe answers.

outputs/unordered-overlap-wildcard-directory-over-literal · correctness
✓✓✓✓✓
✗

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 file
Why this is right

{dist,lib}/app.js names lib/app.js, which the other task writes. A tool that checks only the first brace alternative misses the overlap. The first task’s clean or restore then removes or rolls back the other task’s file with no error. A brace set must be expanded in full before ownership is decided.

outputs/unordered-overlap-brace-directory-over-literal · correctness
✓✓✓✓✓
✗

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 file
Why this is right

build/{a,b}.js and build/*.js both match build/b.js, the file one task writes. Neither pattern looks like the other, so the clash is easy to miss. A tool that clears or restores each task by its pattern can delete one task’s file when the other runs. It must refuse the pair or keep both files intact.

outputs/unordered-overlap-patterns-meet-at-one-file · correctness
✓
✗

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 file
Why this is right

The !types/x/** entry takes back only types/x, so **/dist/** still claims types/dist/y.txt, the other task’s file. A tool that reads any negation under types as removing the whole tree misses the overlap. A restore can then delete that file under a green run. A negation removes exactly what it matches, no more.

outputs/unordered-overlap-partial-negation · correctness
✓✓✓✓✓
✗

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 file
Why this is right

Root outputs live outside a project, so two projects can claim the same workspace path. Here shared/** covers shared/b.txt, which the other project writes. A tool that clears or restores one project’s root tree deletes the other’s file, and the run stays green. Overlap across projects needs the same check as overlap inside one.

outputs/unordered-overlap-root-outputs-across-projects · correctness
✓✓✓✓✓
✗

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 file
Why this is right

A root output can point into another project’s folder. Here one task writes packages/b/dist/a.txt, which project b’s dist/** also claims. When b restores its dist, a tool that ignores the root claim deletes that file, and the run stays green. Root and project paths must be compared on one map of the workspace.

outputs/unordered-overlap-root-output-inside-project-tree · correctness
✓
✗

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 file
Why this is right

The root output tree packages/b/dist/** covers packages/b/dist/y.txt, a file project b declares as its own output. Two tasks in different projects then claim one path. A tool that never compares root outputs with project outputs lets one task’s restore delete the other’s file under a green run. The pair must be refused, or both files kept.

outputs/unordered-overlap-root-output-tree-over-project-file · correctness
✓✓✓✓✓
✗

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 side
Why this is right

dist and build are different directories, so the two tasks share nothing. A tool that sees a conflict wherever two tasks name directories refuses a build that works. One that clears more than each task named breaks it. Each task should build, cache and restore its own tree without touching the other.

outputs/disjoint-two-literal-dirs · correctness
✓✓✓✓
✗

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 side
Why this is right

dist and distant/app.js share a run of letters but no directory. A tool that compares paths as plain strings thinks dist covers distant. It then refuses a working build, or clears the other task’s file when dist is restored. Paths must be compared segment by segment.

outputs/disjoint-dir-beside-longer-name · correctness
✓✓✓✓
✗

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 side
Why this is right

Naming dist/a and dist/b outright gives each task its own sibling folder; the shared parent belongs to neither. A tool that restores dist as one unit wipes the sibling. One that flags any two outputs under one parent refuses a working layout. Each named directory should be cached and restored on its own.

outputs/disjoint-literal-subdirs · correctness
✓✓✓✓
✗

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 side
Why this is right

dist/** and distant/** look alike as text but sit in different directories. A string-prefix check reads one as inside the other, and either refuses the pair or lets one task’s restore delete the other’s tree. The two tasks own separate paths, so both must build, cache and restore side by side.

outputs/disjoint-subtree-beside-longer-name · correctness
✓✓✓✓✓✓
An output tree and another task’s pattern over a sibling directory build, cache and restore side by side
Why this is right

dist/a/** and dist/b/*.js cover sibling folders, so no file can match both. A tool that stops comparing at the first wildcard, or at the shared dist, sees a clash that is not there. It refuses a working build, or clears one sibling when the other restores. Overlap is decided by what the patterns can match.

outputs/disjoint-subtree-beside-sibling-glob · correctness
✓✓✓✓✓✓
Two patterns over one tree that take different extensions build, cache and restore side by side
Why this is right

dist/**/*.js and dist/**/*.map cover one tree but take different extensions, so x.js and x.js.map belong to different tasks. Splitting a bundle and its source maps between tasks is a real layout. A tool that calls any two patterns over one tree a clash refuses it, and one that restores by directory deletes the other’s files.

outputs/disjoint-extension-globs-apart · correctness
✓✓✓✓✓✓
An output tree and another task’s deep pattern over another directory build, cache and restore side by side
Why this is right

lib/** and dist/**/*.js start in different top-level directories. Two deep patterns are not in conflict just because both use **. A tool that treats ** as reaching everywhere refuses a working build, or clears the other task’s files on restore. Each pattern reaches only below its fixed prefix.

outputs/disjoint-tree-beside-deep-glob · correctness
✓✓✓✓✓✓
A ? output pattern beside a file it does not match build, cache and restore side by side
Why this is right

dist/a?.txt cannot match dist/zz.txt, since the first letter differs. A tool that treats any wildcard in a shared directory as a clash refuses a working build. A restore of the pattern’s task must also leave dist/zz.txt alone. Ownership is decided by what the pattern really matches.

outputs/disjoint-question-mark-unmatched · correctness
✓✓✓✓✓✓
A brace output pattern beside a file it does not name build, cache and restore side by side
Why this is right

dist/{a,b}.txt names two files, and dist/c.txt is not one of them. A tool that treats a brace as matching anything in the directory refuses a working build, or deletes c.txt when it restores the first task. A brace set is a short list of names and nothing more.

outputs/disjoint-brace-unmatched · correctness
✓✓✓✓✓✓
Two sibling output trees spelled ./dist/a/** and dist//b/** build, cache and restore side by side
Why this is right

./dist/a/** and dist//b/** are untidy spellings of two sibling trees; normalised, they share no path. A tool that does not normalise them can see a clash and refuse a working build. It can also fail to match a task’s own files on a restore and leave them out. Harmless spelling must not change what a task owns.

outputs/disjoint-odd-spellings-apart · correctness
✓✓✓
✗

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 side
Why this is right

!types/** removes the whole types tree from **/dist/**, so the other task’s types/dist/y.txt is not claimed. A tool that ignores the negation refuses a working build, or clears the other task’s file on restore. Negation is how a config narrows a wide pattern, and explicit config beats the broad match.

outputs/disjoint-negation-takes-back-tree · correctness
✓✓✓✓✓✓
A brace output pattern beside another task’s file in the same directory build, cache and restore side by side
Why this is right

build/{a,b}.js names a.js and b.js, while the other task writes build/c.js. They share a directory and nothing else. A tool that treats the brace as a wildcard refuses the pair, or removes c.js on a restore. Both tasks must build, cache and restore side by side.

outputs/disjoint-brace-beside-literal · correctness
✓✓✓✓✓✓
An output tree and another task’s files beside it, one in a directory whose name extends it build, cache and restore side by side
Why this is right

dist/a/** claims only the dist/a folder, while the other task writes dist/ab/c.js and dist/b.js. dist/ab starts with the same letters but is a different folder. A string-prefix check reads it as inside dist/a, then refuses the pair or deletes the file on a restore. Paths are compared by whole segments.

outputs/disjoint-literal-beside-glob-head · correctness
✓✓✓
✗

moon Step 1: exited 1, expected success

✓✓
Two projects writing distinct root output files into one directory build, cache and restore side by side
Why this is right

Two projects writing generated/a.ts and generated/b.ts share a workspace folder but no file, a common layout for code generators. A tool that treats the shared root folder as owned by one project refuses the build. One that clears the folder on a restore deletes the other project’s file. Each root output should be cached and restored on its own.

outputs/disjoint-root-outputs-distinct · correctness
✓✓✓✓✓✓
A project output and another project’s root output with the same relative name build, cache and restore side by side
Why this is right

A project output generated/x.ts means packages/a/generated/x.ts, while a root output of the same name means generated/x.ts at the workspace root. They are different files. A tool that compares the two lists without resolving them refuses a working build, or restores one file over the other. Relative names must be resolved before they are compared.

outputs/disjoint-project-and-root-namespaces · correctness
✓✓✓✓✓✓
A root output file in another project, beside that project’s output tree build, cache and restore side by side
Why this is right

A root output in packages/b/gen sits inside project b’s folder but outside its dist tree, so no file is claimed twice. A tool that treats a whole project folder as that project’s own refuses the pair, or clears gen when b restores. Ownership follows the declared patterns, not the project folder.

outputs/disjoint-root-output-beside-project-tree · correctness
✓✓✓✓✓✓
A root output file in another project’s output directory that its pattern does not match build, cache and restore side by side
Why this is right

Project b claims dist/*.js, and a root output writes packages/b/dist/a.txt beside those files. The .txt file does not match b’s pattern. A tool that treats b’s whole dist folder as b’s refuses a working build, or deletes a.txt when b restores. Only what the pattern matches is b’s.

outputs/disjoint-root-output-beside-project-glob · correctness
✓✓✓✓✓✓
A root output file in another project’s output tree that a ! entry takes back build, cache and restore side by side
Why this is right

Project b’s !dist/a.txt takes back the file another project writes into b’s dist as a root output, so no path is claimed twice. A tool that ignores negations when comparing root and project outputs refuses a working build, or deletes a.txt on b’s restore. Explicit config beats the broad match.

outputs/disjoint-root-output-taken-back · correctness
✓✓✓✓✓✓
Two projects declaring the same project-relative output pattern build, cache and restore side by side
Why this is right

Two projects declaring dist/** mean two different folders, packages/a/dist and packages/b/dist, since project outputs are relative to their project. A tool that compares the patterns as written sees a clash in almost every monorepo, or restores one project’s files into the other. Patterns must be resolved against their project first.

outputs/disjoint-two-projects-one-pattern · correctness
✓✓✓✓✓✓
A project that adds a root file beside a dependency’s root output tree: warm runs and an upstream edit keep both
Why this is right

When a project adds a root file beside a dependency’s root output tree, every run must end with both files. The dependency’s shared/** covers shared/b.txt, so restoring its entry as the whole tree would delete the dependant’s file. After an upstream edit the dependency reruns, and the dependant’s file must still be there. The result should match a cold run of both.

outputs/addition-root-across-projects · correctness
✓✓✓✓✓✓
A root output file added into a dependency project’s output tree survives warm runs and that project’s rebuild
Why this is right

A 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 dist for the dependency’s rerun, then replays the dependant without restoring its file, leaves the tree incomplete under a green run. Warm runs should match a cold run of both tasks.

outputs/addition-root-into-project-tree · correctness
✓✓✓✓✓✓
A stray file dropped deep in an output tree after warm runs is gone on the next run
Why this is right

Outputs 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.

outputs/nested-stray-removed-after-warm-hits · correctness
✓✓
✗

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 replays
Why this is right

A 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.

outputs/stray-from-later-task-removed · correctness
✓✓
✗

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 replays
Why this is right

A replayed task’s outputs are what the task made, even if a later task deleted one in between. Here an uncached step removed dist/sub/in.js. A tool that sees the rest of the tree as current and skips the restore leaves the file missing under a green run. Anything that reads it next fails.

outputs/removed-by-later-task-restored · correctness
✓✓✓✓✓
✗

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 run
Why this is right

Naming a directory outright, as dist, claims everything under it, the same as dist/**. A stray file in a subfolder is therefore inside the outputs and must go. A tool that only checks the directory entry itself keeps the stray and ships it. Both spellings should leave the same tree.

outputs/bare-dir-output-stray-removed · correctness
✓✓
✗

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 run
Why this is right

An output named outright, like dist/tool, is still an output, so a hand edit to it must be undone. A tool that only checks files found by a glob walk can miss a literal path and keep the edit. The build then ships bytes no run produced. Literal and wildcard patterns should be checked the same way.

outputs/bare-file-output-edit-restored · correctness
✓✓✓
✗

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 file
Why this is right

When 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 dist/tool exactly as the task writes it. A literal output path is checked like any other.

outputs/bare-file-output-dir-replaced · correctness
✓✓
✗

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 hit
Why this is right

**/dist/** claims every dist folder in the project, including one that did not exist when the task ran. A stray in a new lib/dist is therefore part of what the task owns, and a hit must remove it. A tool that checks only the folders it saw last time ships the stray. The pattern, not the last listing, says what is owned.

outputs/anywhere-dir-pattern-stray-removed · correctness
✓
✗

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 replays
Why this is right

A 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.

outputs/root-output-stray-removed · correctness
✓
✗

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 restored
Why this is right

Once 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/root-output-replayed-unchanged · efficiency
✓✓✓✓✓✓
Outputs with paths and file names over 100 bytes restore intact, with their modes
Why this is right

A 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 dist/cli, must survive too.

outputs/long-names-round-trip · correctness
✓✓✓✓✓✓
Outputs with names over 100 bytes are cached: deleted, they are restored, not rebuilt
Why this is right

Long 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.

outputs/long-names-replayed · efficiency
✓✓✓✓✓✓
A task whose own output patterns and directories overlap restores exactly the tree it wrote
Why this is right

A task’s own output patterns may overlap: .next/standalone and .next/standalone/** name the same files, as do .next/*.json and .next/**/*.json. A file two patterns match is one output, not two to write over each other. A tool that stores it twice can fail the restore or leave a half-written file. The hit should leave exactly the tree the task wrote.

outputs/overlapping-own-patterns-restored · correctness
✓✓✓✓
✗

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 restore
Why this is right

Overlapping 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.

outputs/overlapping-own-patterns-replayed · efficiency
✓✓✓✓✓✓
An output pattern with a brace set of extensions restores those files and no other
Why this is right

.build/**/*.{js,map,ts} names three extensions, so a hit restores those files and not .build/c.css. A tool that mishandles the brace either drops every file or restores the whole tree. The first leaves a broken build behind a green run; the second brings back files the config never claimed. Explicit patterns decide what is cached.

outputs/brace-extension-set · correctness
✓✓✓✓✓✓
Output names with a space, a quote, a backslash or non-ASCII letters restore as written
Why this is right

File 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.

outputs/odd-names-round-trip · correctness
✓✓✓✓
✗

Vite Task Step 1: exited 1, expected success

✓
Outputs with odd names are cached: deleted, they are restored, not rebuilt
Why this is right

Odd 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.

outputs/odd-names-replayed · efficiency
✓✓✓✓
✗

Vite Task Step 1: exited 1, expected success

✓
A large incompressible output restores byte for byte, an executable beside it kept executable
Why this is right

A 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.

outputs/large-output-restored · correctness
✓✓✓✓✓
✗

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 into
Why this is right

A retry may clean up the failed attempt’s outputs, but only the outputs that task declared. Here p owns out/a.txt, while q writes out/b.txt into the same folder at the same time. A tool that removes the whole out folder before the retry deletes q’s file mid-run. One task’s retry must never touch another task’s work.

outputs/retry-spares-a-sibling-root-output · correctness
✓––✓––
An output whose mode was changed by hand gets back the mode the task gave it
Why this is right

A file’s mode is part of what a task produces, so a hand change to it must be undone. Here the task makes dist/out.txt 0644 and someone marks it 0755. A tool that compares only bytes keeps the wrong mode and ships it. Packaging and deploy steps can act on the executable bit, so the run must leave the task’s mode.

outputs/hand-chmod-reset · correctness
✓✓✓
✗

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 hit
Why this is right

Empty 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 gen/empty along with gen/real.txt. A tool that stores only files, or handles root outputs on a separate path, drops it. A later step that writes into it then fails only on machines that replayed.

outputs/root-empty-dirs-restored · correctness
✓✓✓
✗

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 run
Why this is right

A 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.

outputs/root-stale-file-in-empty-dir · correctness
✓✓
✗

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 hit
Why this is right

A cache entry holds what the task made, not what older builds left behind. gen/old predates the run, so it must not be stored or restored. A tool that captures every folder under a root output tree recreates it on every hit, on every machine. Old layouts then outlive the code that made them.

outputs/root-old-empty-dirs-not-cached · correctness
✓
✗

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 hit
Why this is right

A 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.

outputs/root-stray-empty-dir-removed · correctness
✓
✗

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 restore
Why this is right

A 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.

outputs/mixed-damage-restored · correctness
✓
✗

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 back
Why this is right

What 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.

outputs/edit-after-save-not-cached · correctness
✓✓✓✓✓✓
Paths a ! output entry takes back are left in place by a run and never come back from a replay
Why this is right

A ! entry says a path under the output directory is not the task’s. A run leaves such files in place, here keep.txt and fixture.txt that the user keeps there. A replay restores the real outputs but never brings back an excluded file, like the .tmp scratch file the command wrote. Excluded means neither cleaned nor stored.

outputs/negated-left-by-run-and-not-replayed · correctness
✓✓✓✓✓✓
A replay restores the outputs and leaves an edited path a ! entry takes back alone
Why this is right

The ! entry says the file is not the task’s output, so it belongs to the user. Here the user edits fixture.txt and deletes the real outputs; the replay restores the outputs and leaves the edited file as it is. A replay that cleans the whole directory or writes over the file loses the user’s bytes with no warning.

outputs/negated-left-by-replay · correctness
✓✓✓✓✓✓
A stray file in an output directory with ! entries is gone after a replay
Why this is right

! entries carve out named paths; they do not turn the rest of the directory into user space. A stray file that is neither an output nor excluded must still go on a replay, or it ships with the build. Only the paths the ! entries name are kept.

outputs/negated-stray-still-removed · correctness
✓
✗

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 task
Why this is right

A path can be excluded from outputs and still be an input. Here out/keep.txt is taken back by a ! entry and named in the inputs, so editing it reruns the task. A tool that leaves a path out of the key because it sits under an output directory replays a result built from the old file.

outputs/negated-path-stays-input · correctness
✓✓✓
✗

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 back
Why this is right

When a later task adds to the earlier task’s output tree, each task’s ! entries still apply to its own outputs. Here types excludes *.tmp, so a replay restores a.js and a.d.ts and leaves the scratch file out. Bringing back an excluded file puts local state into a tree that ships.

outputs/negated-in-dependant-adding-to-tree · correctness
✓✓✓✓✓✓
Two tasks with disjoint outputs, each with a ! entry, both run
Why this is right

Two tasks conflict only when they write the same path. Here x writes dist and y writes out, each with a ! entry inside its own directory. A ! entry narrows a task’s own outputs and is not an overlap, so refusing or dropping either task blocks a valid config for no reason.

outputs/negated-disjoint-pair-both-run · correctness
✓✓✓✓✓✓
A stray in an earlier task’s outputs is gone after a replay, beside a later task’s narrower ! entry
Why this is right

Each task’s ! entries speak only for its own outputs. The later task’s !out/types/*.tmp is about its own tree; it does not make a stray in the earlier task’s out anyone’s output. A replay removes the stray, or a file no task wrote ships with the build.

outputs/negated-narrow-dependant-stray-removed · correctness
✓
✗

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 input
Why this is right

A ! entry on an output at the workspace root works like one inside the project. Here gen/keep.txt is excluded from the root output and named as an input: it stays in place on a run, never comes from a replay, and still reruns the task when it changes. Treating it as output loses the user’s file; leaving it out of the key replays stale results.

outputs/negated-root-output · correctness
✓✓✓
✗

moon Step 5: did not run a#build

✓✓
An output directory the user made a link inside the project is restored through
Why this is right

A 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.

outputs/output-dir-linked-inside-project-written-through · correctness
✓
✗

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, above a deeper output is restored through: its target made, the link kept
Why this is right

A 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.

outputs/dangling-link-in-project-root-above-deeper-entry · correctness
✓
✗

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 output link out of the project at the output directory, above a deeper output gets nothing created through it
Why this is right

A 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.

outputs/dangling-link-out-of-project-root-above-deeper-entry · correctness
✓
✗

Nx Step 5: packages/a/dist is not a symlink

✗

Turborepo Step 5: packages/a/dist is not a symlink

✓✓✓
The root project’s output directory linked to .git never empties the repository
Why this is right

When the repository root is itself a project, .git sits inside that project. A link from dist into .git therefore stays in the project, and a clean that follows it deletes git’s objects and refs: the repository, with any work not pushed, is gone. Removing or refusing the link is safe; clearing through it never is.

outputs/root-project-output-dir-linked-to-git · correctness
✓✓✓✓✓✓
A root output directory linked to .git never empties the repository
Why this is right

An output declared at the workspace root, outside any project, can be a link into .git too. Clearing out/** by following that link deletes git’s objects and refs, and with them any work not pushed. A tool may refuse the run or replace the link, but it never deletes through it.

outputs/root-output-dir-linked-to-git · correctness
✓✓✓✓✓✓
The root project’s output directory linked to .git/refs never empties the repository
Why this is right

When the repository root is itself a project, .git sits inside that project. A link from dist into .git therefore stays in the project, and a clean that follows it deletes git’s objects and refs: the repository, with any work not pushed, is gone. Removing or refusing the link is safe; clearing through it never is.

outputs/root-project-output-dir-linked-to-git-refs · correctness
✓✓✓✓✓✓
A root output directory linked to .git/refs never empties the repository
Why this is right

An output declared at the workspace root, outside any project, can be a link into .git too. Clearing out/** by following that link deletes git’s objects and refs, and with them any work not pushed. A tool may refuse the run or replace the link, but it never deletes through it.

outputs/root-output-dir-linked-to-git-refs · correctness
✓✓✓✓✓✓
A root **/*.js output leaves installed packages and .git alone
Why this is right

**/*.js at the root means the files the build writes. Read literally, it also matches installed packages under node_modules and files under .git. A clean or restore that reaches them deletes or reverts what no task wrote, and breaks the install or the repository. Refusing so wide a pattern is as safe.

outputs/root-wide-glob-spares-node-modules-and-git · correctness
✓✓
✗

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 back
Why this is right

out/\[x\] names one path, an entry literally called [x]. Read as a pattern instead, it matches out/x, so when the output changes from a directory to a file, the emptied directory is not cleared and blocks the file’s restore. Escapes in output patterns hold for saving and for cleaning alike.

outputs/escaped-literal-dir-swapped-for-file · correctness
✓✓✓
✗

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 run
Why this is right

An 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.

outputs/root-output-edit-restored · correctness
✓✓✓
✗

moon Step 3: shared/g.txt is "CORRUPT", expected "g"

✓✓
A stray file in the root project’s output directory is gone after a replay
Why this is right

When the repository root is a project, its dist is an output directory like any other. A stray file dropped there must be gone after a replay, or it ships with the build. Being at the root is no reason to skip cleaning declared outputs, nor to reach past them into the rest of the repository.

outputs/root-project-stray-removed · correctness
✓
✗

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 tree
Why this is right

Two tasks may share an output tree when one runs after the other: here build writes gen and individual adds gen/individual. A warm run must end with the same tree as the cold one. If restoring build clears all of gen and the later task is not restored after it, gen/individual is missing on every warm run.

outputs/root-addition-subdir-warm · correctness
✓✓✓✓✓✓
A task adding a subdirectory to an earlier task’s root output: an unchanged tree replays both
Why this is right

When 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.

outputs/root-addition-subdir-warm-replays · efficiency
✓✓✓✓✓✓
A task writing into the root output it reads from the task before it keeps that task’s files
Why this is right

A later task may read from and write into the tree the task before it wrote. Here individual reads gen/a.txt and writes gen/b.txt beside it. Clearing the shared tree before the later task runs deletes the file it is about to read, so the run fails or writes the wrong bytes.

outputs/root-same-tree-reader-keeps-upstream · correctness
✓✓✓
✗

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 files
Why this is right

A task in one project may write into another project’s output directory: here a writes b/dist/a.txt, and b adds its own file after it. A warm run keeps both files. If restoring b clears dist first, it deletes a’s file, and the package ships without it.

outputs/root-anchored-upstream-under-dependant · correctness
✓✓✓✓✓
✗

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 replay
Why this is right

When nothing changed, both tasks writing into b/dist replay. Rerunning them because their outputs share a directory costs their full time on every run. The graph orders them, so restoring a’s files and then b’s gives the tree the cold run left.

outputs/root-anchored-upstream-under-dependant-replays · efficiency
✓✓✓✓✓✓
A file a later task deletes from an earlier task’s root output stays deleted on warm runs
Why this is right

A later task may delete a file the earlier task wrote, here tmp.js. On a warm run, the replay of the earlier task brings that file back. The later task must then remove it again, or not be replayed, or the file ships in every warm tree but not in a cold one.

outputs/root-addition-removal-not-undone · correctness
✓✓
✗

Turborepo Step 2: gen/tmp.js exists, expected none

✓✓✓
A replay started from a project’s subdirectory restores each output at its own project
Why this is right

Outputs 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 dist into packages/app/src and leave the real dist missing. The next step, a bundle or a deploy, would then read nothing or old files while the run reported success.

outputs/from-subdir-restores-at-each-project · correctness
✓✓✓✓✓✓
An output that is a directory, a file or a link in turn comes back in each build’s own shape
Why this is right

A replay must leave the output exactly as the build that stored it, whatever was there before. When dist/out is a directory in one build, a file in the next and a link in a third, each replay lands on another shape’s tree. Merged into it, the result is neither build’s output, and the next step reads a mix of both.

outputs/shape-change-restored-exactly · correctness
✓✓✓
✗

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 replaced
Why this is right

A 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.

outputs/stray-other-shape-replaced · correctness
✓✓✓✓✓
✗

Nix Step 3: packages/a/dist/out is missing, expected "flat\n"

Through an output directory that is a link inside the project, a deleted output comes back
Why this is right

Builds 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 dist/out.js missing while the run reports success.

outputs/linked-output-dir-restored · correctness
✓
✗

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 files
Why this is right

Each build’s output is exactly the files it wrote, also when the output directory is a link. A replay of one must remove two.js that another build left behind it, and the other way round. A runner that only adds files through the link leaves both, and a bundler or deploy step then ships files from a build that is not current.

outputs/linked-output-dir-own-files-only · correctness
✓
✗

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 deleted
Why this is right

An output named dist means what the build wrote there, also when dist is a link to another folder. A runner that stores only the link, and not the files behind it, has nothing to restore once real-out/out.js is deleted. The replay then reports success while the file the next step reads is missing.

outputs/linked-dir-literal-bare · correctness
✓
✗

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 deleted
Why this is right

Naming both the link dist/ and its target real-out/** declares the same files twice, which must not confuse a restore. The replay must bring back real-out/out.js once, and keep dist pointing at it. A runner that writes the files through the link and then replaces the link, or the other way round, can leave the output missing.

outputs/linked-dir-literal-slash-and-target · correctness
✓✓✓✓✓✓
A rebuild whose output turns from a directory into a file finds no stale directory in its way
Why this is right

Declared outputs belong to the task, so the runner clears them before a run, as a replay would. If the old dist/out directory stays, a build that now writes dist/out as a file fails, or writes beside stale files. The result then depends on what earlier builds left, and a clean CI checkout would build something else.

outputs/miss-shape-change-cleaned · correctness
✓
✗

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 replayed
Why this is right

An output named outright, such as dist/out, is the same path whether it is a file or a directory. Run or replayed, the runner must leave it in the shape this build made. A runner that matches the name only as a file, or only as a directory, stores or restores nothing in the other shape, and the next step finds no output.

outputs/literal-output-shape-change · correctness
✓
✗

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 into
Why this is right

Before 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.

outputs/retry-spares-a-sibling-output · correctness
✓––✓––
An uncached task over a cached dependency finds the dependency’s deleted outputs back
Why this is right

An 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.

outputs/uncached-dependant-restores-dependency · correctness
✓✓✓✓✓✓
Either of a dependency’s two output trees, deleted alone, is back before a rebuilding dependant reads it
Why this is right

A task that writes two trees, as a build writing dist and types does, is only present when both are. Deleting one and keeping the other is what a partial clean or a tool that prunes one directory leaves. A runner that sees one tree on disk and calls the dependency done lets the dependant build against a missing file, and fail or ship without it.

outputs/one-of-two-trees-deleted-restored-for-dependant · correctness
✓✓✓✓✓✓
A read-only output file left by a newer build is replaced by the cached one, still read-only
Why this is right

Some 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.

outputs/read-only-output-replaced-on-hit · correctness
✓✓✓✓✓
✗

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 hit
Why this is right

A 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.

outputs/many-empty-and-equal-files-restored · correctness
✓✓✓✓✓✓
A file only a failed attempt wrote is not saved with the retry that passed
Why this is right

The stored result should be what a passing run produces. Here the first attempt writes dist/stale.txt and fails; the retry writes only dist/out.txt and passes. A tool that saves everything under dist stores the failed attempt’s leftover as part of a good build and restores it on every hit.

outputs/retry-saves-only-the-passing-attempt · correctness
✓––
✗

moon Step 3: packages/a/dist/stale.txt exists, expected none

––
A brace output whose alternative escapes [ and ] restores both trees
Why this is right

{dist,app/\[id\]}/** names two output trees, one of them a folder with brackets in its name. A tool that stops expanding the brace once it sees an escape saves neither. A hit then restores nothing and the build looks done while its files are missing.

outputs/brace-holding-escaped-brackets · correctness
✓✓✓
✗

moon Step 1: exited 1, expected success

✓✓

Run report

9 cases
100%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.

CasevxNxTurborepomoonVite TaskNix
A retried task’s reported time covers every attempt, not only the last
Why this is right

A 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.

report/retried-duration-all-attempts · data
✓––✓––
A run reports that a task missed the cache
Why this is right

Knowing 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.

report/cache-miss · data
✓✓✓✓✓✓
A run reports that a task was replayed
Why this is right

A 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.

report/cache-hit · data
✓✓✓✓✓✓
A run reports how long each task took
Why this is right

Per-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.

report/duration · data
✓✓✓✓
✗

Vite Task Step 1: reports no durationMs

✗

Nix Step 1: reports no durationMs

A run reports how long a replay took, not the original run
Why this is right

A 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.

report/restore-duration · data
✓✓✓✓
✗

Vite Task Step 3: reports no durationMs

✗

Nix Step 3: reports no durationMs

A run reports each task’s CPU time
Why this is right

CPU 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.

report/cpu · data
✓
✗

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 memory
Why this is right

Peak 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.

report/memory · data
✓
✗

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 wrote
Why this is right

The 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.

report/output-size · data
✓
✗

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 restored
Why this is right

On 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.

report/restored-size · data
✓
✗

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 cases
100%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.

CasevxNxTurborepomoonVite TaskNix
A variable the task does not declare never reaches it
Why this is right

A variable that reaches the command but not its key can change the output while the key stays the same. A later run with another value then replays the wrong result. Keeping undeclared variables out of the task makes that mistake fail loudly now instead of silently later.

sandbox/undeclared-env-hidden · correctness
✓
✗

Nx Step 1: packages/a/dist/out.txt is "leaked", expected "unset"

✓
✗

moon Step 1: packages/a/dist/out.txt is "leaked", expected "unset"

✓✓
An isolated task that reads its inputs and writes its outputs succeeds
Why this is right

Isolation 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.

sandbox/declared-reads-and-writes-pass · correctness
✓––––✓
An isolated task that reads a project file it never declared fails
Why this is right

A 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.

sandbox/undeclared-read-fails · correctness
✓––––✓
An isolated task may read a file it is granted beyond its inputs
Why this is right

Some 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.

sandbox/granted-read-passes · correctness
✓––––✓
An isolated task that reads another project’s source fails
Why this is right

Reading 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.

sandbox/other-project-read-fails · correctness
✓––––✓
An isolated task that writes outside its outputs fails, and the file is not left behind
Why this is right

A 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.

sandbox/undeclared-write-fails · correctness
✓––––
✗

Nix Step 1: succeeded, expected a failure

An isolated task that writes into another project fails, and leaves that project as it was
Why this is right

Writing 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.

sandbox/other-project-write-fails · correctness
✓––––✓
An isolated task that rewrites its own source fails, and the source is kept
Why this is right

A 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.

sandbox/source-write-fails · correctness
✓––––
✗

Nix Step 1: succeeded, expected a failure

An isolated task cannot read the user’s SSH key
Why this is right

An SSH key in the user’s home is a secret no build should read. A task that can reach it can leak it through a log, an output or a cached artifact shared with others. An isolated task must see none of the home it did not declare, so a compromised script cannot take the key.

sandbox/credential-store-hidden · correctness
✓––––✓
An isolated task with no network grant cannot reach a server the run started
Why this is right

A 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.

sandbox/network-refused · correctness
✓–––––
A task closed off from the machine cannot read a system file
Why this is right

A 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.

sandbox/closed-machine-read-fails · correctness
✓––––✓
A task closed off from the machine still runs when it reads only its inputs
Why this is right

Closing 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.

sandbox/closed-declared-reads-pass · correctness
✓––––✓
An isolated task that writes into the home folder fails, and leaves nothing there
Why this is right

A 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.

sandbox/home-write-fails · correctness
✓––––✓
An isolated task reads what the task it depends on built in another project
Why this is right

A 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.

sandbox/dependency-output-readable · correctness
✓––––✓
An isolated task that depends on another project still cannot read its sources
Why this is right

Depending on a task gives its outputs, not the files it was built from. A read of the dependency’s sources ties the build to files the edge never promised. Failing it keeps the edge meaning one thing: what the dependency built.

sandbox/dependency-source-hidden · correctness
✓––––✓
An isolated task may read the outputs of a task it depends on
Why this is right

Reading 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.

sandbox/dependency-output-read-passes · correctness
✓––––✓
An isolated task that reads a dependency file beside its declared outputs fails
Why this is right

The dependency declared gen/**/*.js as its outputs, so only those files are what it hands on. gen/note.txt sits in the same folder but nothing keys it: an edit to it would replay the dependant’s stale result. Granting the whole folder hides that gap, so the read must fail.

sandbox/read-beside-dependency-output-fails · correctness
✓––––✓
An isolated task that reads another project’s file through git fails
Why this is right

Git’s object store holds every committed file of every project. A task that runs git show against it reads another project’s source without touching that project’s folder, and nothing keys the file. Walling off the folders but not the repository leaves the same stale-hit hole open through git.

sandbox/other-project-read-through-git-fails · correctness
✓––––✓

Watch mode

31 cases
100%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.

CasevxNxTurborepomoonVite TaskNix
Editing a source file while watching rebuilds with the new content
Why this is right

Rerunning 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.

watch/input-edit-reruns · correctness
✓✓✓–––
Editing a file the task does not read reruns nothing
Why this is right

The 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.

watch/edit-outside-inputs-reruns-nothing · efficiency
✓✓✓–––
Saving a file without changing it reruns nothing
Why this is right

An 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.

watch/same-bytes-rerun-nothing · efficiency
✓✓✓–––
Undoing an edit while watching brings back the earlier result without rebuilding
Why this is right

After 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.

watch/edit-back-replays · efficiency
✓✓✓–––
A new file matching the task’s inputs rebuilds with it included
Why this is right

Inputs 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.

watch/new-input-file-reruns · correctness
✓✓✓–––
Deleting a source file rebuilds without it
Why this is right

A 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.

watch/deleted-input-reruns · correctness
✓✓
✗

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 edit
Why this is right

A 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.

watch/file-in-new-directory-reruns · correctness
✓✓✓–––
Creating an empty folder under the inputs reruns nothing
Why this is right

An 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.

watch/empty-directory-reruns-nothing · efficiency
✓✓✓–––
A change to a git-ignored file among the sources reruns nothing
Why this is right

Ignored 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.

watch/ignored-file-reruns-nothing · efficiency
✓✓✓–––
A build’s own output does not start it again
Why this is right

The 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.

watch/output-does-not-retrigger · efficiency
✓✓✓–––
A build writing a declared output folder that git does not ignore does not loop
Why this is right

Some 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.

watch/tracked-output-does-not-retrigger · efficiency
✓✓✓–––
A build that rewrites an ignored side file every run does not loop
Why this is right

A 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.

watch/ignored-side-file-does-not-loop · efficiency
✓✓✓–––
Many files changed at once, as a checkout does, rebuild once
Why this is right

Switching 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.

watch/many-edits-one-rerun · efficiency
✓✓✓–––
An editor’s save by renaming a temporary file over the source rebuilds
Why this is right

Many 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.

watch/atomic-save-reruns · correctness
✓✓✓–––
A source folder deleted and made again, as a checkout does, rebuilds from the new files
Why this is right

Tools 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.

watch/replaced-directory-reruns · correctness
✓✓✓–––
An edit made while the build is running is built right after
Why this is right

The 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.

watch/edit-during-run-not-lost · correctness
✓✓✓–––
An edit undone while its build runs leaves the output of the bytes on disk
Why this is right

The 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.

watch/edit-undone-during-run-rebuilds · correctness
✓✓✓–––
An edit made during the watch’s first build is built right after it
Why this is right

The 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.

watch/edit-during-first-run-rebuilds · correctness
✓
✗

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 fixed
Why this is right

Breaking 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.

watch/keeps-watching-after-failure · correctness
✓✓✓–––
Editing a library rebuilds it, then the app that depends on it
Why this is right

The 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.

watch/dependency-edit-rebuilds-dependants-in-order · correctness
✓✓✓–––
Editing the app rebuilds only the app, not the library it uses
Why this is right

The 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.

watch/dependant-edit-leaves-dependency · efficiency
✓✓✓–––
Editing one project reruns nothing in an unrelated one
Why this is right

Two 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.

watch/unrelated-project-not-rerun · efficiency
✓✓✓–––
Watching one app also rebuilds it when a library it depends on changes
Why this is right

Selecting 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.

watch/selected-project-watches-its-dependency · correctness
✓✓✓–––
Watching one app reruns nothing when a project outside it changes
Why this is right

The 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.

watch/unselected-project-not-rerun · efficiency
✓✓✓–––
Editing a shared file at the root that the task reads rebuilds it
Why this is right

A 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.

watch/root-input-edit-reruns · correctness
✓
✗

Nx Step 1: phase 2: after 30 s: did not run app#build

✓–––
Editing a root file no task reads reruns nothing
Why this is right

A 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.

watch/root-file-not-input-reruns-nothing · efficiency
✓✓✓–––
Changing a task’s command while watching runs the new command
Why this is right

The 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.

watch/config-change-runs-new-command · correctness
✓✓✓–––
Adding a new task to the config while watching does not rebuild the existing one
Why this is right

The 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.

watch/unrelated-config-edit-reruns-nothing · efficiency
✓
✗

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

–––
An edit saved among many writes to git-ignored files still rebuilds, once
Why this is right

A 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.

watch/ignored-burst-keeps-the-edit · correctness
✓✓✓–––
Edits to two projects, one after another, each rebuild their project and the last wins
Why this is right

A 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.

watch/edits-in-turn-each-rebuild · correctness
✓✓✓–––
Turborepo, Nx and other product names are trademarks of their owners. vx is not affiliated with or endorsed by them.