Turbo / Nx parity: behavioural contracts vx does not pin (2026-07)
Two independent research passes, run 2026-07-28 against the current upstream trees, cataloguing behaviour those runners’ own suites pin that vx has no equivalent test for. Commissioned by the owner directive “Check nx and turbo repo make sure we cover everything.”
This does not replace docs/design/turbo-nx-test-gaps.md (2026-05); it
corrects and extends it. The prysk/cram .t integration tests that document
cites no longer exist in vercel/turborepo — they were ported to Rust at
crates/turborepo/tests/*.rs (70 files, 16.6k lines), which is what the Turbo
half below cites. The fixtures still live at
turborepo-tests/integration/fixtures/.
Discipline both passes were held to
Section titled “Discipline both passes were held to”- Every item grepped against
tests/andpackages/cloud/tests/before being listed. A catalogue padded with already-covered items is worse than a short honest one. - Items already recorded as deliberate divergences in
docs/comparison.mdor the 2026-05 doc are excluded. - Each pass ends with a “checked and already covered” table (21 rows for
Turbo) and a “deliberately not listed” section naming the owner-rejected
features (
namedInputs,targetDefaults, token substitution, executor batching, globals,$TURBO_ROOT$, env modes, prune, daemon,//root tasks). Those exist so the next pass does not re-tread this ground. - HIGH means could be a wrong cache hit, a silently wrong selection, or lost work in distributed mode — not “looks important”.
Counts: Turbo 6 HIGH / 12 MED / 7 LOW; Nx 9 HIGH / 7 MED / 3 LOW.
The finding both passes reached independently
Section titled “The finding both passes reached independently”A negation-only cache.inputs.files folds ZERO file inputs.
resolveFiles() does if (positive.length === 0) return [], so
files: ['!**/*.spec.ts'] — which every gitignore-trained reader parses as
“everything except specs” — silently means nothing. The task’s key then stops
moving with its source: a permanent stale hit from a config that reads fine and
passes validation.
Turbo makes this a hard config error (two tests in bad_turbo_json_test.rs);
Nx’s filterUsingGlobPatterns pins the opposite semantics explicitly (“when
negative patterns” → everything minus these). Both passes reproduced it against
a real fixture, one through resolveFiles directly and one end-to-end through
the CLI.
Two reviewers arriving at the same defect from different upstreams, by different methods, is the strongest signal either pass produced.
Reproduced end-to-end against the real CLI
Section titled “Reproduced end-to-end against the real CLI”These are live defects, not theoretical gaps. Each was run against a throwaway fixture, not reasoned about.
-
A gitignored file named explicitly in
cache.inputs.filescontributes nothing to the key. vx’s file universe isgit ls-files --cached --others --exclude-standard, and user globs only filter that set — so an ignored path can never be filtered back in. Declared a gitignored input, edited v1→v2: the second run reportedup-to-dateand left the output at v1, while replaying the cached stdout so the run looked like it executed. Turbo pins the opposite explicitly (gitignored_inputs_test.rs). The existing vx test covers only the broad**/*case — which is correct behaviour, and hides this one. -
Two tasks in one project with overlapping
outputs.filesdestroy each other’s outputs while the run stays green. A hazard vx created with its strict output-ownership rule: Turbo restores additively and cannot hit it, which is why no Turbo test surfaces it and why this needs its own pin. Reproduced at--concurrency 1:2 success · 2 localhits withdist/one.txtsimply gone, including after wipingdist/and re-restoring. Under real parallelism, which task survives is a race. -
Negation in
cache.outputs.filesis a total silent no-op, unlikeinputs.files, which does split on!. The excluded subtree is both archived into every cache entry and deleted before every exec and restore. Both halves verified, including theoutput_filesrows. -
--affectedand the cache key disagree about what an input is.projectsContainingknows only project directories, so a change to acache.inputs.workspaceFilestarget or to any package-manager lockfile busts every affected cache key while selecting zero projects —vx run test --affectedexits 0 having run nothing. Nx runs four locators for exactly this reason.docs/cli.md:139states the violated principle verbatim: “input hashing sees it, so--affectedmust too.” -
ESSENTIAL_ENVforwardsNODE_OPTIONS,LC_ALLandCIto every child, and no cache key can see them. An implicit 24-name pass-through nobody declared and nobody can remove, three entries of which change what a task produces —NODE_OPTIONS=-r ./hook.jschanges emitted bytes, locale changessortorder.docs/schema.mdis the tell: thepassThroughbullet says “NOT folded into the cache key”, the essentials bullet directly above says nothing. -
An arbitrary file write via
git diff --output=<path>. An option-like--affected=<base>is passed through to git, which treats it as an option; the write was reproduced in a throwaway repo. Not exploitable at HEAD — but only becauseverifyRefhappens to run first and its exit 1 is fatal, a branch that was itself rewritten on 2026-07-26. Nx rejects option-like refs explicitly, before invoking git.
Recorded as landmines, not live bugs
Section titled “Recorded as landmines, not live bugs”Called out separately because inflating them would devalue the list above.
- Without
--frozen, every distributed agent live-evaluates its own TypeScript configs under its own environment.dist-hash-equality.test.tsscopes itself to “same configs” as a precondition; nothing tests, detects, or warns about the diverged case. - The
--affectedmemo is sound today precisely because the probe environment is ambient; it becomes wrong the moment that stops being true. src/workspace/affected.ts’s header comment claims<since>...HEADthree-dot semantics while the code runs a two-dotgit diff <since>. A doc/code contradiction worth fixing independently of any test.
Negative results worth keeping
Section titled “Negative results worth keeping”- vx’s flakiness rule has no gap against Nx’s. It matches Nx Cloud’s same-key-mixed-outcome signal and adds a within-run-retry signal Nx lacks.
- Symlink hashing, deleted/renamed/gitignored file handling in the hasher,
validateOutputs,--affected+--filterunion semantics, mixed valid/malicious tar entries, malformed-manifest error paths, absolute paths in inputs/outputs, and env-value plaintext capture were all checked and found already covered.
Deliberately not acted on here
Section titled “Deliberately not acted on here”These are source defects, not test gaps. Fixing the negation case changes cache
keys for every affected config, so it needs its own wave with a
CACHE_VERSION decision — see .claude/skills/bump-cache-version. Landing
them inside a test-expansion change would bury that decision.
The full per-item catalogues follow, each in the shape upstream behaviour → vx equivalent → existing coverage → proposed test → value, with source citations pinned to the commits named above.
HIGH (6) — four of the six were reproduced end-to-end against the real CLI; three are wrong cache hits / wrong output trees.
| # | Finding | Reproduced? |
|---|---|---|
| H1 | A gitignored file explicitly named in cache.inputs.files contributes nothing to the key | yes — stale hit |
| H2 | Negation-only cache.inputs.files (['!dist/**']) hashes ZERO files | yes — permanent stale hit |
| H3 | --affected is a two-dot diff, so a diverged base marks other people’s packages affected | yes — over-selection |
| H4 | Two tasks with overlapping outputs.files delete each other’s outputs; run stays green | yes — wrong output tree |
| H5 | --filter . silently selects the whole workspace | yes — wrong selection |
| H6 | Negation in cache.outputs.files is a silent no-op (captured AND deleted) | yes — inverted intent |
MEDIUM (12): M1 DOT escaping · M2 recursive vx run · M3 pkg#task vs
--filter=!pkg · M4 anchored/bare cross-product · M5 odd filenames in input
hashing · M6 empty name field · M7 watch edit-during-initial-run · M8 watch
same-content write · M9 task stdin EOF · M10 --concurrency 50% / range refs ·
M11 cache.inputs.tasks typo decoupling · M12 output shape transitions.
LOW (7): L1 symlink hash semantics · L2 ../ path filters · L3 root-member
affected · L4 fingerprint doc drift · L5 watch across git checkout · L6 prune
racing a save · L7 control-character log replay.
Two sections at the end record what was checked and already covered (21 contracts) and what is explicitly not a gap (Turbo features vx has rejected), so the next reader does not re-derive them.
H1. A gitignored file that is EXPLICITLY declared in cache.inputs.files silently contributes nothing to the cache key
Section titled “H1. A gitignored file that is EXPLICITLY declared in cache.inputs.files silently contributes nothing to the cache key”-
Turbo behaviour:
crates/turborepo/tests/gitignored_inputs_test.rs:10test_gitignored_file_in_explicit_inputs— writespackages/util/internal.txt, adds it to.gitignore, declares it in the task’sinputs, and asserts (a)internal.txtappears in the run summary’sinputsmap (“internal.txt should appear in inputs despite being gitignored”) and (b) editing it produces a cache miss with a different per-file hash.final_hash_contract.rs:365gitignored_explicit_input_hashessnapshots the same transition at the task-hash level. So in Turbo an explicitinputsentry overrides gitignore. -
vx equivalent:
src/cache/inputs.tsresolveFiles— the file universe comes fromgit ls-files --cached --others --exclude-standard, and the user’sinputs.filesglobs are a filter on top of that set. An ignored path is never in the set, so it can never be filtered back in. -
Existing vx coverage: NONE for the explicit-declaration case.
tests/inputs.test.ts:429gitignored files are excluded (workspace-root .gitignore)pins the broad-glob case (files: ['**/*']), which is the correct-and-desirable behaviour. No test declares an ignored path by name. -
Reproduced (real CLI,
packages/adeclaresinputs.files: ['generated.txt'],outputs.files: ['out.txt'],.gitignorecontainspackages/a/generated.txt):RUN1 → 1 miss, out.txt = v1edit generated.txt v1 → v2RUN2 → ┌─ a#build > up-to-date • 77a8d71a … 1 up-to-dateout.txt = v1 ← STALEThe replayed stdout even prints the task’s
RANline, so the run looks like it executed. -
Proposed test (
tests/stale-hit.test.ts, alongside the three existing real-CLI stale-hit cases): build the fixture above, run, change the gitignored declared input, run again, assert the second run is not a hit and thatout.txtholds the new content. Then a second unit case intests/inputs.test.ts:resolveInputs({ inputs: { files: ['secret.txt'] } })wheresecret.txtis gitignored returns it — or, if the decision is to keep the current semantics, a test that pins the loud failure instead (see “design options” below). -
Design options (this needs an owner call, not just a test): (a) match Turbo — union
git ls-filesoutput with an explicit non-globinputs.filesentry that exists on disk; (b) keep the exclusion but make it loud — the loader or the resolver errors when a literal (metacharacter-free)inputs.filesentry resolves to a path that exists on disk but is gitignored. Either kills the silent stale hit; (b) is cheaper and matches vx’s “explicit over magical” rule. Doing nothing is the only bad option, because the failure mode is exactly the classdocs/caching.mdopens by calling “the worst failure mode of a task runner”. -
Value: HIGH — reproduced wrong cache hit, no attacker, ordinary config (
dist/-style generated inputs consumed by a downstream task are gitignored in most repos).
H2. A negation-only cache.inputs.files hashes ZERO files — permanent stale hit
Section titled “H2. A negation-only cache.inputs.files hashes ZERO files — permanent stale hit”-
Turbo behaviour:
crates/turborepo/tests/bad_turbo_json_test.rs:338test_structured_startup_rejects_negative_only_globs_without_defaultsand:371test_structured_jit_rejects_negative_only_globs_without_defaults—inputs: [{ mode: …, globs: ["!src/generated/**"] }]is a hard config error (“negative” + “withDefaults” in stderr). Turbo refuses a glob list that can only subtract, because with no positive term it selects nothing. (The legacy form’s answer is the$TURBO_DEFAULT$sentinel — an explicit “start from the defaults, then subtract”.) -
vx equivalent:
src/cache/inputs.ts:500resolveFilessplits entries intopositive/negativeand thenif (positive.length === 0) return []. The loader (src/workspace/project-loader.ts) validatesinputs.filesis astring[]but says nothing about its content. -
Existing vx coverage: NONE.
tests/inputs.test.tscovers negation only in the mixed form (['src/**', '!**/*.test.ts'], lines 323 and 533). Nothing exercises an all-negative list. -
Reproduced (
cache: { inputs: { files: ['!dist/**'] }, outputs: { files: ['out.txt'] } }):RUN1 → 1 miss, out.txt = v1edit src/in.txt v1 → v2-CHANGEDRUN2 → 1 up-to-date (hit), out.txt = v1 ← STALE, foreverA reader of
['!dist/**']means “everything except dist” — the exact reading gitignore syntax trains them into. vx silently means “nothing”. -
Proposed test: (a) a
tests/project-loader.test.tscase asserting acache.inputs.fileslist whose every entry starts with!is REJECTED at load with a message naming the task and suggesting['**/*', '!dist/**']; (b) atests/inputs.test.tsunit case pinning that a mixed list still subtracts (control, already passing) so the fix cannot over-reject. -
Related, same mechanism, worth a second case: a positive glob that matches nothing (a typo —
files: ['sr/**']) also yields zero hashed files and the same permanent hit. Rejecting that statically is not possible, but a run-time warning (“task a#build declared inputs.files but resolved 0 files”) is cheap and would have caught both shapes. Consider pinning the warning. -
Value: HIGH — reproduced permanent wrong cache hit from a config a user would reasonably write.
H3. --affected uses two-dot git diff <base>, so a diverged base branch marks OTHER people’s packages affected — and the source comment claims the opposite
Section titled “H3. --affected uses two-dot git diff <base>, so a diverged base branch marks OTHER people’s packages affected — and the source comment claims the opposite”-
Turbo behaviour:
crates/turborepo/tests/affected_test.rstest_affected_merge_base_diverged— branchmy-branchchangesapps/my-app;mainthen independently changespackages/util; back onmy-branch,turbo ls --affectedassertsmy-appis listed andutilis NOT (“util changed on main, not branch”). Turbo diffs from the merge base (three-dot), which is also whatTURBO_SCM_BASE/TURBO_SCM_HEADfeed (test_affected_scm_base_override,test_affected_scm_head_override). -
vx equivalent:
src/workspace/affected.ts:52runsgit diff --no-renames --relative --name-only -z <since>— a two-dot diff of<since>’s tip against the working tree. The file’s own header comment (line 4) says “The diff is<since>...HEADplus working-tree changes” — three-dot notation, which the code does not implement.docs/cli.md§--affectedalso doesn’t state which. -
Existing vx coverage: NONE.
tests/affected.test.tscovers committed-only history, staged-only, working-tree-only, deletes, renames, many-commit ranges anddefaultAffectedBase— but every case is a linear history. No test creates a second branch that moves the base ref forward. -
Reproduced:
main: initmy-branch: change packages/app/src/in.txt (commit)main: change packages/util/src/in.txt (commit)my-branch: vx run build --affected=main --dry→ app#build cache miss — would executil#build cache miss — would exec ← util changed on MAINgit diff --name-only main → app, util (what vx runs)git diff --name-only main...HEAD → app (merge-base / turbo) -
Why it matters: the flagship CI recipe is
vx run test --affected=origin/mainon a PR. Every merge intomainwhile the PR is open widens vx’s affected set by everyone else’s changes, so on a busy monorepo--affectedconverges on “run everything” — the exact cost the flag exists to avoid. The direction is safe (over-selection, never a stale hit), which is why it can sit unnoticed indefinitely. -
Proposed test (
tests/affected.test.ts): build the two-branch fixture above and assertaffectedProjects({ since: 'main' })contains the branch-changed project and not the main-only one; plus a control that a working-tree edit on the branch is still picked up (merge-base diff must not drop uncommitted work — this is why the fix isgit merge-base <since> HEADthen diff against that, not a baregit diff <since>...HEAD, which would drop the worktree). -
Value: HIGH — silently-wrong (over-broad) selection on the documented CI path, plus a source comment that asserts the behaviour the code lacks. Fixing it is ~3 lines (
git merge-base --fork-point-less:git merge-base <since> HEAD→ diff against the result); the test is the load-bearing part.
H4. Two tasks in one project declaring OVERLAPPING cache.outputs.files destroy each other’s outputs — the run stays green
Section titled “H4. Two tasks in one project declaring OVERLAPPING cache.outputs.files destroy each other’s outputs — the run stays green”-
Turbo behaviour: Turbo restores additively and never wipes, so overlapping outputs are merely ambiguous, not destructive. It still pins that output paths are attributed per-task/per-package:
crates/turborepo/tests/run_caching.rs:1348test_dependency_outputs_distinguishes_cross_package_output_paths(two packages each writingdist/generated.txtmust stay distinguishable), and:1708test_gitignored_output_deletion_restores_from_cache(a deleted output is restored on a hit). vx’s strict output ownership (docs/caching.md§ “Strict output ownership”: declared outputs are wiped before exec and before restore) is a vx-only rule — and therefore a vx-only failure mode that no Turbo test can surface. That is precisely why it needs its own pin. -
vx equivalent:
src/cache/inputs.tscleanOutputs, called fromsrc/orchestrator/execute-task.tson both the miss path (pre-exec) and the hit path (pre-restore), wheneverwillWrite. -
Existing vx coverage: NONE.
tests/inputs.test.tspins thatcleanOutputsremoves declared globs, does not touch undeclared files, and respects project boundaries — all single-task properties. No test has two tasks whose output globs intersect. -
Reproduced (one project,
gen1→dist/one.txt,gen2→dist/two.txt, both declaringoutputs.files: ['dist/**'], run with--concurrency 1so it is deterministic rather than a race):RUN1 cold → 2 success · 2 miss dist/ = two.txt ← one.txt goneRUN2 warm → 2 success · 2 local dist/ = two.txtrm -rf distRUN3 warm → 2 success · 2 local dist/ = two.txt ← restore did not fix itgen1reports a green cache hit and its artifact is then deleted bygen2’s pre-restore clean. Nothing in the run output hints that half the declared output tree is missing. Under real parallelism which task survives is nondeterministic. -
Proposed test:
tests/inputs.test.ts(or a newtests/output-ownership.test.ts) — the fixture above, driven through the real orchestrator at--concurrency 1, assertingdist/one.txtanddist/two.txtboth exist after a cold run and after a warm restore. It will fail today; that is the point. Pair it with a loader-level case once the remediation is chosen. -
Remediation options (needs an owner call): (a) reject at config load — two tasks in the same project whose
outputs.filesglob sets can intersect is a config error (cheapest, matches “explicit over magical”, and overlapping outputs are almost always a modelling mistake); (b) narrow the clean to the files the task’s own cache entry recorded (output_filesrows) rather than re-globbing, which keeps the strict-ownership guarantee for a task’s own files without claiming its siblings’; (c) document the hazard and pin (a test that asserts today’s destructive behaviour, so at least it is a decision). -
Value: HIGH — green run, silently wrong output tree, ordinary config (two codegen tasks writing into one
dist/), and it is a hazard vx created with a rule it advertises as a differentiator.
H5. --filter . is accepted, undocumented, and silently selects the ENTIRE workspace
Section titled “H5. --filter . is accepted, undocumented, and silently selects the ENTIRE workspace”-
Turbo behaviour:
crates/turborepo/tests/infer_pkg_test.rs—test_infer_from_packages_subdir(-F '{./util}'run frompackages/selectsutil),test_filter_sibling_directory(-F '../apps/*'frompackages/selectsmy-app),test_cwd_overrides_inference. Turbo resolves path filters relative to the invocation cwd, so././x/../xmean what a shell user expects. pnpm’s filter DSL — which vx’s is modelled on — is the same. -
vx equivalent:
src/workspace/filter.ts:74—if (s.startsWith('./') || s === '.') { isPath = true; matcher = path.resolve(workspaceRoot, s) }. Path filters resolve against the workspace root.docs/cli.mddocuments./<dir>as “relative to workspace root” (a deliberate, defensible choice) but does not document the bare.form at all — andpath.resolve(root, '.')is the root itself, which is “at or under” every project dir. -
Existing vx coverage: NONE for
..tests/filter.test.tscovers./<dir>,{<dir>}and an absolute path; no case passes a bare.. -
Reproduced (workspace with
packages/util+apps/web, cwd =packages/util):vx run build --filter . --dry → util#build AND web#build (2 tasks, exit 0)vx run build --dry → util#build (cwd inference, correct)So the flag a user reaches for to narrow to the current package instead widens to everything, silently, exit 0. By contrast
--filter ./utilfrompackages/fails loud (vx: filter "./util" matched no projects), which is why only the.form is dangerous. -
Proposed test (
tests/filter.test.ts+ one CLI e2e): assertparseFilter('.', root)/ the resolved selection from inside a package dir. Whatever the chosen semantics, pin them: either (a) make.cwd-relative like the rest of the ecosystem (then--filter .frompackages/utilselects exactlyutil), or (b) reject bare.with a message pointing at--filter ./<dir>and plain cwd inference. Add a../-prefixed case at the same time —--filter '../apps/*'is not recognised as a path form at all today and falls through to the NAME matcher. -
Value: HIGH — silently-wrong selection, exit 0, no warning, and the direction is “runs the whole monorepo when you asked for one package”. Lower blast radius than a stale hit (nothing is cached wrongly), but there is no signal at all that it happened.
H6. Negation in cache.outputs.files is a SILENT NO-OP — the excluded tree is both captured into the artifact and deleted before every run
Section titled “H6. Negation in cache.outputs.files is a SILENT NO-OP — the excluded tree is both captured into the artifact and deleted before every run”-
Turbo behaviour:
crates/turborepo-lib/src/task_graph/visitor/mod.rs:1157CompiledOutputGlobs::newsplits every output glob on a leading!intoinclusions/exclusions, andmatches()returnsfalsefor any path an exclusion matches.["dist/**", "!dist/cache/**"]means what it reads like. -
vx equivalent:
src/cache/inputs.ts:260resolveOutputspassesargs.outputsstraight toscanUnionwith no!handling — unlikeresolveFiles(line 500) andresolveWorkspaceFiles(line 151), which both split on!. So a!-prefixed entry is compiled as a positiveBun.Globwhose first literal character is!, matching nothing, and the tree the user meant to exclude stays fully inside thedist/**inclusion.cleanOutputs(line 285) resolves through the same function, so it deletes it too. -
Existing vx coverage: NONE.
tests/inputs.test.tsexercises negation only ininputs.files. NoresolveOutputs/cleanOutputscase passes a!entry, anddocs/schema.mddoes not say whether outputs support negation. -
Reproduced (
outputs.files: ['dist/**', '!dist/cache/**'], task writesdist/app.jsanddist/cache/state.bin):RUN1 → dist/cache/state.bin = INCREMENTAL-STATEedit an input → cache miss → RUN2 → dist/cache/state.bin = DELETEDsqlite output_files rows after a save → dist/app.jsdist/cache/state.bin ← captured anywayBoth halves of the intent are inverted: the excluded subtree is archived into every cache entry (artifact bloat, and machine-specific incremental state travels through a shared remote cache) and destroyed before every exec and every restore.
-
Proposed test:
tests/inputs.test.ts—resolveOutputs({ outputs: ['dist/**', '!dist/cache/**'] })excludesdist/cache/*, andcleanOutputswith the same globs leavesdist/cache/state.binon disk while removingdist/app.js. Add atests/orchestrator.test.tse2e asserting a cache hit does not resurrect an excluded path. If the decision is instead to reject!in outputs, pin the loader rejection — the one unacceptable outcome is the current silent inversion. -
Value: HIGH — silent inversion of an explicit user instruction, with a destructive side effect (deletes files the user named as “do not touch”) and a cache-content side effect. Not a wrong hit, which is why it is last in HIGH.
MEDIUM
Section titled “MEDIUM”M1. --graph DOT output does not escape task ids
Section titled “M1. --graph DOT output does not escape task ids”- Turbo behaviour:
crates/turborepo/tests/graph_test.rstest_graph_to_html_escapes_task_names— a fixture declaring tasks literally namedback`tick,interpolate${globalThis.alert(1)}andbreak</script>outis rendered to--graph=graph.html; the test asserts the dangerous forms are escaped (break</script>out) and that the template literal is not broken out of. - vx equivalent:
src/cli/plan-format.ts:111formatGraphDotinterpolatest.node.iddirectly into DOT quoted strings:` "${t.node.id}" [label="${label}", …]`and` "${dep}" -> "${t.node.id}";`. Task names are arbitrary object keys invx.config.ts, so",\and newlines are all reachable. - Existing vx coverage: NONE.
tests/plan-format.test.tshasemits a valid digraph with edges + per-status fillcolorbut only with plain ids. - Proposed test: a
formatGraphDotcase over a plan whose task name contains"and\(and one with a newline), asserting the emitted document still has balanced quotes / one statement per line — i.e. escape\and"in both the node id and the label. Same class as the already-fixed GHA workflow-command escaping and therun-report.tsmarkdowncell()escaper. - Value: MED — produces a malformed artifact rather than a wrong result, but vx has shipped this exact bug twice before in sibling formatters.
M2. A task whose command re-invokes vx run is not detected — infinite recursion
Section titled “M2. A task whose command re-invokes vx run is not detected — infinite recursion”- Turbo behaviour:
crates/turborepo/tests/recursive_turbo_test.rstest_recursive_turbo_invocation_detected— a package script that callsturbo runis refused withrecursive_turbo_invocations+ “creating a loop”.missing_tasks_test.rs:test_no_recursive_turbo_warning_for_missing_taskpins the converse (no false positive when the task simply doesn’t exist). - vx equivalent: none.
grepoversrc/orchestrator/run.ts,src/cli/run.tsandsrc/exec/env.tsfinds no depth counter, no sentinel env var, no guard.buildIsolatedEnv’s essentials allowlist forwardsPATH, andexecute-taskprepends the project’snode_modules/.bin, so a task whose command isvx run buildre-enters the full orchestrator. - Existing vx coverage: NONE.
- Proposed test: an e2e where
a#build’s command isvx run build(resolved via the repo’s own bin) and the run is expected to fail fast with a message naming the loop — implemented by exporting aVX_RUN_DEPTH(or a run-id sentinel) inbuildIsolatedEnvand refusing above a small bound. Pair with a control that a task callingvx --versionorvx showis unaffected. - Value: MED — not a correctness bug, but the symptom (fork bomb / machine
wedge) is severe and the trigger is an ordinary migration mistake
(
"build": "vx run build"left in a package.json script, or a group task shelling out).
M3. pkg#task overriding an excluding --filter=!pkg is unpinned
Section titled “M3. pkg#task overriding an excluding --filter=!pkg is unpinned”- Turbo behaviour:
crates/turborepo/tests/pkg_task_entry_test.rspkg_task_syntax_filter_exclusion_overridden—turbo run web#build --filter=!webstill runsweb#build(+ its deplib#build), because apkg#taskentry adds its package regardless of filter exclusions. - vx equivalent:
src/graph/task-graph.ts:132expandRequestedresolves an anchored spec directly againstprojects, ignoringcandidatesentirely, andsrc/cli/run.ts:441applies project scope to bare names only. So vx already behaves like Turbo — but by construction, not by contract. - Existing vx coverage:
tests/task-selection.test.tscovers “an anchored task still runs when a co-requested bare task selects nothing” (an empty scope) and “an anchored task naming an unknown PROJECT fails the run”. No test passes a filter that explicitly excludes the anchored package. - Proposed test:
vx run a#build --filter '!a'runsa#build; control:vx run build --filter '!a'does not. - Value: MED — the behaviour is correct today; the test stops a future filter refactor from silently making an explicit request unrunnable.
M4. pkg#task does NOT widen the scope of a co-requested bare task (Turbo’s cross-product) — unpinned either way
Section titled “M4. pkg#task does NOT widen the scope of a co-requested bare task (Turbo’s cross-product) — unpinned either way”- Turbo behaviour:
pkg_task_entry_test.rspkg_task_syntax_union_with_filter_cross_product—turbo run build --filter=docs web#lintrunsdocs#build,web#lint,web#buildandlib#build:webenters the package scope via theweb#lintentry, and the barebuildthen fans out over the widened scope. - vx equivalent:
expandRequestedfans a bare name overcandidates, andcandidatescome only from the filter/--all/cwd inference — anchored entries never widen it. Sovx run build --filter docs a#lintrunsdocs#buildanda#lint, but nota#build. - Existing vx coverage: NONE — no test mixes an anchored entry with a bare entry under a filter.
- Proposed test: pin vx’s narrower semantics explicitly (
a#buildis NOT pulled in), with a comment citing the Turbo cross-product so the divergence is a recorded decision rather than an accident. vx’s reading is the more predictable one and should not be “fixed” — but it should be stated. - Value: MED — a silent selection difference for anyone migrating a
turbo run build web#lintinvocation.
M5. Input hashing has no coverage for filenames with spaces / quotes / non-ASCII
Section titled “M5. Input hashing has no coverage for filenames with spaces / quotes / non-ASCII”- Turbo behaviour:
crates/turborepo/tests/path_with_spaces_test.rstest_files_with_spaces_can_be_hashed— creatingpackages/util/with spaces.txtmust not break hashing or the dry run. - vx equivalent:
src/cache/inputs.tsenumerates withgit ls-files -s --others --exclude-standard -z, so-zshould make these safe;src/cache/tar.tsthen has to round-trip the name through the artifact. - Existing vx coverage: partial and in the wrong place.
tests/affected.test.ts:240covers a non-ASCII changed filename for--affected;tests/inputs.test.tsandtests/artifact-roundtrip.test.tshave no space/quote/non-ASCII case at all (grep forspacesin those files returns nothing). - Proposed test: one
tests/inputs.test.tscase that a file namedwith spaces.txt, one namedcafé.tsand one containing a"all enter the resolved input set and contribute to the key; onetests/artifact-roundtrip.test.tscase that such names round-trip through pack → extract byte- and name-identically (the GNU-tar long-name path is already pinned; odd characters are not). - Value: MED — the enumeration side is very likely already correct
(
-z), so this is a regression guard on the tar round-trip more than a suspected live bug.
M6. A member package.json with an EMPTY name is silently skipped
Section titled “M6. A member package.json with an EMPTY name is silently skipped”- Turbo behaviour:
crates/turborepo/tests/invalid_package_json_test.rstest_empty_name_field— setting"name": ""on a workspace member is a hard failure:package.json must have a name field. (test_malformed_package_jsonadditionally requires the parse error to name the offending path — vx already covers that,tests/workspace.test.ts:186.) - vx equivalent:
src/workspace/workspace.tsskips a member with no usable name; the decision log records that a nameless member now WARNS, but only when the dir has avx.config.*.""is falsy, so it takes the same path as missing. - Existing vx coverage:
tests/workspace.test.ts:141covers'{}'(no name → skipped). No case sets"name": "", and none asserts the warning fires for a config-bearing member. - Proposed test: a member with
{"name": ""}and avx.config.mjswarns (or errors) naming the directory, rather than vanishing from the project list — a vanished project means its tasks silently never run, whichtests/task-selection.test.tsshows vx otherwise treats as a hard error. - Value: MED — silent under-selection, but needs a malformed manifest to trigger.
M7. vx watch — an edit landing during the INITIAL run is dropped, and nothing pins it
Section titled “M7. vx watch — an edit landing during the INITIAL run is dropped, and nothing pins it”- Turbo behaviour:
crates/turborepo/tests/watch_test.rswatch_edit_during_build_triggers_rebuild(an edit while a build is in flight must produce a rebuild) andwatch_rapid_edits_produce_single_rebuild,watch_no_concurrent_builds_of_same_package,watch_no_spurious_rebuild_after_settle. - vx equivalent:
src/cli/watch.tsinstallsfs.watchhandles only after the initial run’s output is flushed. The decision log (2026-05-26 entry on the watch flake) records this as a known, deliberately-unfixed product gap: “an edit made duringvx watch’s initial run is silently dropped”. Edits during subsequent cycles are handled by thependingreentrancy guard. - Existing vx coverage: the five
tests/cli.test.tswatch e2e cases all wait for thewatching N project(s)readiness line before writing — i.e. they deliberately avoid the window. Nothing pins the dropped-edit behaviour. - Proposed test: write to a watched input before the readiness line appears and assert the documented outcome (today: no re-run; after a fix: a re-run). Even pinning today’s behaviour is valuable — it converts an undocumented data-loss window into a decision with a failing test the day someone closes it.
- Value: MED — known-open, no test either way, and the symptom (“I saved and nothing happened”) is the single most confusing thing a watcher can do.
M8. vx watch — a same-content write should not re-execute
Section titled “M8. vx watch — a same-content write should not re-execute”- Turbo behaviour:
watch_test.rswatch_same_content_write_does_not_rebuild. - vx equivalent: vx re-runs the whole orchestrator on every debounced event and lets the cache absorb it — so the task should not re-execute (the key is unchanged), but a full discovery + hash cycle does run.
- Existing vx coverage: NONE. The watch e2e cases all change content.
- Proposed test:
touch/rewrite an input with identical bytes during a watch session and assert the follow-up cycle reports a cache hit and the command’s side-effect marker did not increment. This is the test that would catch a future mtime/ctime-sensitive regression in thefile_hashesmemo (which the SCHEMA v24 entry shows is a live risk area). - Value: MED.
M9. A task that reads stdin must see EOF and never hang
Section titled “M9. A task that reads stdin must see EOF and never hang”- Turbo behaviour:
crates/turborepo/tests/stdin_eof_startup_test.rsnonpersistent_task_sees_eof_on_stdin_in_stream_mode— spawns a real run with a 15 s timeout and asserts the task printedstdin bytes=0, i.e. it saw EOF rather than blocking. - vx equivalent:
src/exec/runner.ts:289and:440, andsrc/exec/sandbox-runtime.ts:97/:323, all passstdin: 'ignore'— the correct, hang-free choice. - Existing vx coverage: NONE —
grep stdin tests/runner.test.tsis empty. - Proposed test: a task whose command is
cat; echo "bytes=$?"(orhead -c 1 || true) completes well inside the test timeout and reports empty stdin — a cheap guard on a property that is one word ('inherit') away from a permanent CI hang, in a codebase whose decision log records four separate end-of-run hang fixes. - Value: MED.
M10. --concurrency percentage form and range-shaped git bases are rejected — pin the messages
Section titled “M10. --concurrency percentage form and range-shaped git bases are rejected — pin the messages”- Turbo behaviour: Turbo accepts
--concurrency=50%and git ranges in the filter DSL —crates/turborepo/tests/filter_run_test.rstest_filter_git_range_two_dot_committed_changeuses--filter=[HEAD^..HEAD]alongside the single-ref--filter=[HEAD^]form. - vx equivalent:
--concurrency 50%→vx run: invalid concurrency: 50%(strict decimal-integer parse, a deliberate 2026-07-26 decision);--affected=HEAD~1..HEAD/--filter '[HEAD~1..HEAD]'→vx run: git ref "HEAD~1..HEAD" did not resolve. Pass a branch or commit you have locally.Both fail loudly and correctly (verified). - Existing vx coverage:
tests/cli-arg-hygiene.test.tspins numeric-flag strictness generally; nothing pins these two migration-shaped inputs. - Proposed test: two parser/e2e cases asserting the exact refusal, and improving the range message to say a range is not supported (today it says the ref “did not resolve”, which sends a Turbo migrant looking for a missing branch). Cheap, and it is the difference between a 30-second and a 30-minute migration.
- Value: MED — pure DX, but both are things a Turbo user types on day one.
M11. cache.inputs.tasks entries that match no upstream silently decouple the task
Section titled “M11. cache.inputs.tasks entries that match no upstream silently decouple the task”- Turbo behaviour: no direct analogue (Turbo has no upstream-hash filter);
the closest contract is
run_caching.rs:1600test_dependency_outputs_globs_cannot_select_undeclared_outputsand:1667test_dependency_outputs_selected_dependency_must_declare_outputs, where adependencyOutputs.fromselector that matches nothing is a hard error with remediation text (“Add outputs to … or remove it from dependencyOutputs.from”). Turbo refuses to silently narrow a dependency selector to nothing. - vx equivalent:
src/orchestrator/upstream.tsfilterUpstreamHashes— a filter matching no upstream folds zero upstream hashes, which is exactly “fully decoupled from my dependencies” and is a documented stale-hit vector (docs/caching.mdstep 10). - Existing vx coverage: PARTIAL —
tests/upstream.test.tspinsa pattern matching no project selects nothingandempty filter → nothing contributes (fully decoupled)at the unit level. What is missing is the typo shape: a bare exact name (['buidl']) that matches no upstream task, and any e2e that shows the resulting stale hit. - Proposed test: an e2e where a consumer declares
cache.inputs.tasks: ['buidl'](typo forbuild), the upstream’s source changes, and the consumer still hits — then decide whether an exact-name entry that matches nothing should warn or error (patterns must stay silent, per the 2026-07-10 wildcard decision). - Value: MED — real stale-hit vector, but it needs a typo in an advanced-usage field, and half the mechanism is already pinned.
M12. Sequential restore across an output-shape change (file ↔ directory ↔ symlink)
Section titled “M12. Sequential restore across an output-shape change (file ↔ directory ↔ symlink)”- Turbo behaviour:
crates/turborepo-cache/src/cache_archive/restore.rstest_sequential_restores_symlink_then_directory— a restore that must replace a symlinked output with a real directory (and the reverse). - vx equivalent:
cleanOutputs+extractOutputs.cleanOutputscallsrm(f, { force: true })on each file path resolved from the globs; a path that is now a directory where the cached entry holds a file (or a symlink where a directory is expected) exercises a different branch ofsrc/cache/tar.ts. - Existing vx coverage:
tests/tar-security.test.tscovers the adversarial symlink cases (pre-existing symlink not followed, directory containment) andtests/cache.test.tscoversisOutputsCurrentmode/size mismatch. Nothing drives a benign shape transition end-to-end through two real runs. - Proposed test: run 1 produces
dist/outas a directory; change inputs so run 2 producesdist/outas a file; then restore run 1’s entry from cache and assert the tree matches run 1 exactly. Repeat with a symlink. - Value: MED — the v25 history shows this area produces silent, non-self-healing corruption when it goes wrong.
L1. Symlinked input hashing semantics are unpinned (and the doc’s claim is inaccurate for symlinks)
Section titled “L1. Symlinked input hashing semantics are unpinned (and the doc’s claim is inaccurate for symlinks)”- Turbo behaviour:
crates/turborepo-scm/src/package_deps.rstest_hash_symlink— pins that a symlink hashes to git’s blob of the link target string. - vx equivalent: symlinks never get a trusted index OID (per
docs/caching.md), so they fall through toCache.hashFile, which reads viaBun.fileand therefore hashes the target’s content. Verified: changing the content of a file outside the project that a declared input symlinks to produces a cache MISS (safer than git semantics, but different from the documented claim that the fallback “is the same value the index holds”). - Existing vx coverage:
tests/inputs.test.tscovers broken/cyclic symlinks not crashing. Nothing pins the hash semantics. - Proposed test: pin the reproduced behaviour (target-content change ⇒ miss)
and correct the sentence in
docs/caching.mdthat claims fallback/index parity, adding “except symlinks”. - Value: LOW — behaviour is safe; the doc is wrong and the property is load-bearing for anyone reasoning about the v20 OID design.
L2. --filter path forms: ../-prefixed patterns are not recognised as paths
Section titled “L2. --filter path forms: ../-prefixed patterns are not recognised as paths”- Turbo behaviour:
infer_pkg_test.rs::test_filter_sibling_directory—-F '../apps/*'frompackages/selectsmy-app. - vx equivalent:
src/workspace/filter.ts:74treats only./…,.,{…}and absolute paths as path filters;../apps/*falls through to the name matcher and matches nothing. Verified: fails loud (vx: filter "../apps/*" matched no projects). - Existing vx coverage: NONE.
- Proposed test: pin the loud refusal (and, if
./cwd-relativity is fixed per H5, decide whether../becomes a path form too). - Value: LOW — loud failure, and
docs/cli.mdonly ever documents./<dir>.
L3. Root-level file change and the root project’s affected status
Section titled “L3. Root-level file change and the root project’s affected status”- Turbo behaviour:
filter_run_test.rs::test_filter_git_range_with_unstaged— an unstaged edit to a root-levelbar.txtputs the root package (//) in scope;affected_test.rs::test_root_package_json_change_does_not_globally_affect_taskspins that a rootpackage.jsonedit does not affect every task. - vx equivalent:
src/workspace/affected.tsprojectsContainingmaps a changed path to its deepest owning project; the workspace fingerprint (src/workspace/fingerprint.ts) deliberately excludes rootpackage.json(“hashed per-project via projectPackageJsonHash, not here”), so vx already matches Turbo’s non-global behaviour. - Existing vx coverage:
tests/affected.test.tscovers “ignores changes outside any project directory”. No case has the workspace root as a member (the"."member layout this repo itself uses) and asserts that a root-level edit selects the root project and nothing else. - Proposed test: a
"."-member workspace where editing a root file selects only the root project, and editing rootpackage.jsondoes not select the other members. - Value: LOW — believed correct; worth pinning because the
"."member is load-bearing for this repo’s own config.
L4. docs/caching.md claims the workspace fingerprint covers package.json’s workspaces field — it does not
Section titled “L4. docs/caching.md claims the workspace fingerprint covers package.json’s workspaces field — it does not”- Turbo behaviour: n/a (documentation accuracy item found while checking
test_root_package_json_change_does_not_globally_affect_tasks). - vx equivalent:
docs/caching.md§ Invalidation paths says “Editpnpm-workspace.yamlorpackage.json’sworkspacesfield → step 3 (workspace fingerprint)”.WORKSPACE_FINGERPRINT_FILEScontains nopackage.json. Editing theworkspacesarray changes keys only indirectly (project membership changes which dirs are nested-project-excluded from a parent’s globs), which is enough for correctness but is a different mechanism than the doc names. - Existing vx coverage: NONE (no test asserts either the doc’s claim or the real mechanism).
- Proposed test: the drift-guard style already used by
tests/schema-doc-drift.test.ts/tests/output-doc-drift.test.ts— assert the documented invalidation triggers against the real fingerprint file list. - Value: LOW — doc accuracy only; no wrong behaviour.
L5. vx watch and a git branch switch
Section titled “L5. vx watch and a git branch switch”- Turbo behaviour:
crates/turborepo-filewatch/src/hash_watcher.rstest_switch_branch/test_switch_branch_with_inputs— agit checkoutthat rewrites many files must re-hash correctly. - vx equivalent:
vx watchre-runs the orchestrator from scratch each cycle, so a checkout is just a burst of fs events collapsed by the debounce. - Existing vx coverage: NONE (the 2026-05 gap doc flagged this; still uncovered — the five watch e2e cases only write single files).
- Proposed test: during a watch session,
git checkouta branch that changes an input, and assert exactly one re-run with the new content. - Value: LOW — cheap, and it is the one watch scenario where the debounce window is stressed by hundreds of simultaneous events.
L6. Cache pruning concurrent with a save
Section titled “L6. Cache pruning concurrent with a save”- Turbo behaviour:
crates/turborepo-cache/src/fs.rseviction tests (test_evict_removes_stale_entries,test_evict_by_size_removes_oldest_first) plus the concurrency suite (test_concurrent_writes_same_hash,test_read_during_write) — all verified present at this commit. - vx equivalent:
Cache.prunedeletes in one transaction + parallelrm;restoreOutputswas hardened in v25 to throw when the artifact vanished mid-restore, “reachable via a concurrentvx cache prune”. - Existing vx coverage:
tests/cache.test.tscovers concurrent writers and concurrent same-hash saves. Nothing runs a prune while a save/restore is in flight — i.e. nothing exercises the exact race the v25 note names as the motivation for the throw. - Proposed test: start a restore, prune the entry underneath it, assert the run fails loudly (never a green hit over an emptied tree) and that the cache stays internally consistent.
- Value: LOW — the guard exists; the race that motivated it is untested.
L7. --output-logs and cache-hit log replay fidelity for control characters
Section titled “L7. --output-logs and cache-hit log replay fidelity for control characters”- Turbo behaviour:
run_logging.rs::test_log_prefix_modesasserts the cached log file contains exactly what the live run printed (no prefixes leak into the stored log), and that a replay reproduces it. - vx equivalent: vx stores stdout twice (artifact +
entries.stdout) and replays from SQL on a local hit. - Existing vx coverage:
tests/output-flow.test.tsandtests/status-line.test.tspin the framed shapes; the artifact round-trip pins byte-identity for a 20 000-line stdout (2026-07-27 entry). Not covered: stdout containing\rprogress bars, raw ANSI, or a NUL byte surviving the SQLiteTEXTcolumn round-trip identically. - Proposed test: a task emitting
\r-heavy and ANSI-heavy output; assert the replayed bytes equal the executed bytes exactly. - Value: LOW — the 20 000-line pin already covers the volume case; this is the encoding case.
Checked and already covered (do not re-derive)
Section titled “Checked and already covered (do not re-derive)”Each of these was a plausible Turbo-sourced gap; a grep of /home/user/vx/tests/
showed vx already pins it, so it is deliberately not listed above.
| Turbo contract | vx coverage |
|---|---|
| Mixed valid + malicious tar entries → whole archive rejected, no partial extract | tests/tar-security.test.ts:505 |
Malformed member package.json error names the offending path | tests/workspace.test.ts:186 |
Absolute paths rejected in inputs / outputs | tests/project-loader.test.ts:172, :298 |
| Deleted output restored on a cache hit | tests/orchestrator.test.ts:1185 |
| Env values never persisted in plaintext by the key-capture side channel | tests/cache.test.ts:454 |
--affected + --filter stack as a UNION (Turbo intersects) | tests/filter.test.ts “stacked: —filter ui —filter [main] unions…” + docs/comparison.md § Filter DSL — deliberate divergence |
Non-existent --filter name / typo’d task name refuses the run | tests/task-selection.test.ts (5 cases), tests/cli.test.ts |
pkg#task still runs when a co-requested bare task’s scope is empty | tests/task-selection.test.ts |
Negation in cache.inputs.files subtracts | tests/inputs.test.ts:323, :533 |
gitignored files excluded from a broad **/* input glob | tests/inputs.test.ts:429 |
Untracked-but-not-ignored files participate; nested .gitignore anchoring; .git/info/exclude | tests/inputs.test.ts (git ls-files block) |
| Cross-project rename selects both source and destination projects | tests/affected.test.ts |
| Non-ASCII changed filename selects its project | tests/affected.test.ts:240 |
| Cycle detection (self, cross-project) | tests/task-graph.test.ts |
dependsOn rejects filter-only forms (*, ^*, !name) | tests/task-graph.test.ts |
Concurrency cap, --concurrency 1 serialization, failed-dep skip cascade | tests/scheduler.test.ts |
| SIGINT/SIGTERM teardown, no orphaned children | tests/signal-handling.test.ts |
| Task timeout kills and is not cached | tests/task-timeout.test.ts |
--dry never executes; --dry=json field set | tests/cli.test.ts, tests/plan-format.test.ts |
| Lockfile change invalidates every task | tests/cache.test.ts (workspace fingerprint) |
| Config change invalidates the task (resolved-config hash) | tests/config-staleness.test.ts |
Explicitly NOT a gap (Turbo features vx has rejected or scoped out)
Section titled “Explicitly NOT a gap (Turbo features vx has rejected or scoped out)”globalDependencies / global.inputs / globalEnv (owner-rejected 2026-07-05,
“no global” — TS presets are the mechanism); $TURBO_ROOT$ / $TURBO_DEFAULT$
token substitution (vx uses real paths + workspaceFiles); env modes
(--env-mode=strict|loose); framework inference; turbo prune; the daemon;
root // package tasks and the include-vs-exclude-only filter-mode
classification that gates them; futureFlags (strictTaskEntrypointSelection,
experimentalTaskCommand, structured inputs modes / dependencyOutputs);
boundaries/tags; turbo query (GraphQL); Cargo-workspace support. Each is
already recorded as out-of-scope or rejected in docs/comparison.md.
Nx behavioural contracts vx has no test for
Section titled “Nx behavioural contracts vx has no test for”Research date: 2026-07-28. Source: nrwl/nx @ master, sparse-cloned to
scratchpad/nx/packages/nx/src (full source incl. *.spec.ts).
Scope note. docs/design/turbo-nx-test-gaps.md (2026-05-17) already catalogues
~165 properties, and many of its “MISSING” rows have since shipped (--continue,
dependsOn wildcards, workspaceFiles, runtime inputs, --cache-dir, presigned
URLs, tar-security hardening). This document deliberately does not re-list any
of those. Everything below was (a) read out of an Nx spec file in this clone and
(b) grep-verified as uncovered in /home/user/vx/tests/ and
/home/user/vx/packages/cloud/tests/.
Ranked HIGH → MED → LOW.
H1. A negation-only cache.inputs.files silently folds ZERO file inputs
Section titled “H1. A negation-only cache.inputs.files silently folds ZERO file inputs”-
Nx behaviour:
filterUsingGlobPatternstreats positive patterns as an OR-set and negative patterns as an AND-filter on top of the full file set. Its explicit testshould OR all positive patterns and AND all negative patterns (when negative patterns)(packages/nx/src/hasher/task-hasher.spec.ts:272) passes ONLY negative patterns['!{projectRoot}/**/*.spec.ts', '!{projectRoot}/**/*.md']against four files and expects['root/a.ts', 'root/b.js']— i.e. negation-only means “everything minus these”, never “nothing”. Implementation (hasher/task-hasher.ts,filterUsingGlobPatterns) short-circuits to the full set when the positive list is empty. -
vx equivalent:
resolveFiles()insrc/cache/inputs.ts. It splits the declared list intopositive/negativeon a leading!, then:if (positive.length === 0) return []So
cache.inputs.files: ['!**/*.spec.ts']resolves to zero file inputs. The loader (src/workspace/project-loader.ts:309-390) validates only that entries are non-empty, non-absolute,..-free strings — a negation-only list passes.Confirmed by execution, not by reading — a real git fixture driven through the real
resolveInputs:["src/**"] -> [ "src/a.spec.ts", "src/a.ts" ]["src/**","!**/*.spec.ts"] -> [ "src/a.ts" ] # negation works WITH a positive["!**/*.spec.ts"] -> [ ] # negation-only -> ZERO inputs[] -> [ ] -
Existing vx coverage: NONE for the negation-only case.
tests/inputs.test.ts:316(negation in inputs.files strips matched files) and:526(git path) both pass a positive glob alongside the negation.tests/inputs.test.ts:276pinsfiles: []→[], but that is the explicitly empty case, not the negation-only case, and it pins the footgun rather than guarding it. -
Proposed test: In
tests/inputs.test.ts,resolveInputswithinputs: { files: ['!**/*.spec.ts'] }over a project containingsrc/a.tsandsrc/a.spec.ts. Assert the CHOSEN contract explicitly — either (a)['src/a.ts'](Nx/Turbo semantics: implicit**/*base) or (b) the loader throws aUserErrornaming the “declare at least one positive glob” rule. Do not leave it silently returning[]. Add the mirror case forcache.inputs.workspaceFiles. Pair with an e2e intests/stale-hit.test.ts: build withfiles: ['!**/*.md'], changesrc/index.ts, re-run, assert vx does NOT reportup-to-date. -
Value: HIGH — silently-zero inputs means a task’s key never moves with its source. That is a permanent stale cache hit, produced by a config that reads perfectly reasonable and passes validation. Same failure class as the four stale-hit defects fixed 2026-07-26.
H2. A runtime input is memoized per (projectDir, command) but never sees the task’s exec.env
Section titled “H2. A runtime input is memoized per (projectDir, command) but never sees the task’s exec.env”- Nx behaviour: Nx has a dedicated regression test —
should hash a shared runtime input against each task env(packages/nx/src/hasher/native-task-hasher-impl.spec.ts:229). Two tasks in two projects declare the SAME runtime command (node -e "console.log(process.env.SELECTED_ENV)") and are hashed in ONEhashTasksinvocation with per-task envs ({'parent:build': {SELECTED_ENV:'parent-env'}, 'child:build': {SELECTED_ENV:'child-env'}}). The test asserts the tworuntime:<cmd>detail hashes differ, and that batching produces byte-identical hashes to hashing each task alone. Nx explicitly guards against a shared runtime memo collapsing two different envs into one value. - vx equivalent:
runRuntimeCommand()/resolveRuntimeValues()insrc/cache/inputs.ts; memo mapsHashCache.runtime/.workspaceRuntimeinsrc/orchestrator/task-hash.ts:26.Bun.spawn(['sh','-c',command], { cwd, ... })passes noenv, so the probe inherits vx’s ambientprocess.env— it never seesexec.env.define/exec.env.passThrough. The memo key isprojectDir + '\0' + command(project scope) or the bare command (workspace scope). - Existing vx coverage: NONE.
tests/runtime-inputs.test.tscovers output-change-invalidates, live-under---frozen, non-zero-fails, and workspaceRuntime-spawns-once — but nothing pins which environment the probe runs in, and nothing pins the memo/env coupling. - Proposed test: Two tasks in the SAME project both declaring
cache.inputs.runtime: ['printenv MY_PROBE || true'], with differentexec.env.define: { MY_PROBE: 'a' }/{ MY_PROBE: 'b' }. Assert the documented contract explicitly: the probe reads the AMBIENT env, so both tasks fold the SAME runtime value, and thedefinevalues do not leak into it. Add a comment atrunRuntimeCommandstating that the memo key is sound only because env is ambient — so a future change that threads the task env in must also widen the memo key. Also worth an e2e: setMY_PROBEin the parent process, run, change it, re-run, assert re-execution. - Value: HIGH — as a landmine rather than a live bug. Today the memo is sound
because the env is constant across the run. The moment anyone “improves” the probe
to run under the task env (an obvious-looking fix), the
(projectDir, command)memo starts serving task A’s probe output as task B’s cache-key component → wrong key → stale hit. Nx has this exact regression test because they hit it. vx has neither the pin nor the comment.
H3. --affected is blind to cache.inputs.workspaceFiles — a root-anchored declared input changes, the consuming project is never selected
Section titled “H3. --affected is blind to cache.inputs.workspaceFiles — a root-anchored declared input changes, the consuming project is never selected”- Nx behaviour:
filterAffected(packages/nx/src/project-graph/affected/affected-project-graph.ts) runs FOUR touched-project locators, not one. The second isgetImplicitlyTouchedProjects, whose whole job is “a changed file that lives outside every project dir still marks the projects whose declaredinputsmatch it”. Pinned byproject-graph/affected/locators/workspace-projects.spec.ts:115should return projects which have touched files in their target inputsand:135(same via a workspace-levelnamedInput), with the negative control at:159should not return projects which have touched files inputs which are not used by its targets. - vx equivalent:
projectsContaining()insrc/workspace/affected.ts:131. It walks each changed path’s ancestor dirs bottom-up and takes the first project dir hit — so a path under no project dir contributes nothing.cache.inputs.workspaceFiles/cache.inputs.workspaceRuntimeare exactly the declared inputs that live outside the project dir (the documented escape hatch —docs/schema.md), and they ARE folded into the cache key, so the key busts correctly while the selection does not. - Existing vx coverage: NONE.
tests/affected.test.ts:125(ignores changes outside any project directory) pins the opposite — it asserts the blindness as intended behaviour, with noworkspaceFilesdeclaration anywhere in the fixture.grep -n workspaceFiles tests/affected.test.ts→ no hits.tests/workspace-files.test.tsnever touches--affected. - Proposed test: In
tests/affected.test.ts, a fixture wherepackages/appdeclarescache.inputs.workspaceFiles: ['tools/**']and nothing else changes excepttools/codegen.js. AssertappIS inaffectedProjects(...). Second case: a project that does NOT declare a matchingworkspaceFilesglob is NOT selected (theshould not return projects which have touched files inputs which are not usedcontrol — the fix must not degenerate to “any root change affects everything”). Requires threading the loaded configs’workspaceFilesglobs intoaffectedProjects, which is the design decision the test forces. - Value: HIGH —
docs/cli.md:139states the governing principle verbatim: “input hashing sees it, so--affectedmust too.” This is that principle being violated by the one input axis explicitly documented for root-level files. Consequence:vx run test --affected=origin/mainafter atools/change runs nothing, exits 0, CI is green, and the regression ships. Same class as the 2026-07-26 “--affectedblind to non-ASCII filenames” defect, which was rated a real bug and fixed.
H4. --affected is blind to a lockfile / workspace-fingerprint change — a dependency bump selects zero projects
Section titled “H4. --affected is blind to a lockfile / workspace-fingerprint change — a dependency bump selects zero projects”- Nx behaviour: the fourth locator in
filterAffectedisgetJSTouchedProjects(plugins/js/project-graph/affected/touched-projects.ts), which decomposes intogetTouchedNpmPackages+getTouchedProjectsFromLockFile. Pinned byplugins/js/project-graph/affected/lock-file-changes.spec.ts(591 lines) andnpm-packages.spec.ts(487 lines) — a lockfile edit resolves to the changed npm packages and then to every project consuming them. - vx equivalent:
computeWorkspaceFingerprint()insrc/workspace/fingerprint.tsfoldspnpm-lock.yaml/package-lock.json/npm-shrinkwrap.json/yarn.lock/bun.lock/bun.lockb/pnpm-workspace.yamlinto every task’s cache key. All seven live at the workspace root, i.e. inside no project dir, soprojectsContainingreturns the empty set for them. - Existing vx coverage: NONE.
tests/affected.test.tshas a lockfile-adjacent test (:131,a vx-lock.json change never marks a project affected) which pins the deliberate exclusion of vx’s own lockfile — the package-manager lockfiles are never exercised.tests/cache.test.tscovers the fingerprint’s effect on the KEY only. - Proposed test:
tests/affected.test.ts— commit a fixture, then modifybun.lock(orpnpm-lock.yaml) at the root and nothing else. Assert the intended contract explicitly. Two defensible contracts, pick one and pin it: (a) every project is affected (Nx/TurboglobalDependenciessemantics — the coarse-but-correct match to what the fingerprint already does to the keys), or (b) nothing is affected, with the reason documented atprojectsContainingand indocs/cli.mdnext to the “input hashing sees it, so--affectedmust too” sentence, which today reads as a promise this case breaks. - Value: HIGH — a dependency-bump PR (
bun update, Renovate, Dependabot) is precisely the PR you most want--affectedCI to test, and today it runs zero tasks and exits 0. Every cache key in the workspace changed; the selector saw nothing.
H5. --affected does not include DEPENDENTS — a changed library never rebuilds/retests its consumers
Section titled “H5. --affected does not include DEPENDENTS — a changed library never rebuilds/retests its consumers”- Nx behaviour:
filterAffectedProjectsinpackages/nx/src/project-graph/affected/affected-project-graph.tscomputesconst reversed = reverse(graph)and traverses from every touched project through the reversed edges. “Affected” in Nx is definitionally touched ∪ transitive dependents — that is whatnx affected -t testmeans. Turbo matches (--affectedis sugar for the...[base]filter form, where the leading...is the with-dependents operator). - vx equivalent:
docs/cli.md:94— “--affected[=<base>]… Sugar for--filter '[<base>]'”. Insrc/workspace/filter.ts:147applyFiltersonly expands dependents whenf.withDependentsis set, which the bare[<since>]form does not set (only the...patprefix does). So--affectedselects the changed projects and stops. - Existing vx coverage: NONE asserting either direction for
--affected.tests/filter.test.ts:131covers...pkg includes pkg and transitive dependentsfor the explicit...form only; no test asserts what--affecteddoes about dependents. - Proposed test:
tests/filter.test.ts/tests/affected.test.ts— workspace whereappdepends onlib; change a file inlibonly; run the--affectedresolution path. Assert the chosen contract forapp. If the current changed-only behaviour is kept, ALSO pin--filter '...[<base>]'as the documented dependents form, and say so in the--affecteddoc section and indocs/comparison.md§“Deliberate divergences” (it is not listed there today, so this reads as an oversight rather than a decision). - Value: HIGH — this is the semantics of the flagship CI recipe, and vx diverges
from both reference implementations in the unsafe direction. A change to a leaf
library does not run the consuming app’s tests. Note the divergence may well be
intentional (vx’s answer is “run
--alland let the cache skip”), but an intentional divergence in the CI-critical selector with no test and no entry in the divergences inventory is one refactor away from being an accident.
H6. ! negation is honoured in cache.inputs.* but is a SILENT NO-OP in cache.outputs.* — and vx migrate generates configs that use it
Section titled “H6. ! negation is honoured in cache.inputs.* but is a SILENT NO-OP in cache.outputs.* — and vx migrate generates configs that use it”-
Nx behaviour: negation in outputs is a first-class, pinned semantic.
packages/nx/src/hasher/check-task-files.spec.ts:926excludes paths matching a negated glob even when a positive glob matchesand:940excludes paths inside a negated directory pattern (no glob)both assert a!-prefixed entry subtracts from what the positive output patterns matched. Nx also pins the ordering interaction with..resolution (:802resolves \..` before applying a negated exclusion`). -
vx equivalent:
src/cache/inputs.ts. Four resolvers, two behaviours:resolveFiles()(inputs.files) — splits on a leading!intopositive/negative, negatives become exclude globs. Honoured.resolveWorkspaceFiles()(inputs.workspaceFiles, line 150-160) — same split. Honoured.resolveOutputs()(outputs.files, line ~285) —scanUnion(args.outputs, ...)with the raw list, no split. Ignored.resolveWorkspaceOutputs()(outputs.workspaceFiles, line 306-312) —scanUnion(args.outputs, [], ...), no split. Ignored.
Verified empirically that this is a dead pattern, not a differently-spelled one:
new Bun.Glob('!dist/**/*.map').match('dist/a.map')→false;.match('!dist/a.map')→true. Bun.Glob has no negation operator, so a!entry matches only a path whose first character is literally!.Confirmed end-to-end by execution through the real
resolveOutputs, against a fixture holdingdist/a.js+dist/a.js.map:["dist/**"] -> [ "dist/a.js", "dist/a.js.map" ]["dist/**","!dist/**/*.map"] -> [ "dist/a.js", "dist/a.js.map" ] # negation inertBecause
cleanOutputsisresolveOutputs+rm, the same inert list meansdist/a.js.mapis also deleted before every exec and every restore.The loader visibly anticipates negation in outputs:
hasParentSegment()(src/workspace/project-loader.ts:468) strips a leading!before checking for..segments, and it is applied tooutputs.filesas well asinputs.files. So the validation layer treats!dist/xas a legitimate outputs entry that the resolver then silently drops. -
Existing vx coverage: NONE.
grepacrosstests/finds negation used only ininputs.files(tests/inputs.test.ts:323,533,tests/orchestrator.test.ts:1307,1338). No test passes a!entry inoutputs.filesoroutputs.workspaceFiles.tests/migrate.test.ts:74makes this worse: the Turbo migration fixture isoutputs: ['dist/**', '!dist/**/*.map']— a standard Turbo idiom — andvx migrateemits it verbatim into the generatedvx.config.ts, producing a config whose negation does nothing and whose only pinned assertion is that it round-trips as text. -
Proposed test:
tests/inputs.test.ts—resolveOutputsover a project containingdist/a.jsanddist/a.js.mapwithoutputs: ['dist/**', '!dist/**/*.map']. Assert the chosen contract: either the map is excluded (implement the split, matchingresolveFiles) or the loader throwsUserErrornaming outputs-negation as unsupported. Mirror foroutputs.workspaceFiles. Then the sharp companion intests/inputs.test.ts→cleanOutputs: assert whetherdist/a.js.mapsurvives a clean — today it is deleted before every exec and before every restore, because the negation that was supposed to disown it is inert. Finally, updatetests/migrate.test.tsto assert whichever contract wins (migrate must not emit a silently-inert entry). -
Value: HIGH — same syntax, same config object, opposite behaviour on the two halves, with zero diagnostics. The user-visible damage is on the destructive side:
cleanOutputswipes files the user believed they had excluded from vx’s ownership, and the cached artifact carries bytes the user tried to keep out of it. And vx’s own migration tool is a generator of exactly this config shape.
H7. A distributed run without --frozen lets each agent LIVE-EVALUATE its own configs — nothing detects or warns about a diverged graph
Section titled “H7. A distributed run without --frozen lets each agent LIVE-EVALUATE its own configs — nothing detects or warns about a diverged graph”- Nx behaviour: Nx treats “the agent’s local graph diverged from the coordinator’s”
as a named, testable failure mode.
packages/nx/src/tasks-runner/utils.spec.ts:1038isthrows a descriptive error when a task references a project missing from the graph, and its in-test comment names the scenario verbatim:// e.g. a DTE agent whose local graph diverged from the coordinator's. Nx’s own DTE contract additionally requires every agent to check out the same commit beforenx-cloud start-ci-runhands out work. - vx equivalent:
packages/cloud/src/dist/—agent-loop.ts:317(const frozen = policy?.frozen ?? opts.frozen),scheduler.ts:105(deriveAssignPolicy),protocol-dist.ts:97(AssignPolicy.frozen). vx propagates the submitter’s--frozenper assignment (shipped 2026-07-18), and a dirty tree is refused. But--frozenis optional, and vx configs are TypeScript programs that may readprocess.env— a documented, first-class capability. Without--frozen, every agent re-evaluatesvx.config.tsunder its OWN environment. Same commit, same clean tree, different resolved config ⇒ different resolved-config hash ⇒ different cache key for the same logical task. - Existing vx coverage: NONE for the divergent case.
packages/cloud/tests/dist-hash-equality.test.tsis the §6.3 correctness-law guard and its own header scopes itself to “same commit, clean tree, same configs” — the divergence is an explicit precondition of the test, never an assertion. Nothing inpackages/cloud/tests/(dist-*.test.ts,agents-e2e.test.ts) varies an agent’s env or asserts any warning.grep -rn 'divergen' packages/cloud/src→ no hits. - Proposed test:
packages/cloud/tests/dist-hash-equality.test.ts— a fixture whosevx.config.tsreadsprocess.env.BUILD_FLAVORintoexec.command(or intocache.inputs.env, or any config value). Derive the submitter’s stable keys withBUILD_FLAVOR=a, then run the agent-style scopedrun()withBUILD_FLAVOR=b, and assert the two keys DIFFER — i.e. pin the hazard as a fact. Then assert the mitigation: the same pair underfrozen: true(with avx-lock.jsonwritten byvx lockat flavora) derives IDENTICAL keys. Add the ergonomic half inpackages/cloud/tests/dist-backend.test.ts: submitting a distributed run without--frozenemits a warning naming--frozenas the reproducibility guarantee — the same shape as the existing “no remote agents” warning. - Value: HIGH — this is the lost-work-in-distributed-mode class. The submitter’s
deriveStableKeysoutput is what the serve stat-prunes against and whatmaterializeOutputslater addresses; if agents derive different keys, artifacts land under keys the submitter never asks for and outputs come back silently unrestored, or the same logical task is executed on every agent because no one’s key ever hits. Neither failure is loud. vx already built the exact mitigation (--frozenover the wire) — what is missing is the test that proves the hazard is real and the warning that tells a user to use it.
H8. An option-like --affected=<base> is passed to git diff as an OPTION — arbitrary file write, blocked only incidentally
Section titled “H8. An option-like --affected=<base> is passed to git diff as an OPTION — arbitrary file write, blocked only incidentally”-
Nx behaviour: Nx hardened this explicitly, with a dedicated describe block
resolving the affected base against gitinpackages/nx/src/utils/command-line-utils.spec.ts:507. Six pinned properties::528should resolve the merge base by passing revisions as arguments rather than through a shell:539/551/562should treat a shell substitution in --base / in nx.json defaultBase / in NX_BASE as an opaque revision(all three ref sources):575/582/589should reject an option-like base/head/defaultBasebefore invoking git:596should fall back to the fork point without a shell when merge-base fails
Note the third pair: Nx rejects a
--leading revision before invoking git at all, independently of shell-safety. -
vx equivalent:
src/workspace/affected.ts.affectedProjects()passesargs.sinceverbatim as a trailing positional togit diff --no-renames --relative --name-only -z <since>(gitPaths, line ~52) and togit rev-parse --verify --quiet <ref>(verifyRef, line ~108). The value reaches there from--affected=<base>or the[<since>]filter form (src/cli/run.ts:661), i.e. from CI-controlled input such as--affected=$GITHUB_BASE_REF.Shell injection: NOT possible — both use
Bun.spawn({ cmd: [...] })array form, no shell, so$(...)is opaque. That half is sound.Argument injection: the vector is real and I demonstrated it. In a throwaway repo:
git diff --no-renames --relative --name-only -z "--output=<path>"→ exit 0, and <path> is created with the diff contents--output=is a genuinegit diffoption; an option-like “ref” is an arbitrary file write. NeithergitPathsnorverifyRefpasses--or--end-of-options, and neither screens for a leading-.What actually stops it today is incidental:
affectedProjectsawaitsverifyReffirst,git rev-parse --verify --quietexits 1 for every option-like string I probed (-,--,-c,-C..,--git-dir=/tmp,--output=/tmp/x), and vx’s exit-1 branch throwsUserError. So thegit diffis never reached — via that one call path, with that one exit-code branch. That branch was itself rewritten on 2026-07-26 (“only exit 1 keeps the ref message now”), which is exactly how accidental guards get lost. -
Existing vx coverage: NONE.
grep -niE 'option-like|injection|substitution|--output' tests/affected.test.ts→ zero hits. The suite covers a nonexistent ref (throws UserError when the ref does not resolve) and a git-level failure (reports a git failure as a git failure), but never a--leading value, and nothing asserts thatgit diffis unreachable with one. -
Proposed test:
tests/affected.test.ts— callaffectedProjects({ since: '--output=<tmpfile>' })against a real fixture repo; assert it REJECTS (UserError) and assert<tmpfile>does not exist afterwards (the second assertion is the one that survives a refactor ofverifyRef). Repeat for-,--, and--upload-pack=<script>, and for the[<since>]filter spelling. Then make the guard deliberate rather than incidental: reject asincematching/^-/inaffectedProjectsbefore any spawn, and/or insert--end-of-optionsbefore the ref in bothgitPathsandverifyRef. -
Value: HIGH — a demonstrated arbitrary-file-write primitive reachable from a CI-supplied string, currently prevented by an exit-code side effect in a different function rather than by any check that knows it is a security boundary. It is not exploitable at HEAD; it is one plausible edit (make an unresolvable ref warn-and-fall-back instead of throw, or add a second
gitPathscaller) away from being exploitable, and nothing would fail.
H9. The ESSENTIAL_ENV allowlist forwards behaviour-changing vars (NODE_OPTIONS, LC_ALL, CI) that no cache key can see
Section titled “H9. The ESSENTIAL_ENV allowlist forwards behaviour-changing vars (NODE_OPTIONS, LC_ALL, CI) that no cache key can see”- Nx behaviour: Nx treats “exactly which environment the child sees” as a pinned
contract rather than an incidental allowlist.
packages/nx/src/tasks-runner/task-env.spec.ts:276is a dedicateddescribe('getForceColorForChild')with three cases (should return FORCE_COLOR when it is explicitly set,should return "0" when NX_ORIGINAL_FORCE_COLOR is "0" and FORCE_COLOR was deleted,should prefer FORCE_COLOR over NX_ORIGINAL_FORCE_COLOR) — Nx normalises the colour axis for children and keeps the original underNX_ORIGINAL_FORCE_COLORso the transformation is reversible and testable. Nx’s complementary rule is that anything that should affect the key must be declared as an{ env: 'NAME' }input (hasher/task-hasher.spec.tsenv-input expansion), i.e. Nx forces the user to make the choice per variable. - vx equivalent:
ESSENTIAL_ENVinsrc/exec/env.ts:11— a hard-coded list of ~24 names copied from the hostprocess.envinto every child:PATH, HOME, SHELL, USER, LOGNAME, TMPDIR, TEMP, TMP, LANG, LC_ALL, LC_CTYPE, TERM, COLORTERM, FORCE_COLOR, NO_COLOR, CI, NODE_OPTIONS, SYSTEMROOT, APPDATA, …. The cache key folds onlycache.inputs.envnames the user declared (resolveInputs→Cache.keyenvValues). So every essential is an implicit, undeclarable, unhashable pass-through. Three of them change what a task produces:NODE_OPTIONS— honours--require,--conditions,--import. A build run withNODE_OPTIONS=-r ./instrument.jsemits different bytes than one without, under a byte-identical cache key. Restore the wrong artifact, silently.LC_ALL/LC_CTYPE/LANG— collation. Any build step that shells out tosort(or any locale-sensitive comparator) produces different output ordering underLC_ALL=Cvsen_US.UTF-8, same key.CI/FORCE_COLOR/NO_COLOR/TERM/COLORTERM— change stdout bytes, andCIcommonly switches test reporters and snapshot-write behaviour. vx caches stdout and replays it on a hit, so a warm run piped to a file replays the ANSI from whichever run populated the entry.
- Existing vx coverage: NONE for the key interaction.
tests/env.test.ts(10 tests) covers only the composition of the child env (essentials forwarded, passThrough, define precedence, binPaths on PATH). No test asserts that changing an essential leaves the key unchanged, and no test flags it as a hazard.grep -rn 'NODE_OPTIONS|LC_ALL' tests/→ hits only intests/colors.test.ts, about vx’s OWN terminal output.docs/schema.md:319-331is the tell: thepassThroughbullet says “NOT folded into the cache key” explicitly, and theEssential allowlistbullet immediately above says nothing at all about the key. - Proposed test:
tests/cache.test.ts— derive a task key withenvSource: { NODE_OPTIONS: '-r ./a.js' }and again with{ NODE_OPTIONS: '-r ./b.js' }, assert the keys are IDENTICAL, with a comment naming this as the accepted-but-real hazard (a pin, so it can never change silently). Then the e2e that makes it concrete, intests/stale-hit.test.ts: a task whose command isnode -e "console.log(process.env.LC_ALL)" > out.txt, run underLC_ALL=C, then re-run underLC_ALL=en_US.UTF-8; assert what happens (today: cache HIT,out.txtrestored withC). Finally, the fix this test should motivate: either fold the essentials into the key, or document them indocs/schema.mdbesidepassThroughwith the same “NOT folded into the cache key” sentence, or narrow the list (dropNODE_OPTIONS, which is the only genuinely output-changing one, and make it apassThroughthe user must opt into). - Value: HIGH — this is a wrong-cache-hit vector reachable with no malformed
config at all, from a variable (
NODE_OPTIONS) that dev shells and CI images routinely set differently. vx’s architecture principle #1 is “Explicit over magical” and itspassThroughdocs are careful to state cache-invariance; the essentials list is the one place where 24 variables are magically passed through with that property undocumented and untested.
M1. Cycle topologies: vx pins 2, Nx pins 8 — the untested ones are the sparse and multi-cycle shapes
Section titled “M1. Cycle topologies: vx pins 2, Nx pins 8 — the untested ones are the sparse and multi-cycle shapes”- Nx behaviour:
packages/nx/src/tasks-runner/create-task-graph.spec.tshas a named cycle matrix, each a separateit::1644should handle cycles within the same project:1724all projects contain the target (lib1:build → lib2:build → lib3:build → lib4:build → lib1:build):1875not all projects contain the target (lib1:build → lib2:build → lib3 → lib4:build) — an intermediate project without the target:2004should handle cycles where tasks seem to depend on themselves(lib1:build → lib2 → lib1:build) — wrap-back through a pass-through project:2074,:2204,:2313three more sparse variants:2416cycles between projects that do NOT create cycles between tasks(app1:build → app2 ↔ app3:build) — a package cycle that must NOT be reported as a task cycle:3470dependencies with 2 cycles(app1→app2↔app3→app4, app5→app6↔app7→app8) — two disjoint cycles in one graph PlusfilterDummyTasksshould filter out dummy tasks with 1 cycle/with 2 cycles.
- vx equivalent:
buildTaskGraph+detectCycle(src/graph/task-graph.ts), and the nearest-holder frontier walk at:316-337whosevisitedset is seeded withprojectName(the 2026-07-26 fix for the direct wrap-back). - Existing vx coverage: only four cycle-adjacent tests —
tests/task-graph.test.ts:140(^name never wraps back into the declaring project on a package cycle),:155(the pattern form),:260(detects a cross-project cycle, singular),:277(detects a same-project task self-cycle). Missing: the two-disjoint- cycles graph, the sparse wrap-back through a target-less intermediate, and the package-cycle-that-is-not-a-task-cycle positive control. - Proposed test: three cases in
tests/task-graph.test.ts. (1)a → b → awherebdeclares NO matching task andadeclaresdependsOn: ['^build']— assert the graph builds with no self-edge and noCycle detectedthrow (the pass-through variant of the 2026-07-26 fix). (2)app1 → app2 ↔ app3, onlyapp1/app3declaringbuild— assert a cleanapp1#build → app3#buildgraph and NO cycle error. (3) two disjoint 4-node cycles in one workspace — assertdetectCyclethrows and that the message names a node from a cycle (guards against a DFS that only ever roots in the acyclic component). - Value: MED — vx has already shipped one real bug in this exact family (the
2026-07-26
Cycle detected: a#build -> a#buildlie, plus the pattern form silently adding a bogus edge that reached the cache key). The fix was verified only against the direct-wrap shape; the sparse and multi-cycle shapes are where the same class hides.
M2. Nothing asserts the task graph or any cache key is invariant to declaration / request order
Section titled “M2. Nothing asserts the task graph or any cache key is invariant to declaration / request order”- Nx behaviour:
create-task-graph.spec.ts:2656should create deterministic task graphs regardless of target order, with a comment naming the regression it locks: “dummy tasks (created when a dependency project doesn’t have the required target) would have different dependency structures depending on the order targets were processed … leading to non-deterministic task graphs.” The fixture is exactly the sparse case —app1declaringtest: ['^test','^lint']andlint: ['^lint'], withlib1having no targets at all. - vx equivalent:
buildTaskGraph+expandRequested(src/graph/task-graph.ts), the frontier walk,markSurfacedDeps,computeGroupHash(src/orchestrator/task-hash.ts:202, which does sort), andfilterUpstreamHashes. - Existing vx coverage: NONE.
grep -riE 'determinist|order-independent|regardless of .*order' tests/task-graph.test.ts tests/scheduler.test.ts tests/stable-keys.test.ts→ zero hits.tests/cache.test.tspins order-independence of the inputs toCache.key(file order, upstream-hash order), but nothing pins that the GRAPH feeding those inputs is itself order-independent. - Proposed test:
tests/task-graph.test.ts— build the graph twice from the same workspace, once withexpandRequested(['test','lint'], …)and once with['lint','test'], and once more with theprojectsMap built in reverse insertion order. Assert the three results are structurally equal: same node-id set, and each node’sdepsarray deep-equal after sorting. Use the Nx fixture shape — an intermediate project declaring NEITHER task, so the pass-through frontier is exercised. Then the half that actually matters for caching: intests/task-hash.test.ts, assert the derived key for a GROUP task is byte-identical across the two orderings. - Value: MED — order-dependence here is invisible until it changes a key, at which
point it presents as an unexplained cache miss (or, through
computeGroupHash, as a group task that never hits).computeGroupHashsorts today; nothing stops that from regressing.
M3. cache.inputs.workspaceFiles has the same negation-only footgun as H1
Section titled “M3. cache.inputs.workspaceFiles has the same negation-only footgun as H1”- Nx behaviour: same
filterUsingGlobPatternscontract as H1 — Nx applies one positive-OR / negative-AND rule to{workspaceRoot}-anchored filesets and{projectRoot}filesets alike (task-hasher.spec.ts:24should identify ^{workspaceRoot}/tools/**/* as a dependency fileset, and thefilterUsingGlobPatternsblock applies to both). - vx equivalent:
resolveWorkspaceFiles()insrc/cache/inputs.ts:150-156— samepositive/negativesplit, sameif (positive.length === 0) return []. - Existing vx coverage: NONE.
tests/workspace-files.test.ts(23 tests) covers resolution, boundaries, staleness and the migrate mapping, but never a negation-only list. - Proposed test:
resolveWorkspaceFileswithworkspaceFiles: ['!tools/**/*.md']— assert the same contract chosen for H1, so the two halves cannot diverge. Worth writing as a shared table-driven test over all four resolvers. - Value: MED — same permanent-stale-hit mechanism as H1, on the axis explicitly documented for root-level inputs; folded out of H1 only because it is one resolver down.
M4. --affected has no sibling-prefix project-dir case, the exact family of the 2026-07-14 interloper bug
Section titled “M4. --affected has no sibling-prefix project-dir case, the exact family of the 2026-07-14 interloper bug”- Nx behaviour:
project-graph/affected/locators/workspace-projects.spec.ts:32and:44— two near-identical tests both namedshould return projects with the root matching a whole directory name in the file path, with rootslibs/a,libs/a-b,libs/a-b-cand a change inlibs/a-b/index.ts, asserting exactly['ab']. Nx pins it twice because prefix-vs-segment matching of project roots is a repeat offender.:56addsshould return the most qualifying match with the file path(deepest wins). - vx equivalent:
projectsContaining()insrc/workspace/affected.ts:131— the ancestor-path.dirnamewalk, which is segment-safe by construction and takes the deepest match first. - Existing vx coverage: NONE for sibling prefixes.
tests/affected.test.tscovers the nested-project boundary (:170) butgrep -n 'e2e|app-' tests/affected.test.tsreturns nothing — no fixture has two project dirs where one name is a string prefix of another.tests/nested-dirs.test.tscovers this family for the OUTPUT-boundary function only. - Proposed test:
tests/affected.test.ts— a fixture withpackages/app,packages/app-e2eandpackages/app-e2e-utils; touchpackages/app-e2e/src/x.ts; assert exactly{'app-e2e'}is affected (notapp, notapp-e2e-utils). Mirror the deepest-wins case withpackages/app+packages/app/nested. - Value: MED — vx shipped a HIGH-severity bug of precisely this shape on 2026-07-14
(
computeNestedProjectDirsbreaking its scan on a sibling whose name extends the parent by a character sorting below/, silently emptying a project’s nested set and breaking the hard boundary invariant). That fix pinned the outputs side; the selection side has the same hazard and no fixture.
M5. vx migrate maps Turbo’s negated outputs into a config where the negation is inert
Section titled “M5. vx migrate maps Turbo’s negated outputs into a config where the negation is inert”- Nx behaviour: Nx validates outputs at configuration-merge time and errors on
malformed shapes —
tasks-runner/utils.spec.ts:796describe('validateOutputs')withthrows an error if the output is not an array,…entries that aren't strings,…is a glob pattern from the workspace root,…doesn't start with a prefix. The principle: an outputs entry that cannot mean what it appears to mean is rejected, not silently accepted. - vx equivalent:
src/cli/migrate-turbo.ts(theoutputsmapping) plus theresolveOutputsgap from H6. - Existing vx coverage:
tests/migrate.test.ts:74feedsoutputs: ['dist/**', '!dist/**/*.map']through the Turbo path and asserts only that the generated config loads and round-trips. Nothing asserts what the negation does. - Proposed test: after H6’s contract is chosen, extend
tests/migrate.test.tsto assert it — either the emitted config’s negation is honoured end-to-end (build a real fixture, assertdist/a.js.mapis neither captured nor cleaned), or migrate rewrites / TODO-comments the entry rather than emitting an inert one.vx migrate’s stated contract is “TODO comments for everything unmappable”; an inert negation is unmappable and currently gets neither. - Value: MED — it is the generator half of H6, and
vx migrateis the onboarding path, so it is where the inert config shape actually enters real workspaces.
M6. No test drives cache.inputs.files with multiple overlapping positive globs plus a negation that straddles them
Section titled “M6. No test drives cache.inputs.files with multiple overlapping positive globs plus a negation that straddles them”- Nx behaviour:
hasher/task-hasher.spec.ts:252should OR all positive patterns and AND all negative patterns (when positive and negative patterns)— two positives (**/*.ts,**/*.js) and two negatives (!**/*.spec.ts,!**/*.md) over one file set, asserting['root/a.ts','root/b.js']. The point is the interaction:c.spec.tsmatches a positive and is removed by a negative;d.mdmatches no positive at all. Nx also pins:306should handle projects with the root set to .— the workspace-root project. - vx equivalent:
resolveFiles()— positives feedpositiveGlobs(matched with an OR loop thatbreaks on first hit), negatives are merged intoexcludeGlobsalongsideALWAYS_IGNORE, boundary ignores andownOutputs. - Existing vx coverage: partial only.
tests/inputs.test.ts:316/:526each use ONE positive and ONE negation. Nothing exercises two positives with a negation that subtracts from one of them, and nothing asserts the precedence between a user negation andownOutputs/ boundary ignores when they overlap. - Proposed test:
resolveInputswithfiles: ['src/**/*.ts', 'src/**/*.js', '!src/**/*.spec.ts']oversrc/a.ts,src/b.js,src/c.spec.ts,src/d.md→ assert exactly['src/a.ts', 'src/b.js']. Add the root-project variant (a project whose dir IS the workspace root, which this repo has as the"."member) so the{projectRoot} = .path is pinned. - Value: MED — vx’s combination rule is correct as written, but it is assembled from four separate exclude sources and one OR loop, and no test pins the composition. This is the cheapest test in the catalogue and it guards cache-key content directly.
M7. Nx schedules a NO-HISTORY task FIRST; vx gives it the workspace median — and vx’s own benchmark already measured that choice regressing
Section titled “M7. Nx schedules a NO-HISTORY task FIRST; vx gives it the workspace median — and vx’s own benchmark already measured that choice regressing”- Nx behaviour:
packages/nx/src/tasks-runner/tasks-schedule.spec.ts:497should schedule task with no historial runtime first. With estimated timings{app1:test: 200, app4:test: 500, lib1:test: 100}andapp2/app3having none, the asserted dispatch order islib1(blocks others) →app2→app3(both unknown) →app4(500) →app1(200). Nx’s rule is: unknown duration outranks every known duration.:479(should schedule task with longer runtime first) is the known-duration half. - vx equivalent:
computePredictedPriorities()insrc/orchestrator/predict.ts:24-39. A node with no history falls back to the workspace median p50, and if history is entirely empty toDEFAULT_DURATION_MS = 1000. So unknown-duration nodes land in the middle of the ordering, and on a cold cache where every node is unknown they are all equal → every priority ties → the scheduler degenerates to graph-insertion order. - Existing vx coverage: the fallback itself is pinned
(
tests/predict.test.ts:57falls back to workspace median when a node has no history,:64falls back to default duration when history is entirely empty) — but nothing compares the two policies, and no test asserts anything about dispatch order under mixed known/unknown history. - Proposed test / experiment: this one is a benchmark run before it is a test. vx
already owns the instrument —
bench/schedule-policy.tsreplays 9 graph shapes through a deterministic discrete-event sim of the real scheduler and already comparescountvsremCPvsremCP-cold. Add a fourth policy,remCP-unknown-first(unknown p50 ⇒+Infinityrather than the workspace median), and re-run. Then pin whatever wins intests/predict.test.tsas an explicit ordering assertion over a mixed-history table, in the Nx fixture’s shape. - Value: MED — the 2026-07-14 decision-log entry records the measured problem this addresses verbatim: “cold predictive can REGRESS +0.1..+0.9% (uniform-duration fallback is a worse heuristic than reverse-dep-count on some shapes)”, which is precisely the all-nodes-tie degeneration above. Nx’s unknown-first rule is a named, production-tested candidate fix for it, and vx can evaluate it for the cost of one benchmark run with zero risk to the default path.
L1. markSurfacedDeps has no cyclic-group test
Section titled “L1. markSurfacedDeps has no cyclic-group test”- Nx behaviour:
tasks-runner/utils.spec.ts:998terminates on cyclic noop dependencies— twonx:noop(orchestrator-only) targets depending on each other;expandInitiatingTasksThroughNoopmust return an empty set rather than recurse. Siblings::985returns an empty set for a noop with no dependencies,:961recursively collapses nested noop orchestrators. - vx equivalent:
markSurfacedDeps()insrc/graph/task-graph.ts:80-100— the transparent-folder walk that descends through nested same-project group tasks. It uses avisitedset + explicit stack, so it terminates. - Existing vx coverage:
tests/task-graph.test.ts:577describe('markSurfacedDeps')covers nested-group descent, stop-at-real-task, same-project-only and the zero case — but no cyclic-group case. - Proposed test: two group tasks in one project,
a.dependsOn = ['b'],b.dependsOn = ['a'], constructed directly as a node Map (bypassingbuildTaskGraph); assertmarkSurfacedDepsreturns 0 and terminates. - Value: LOW — unreachable through the real pipeline, because
buildTaskGraphrunsdetectCycleand throws beforemarkSurfacedDepsis called. ButmarkSurfacedDepsis an exported unit already tested directly, so the guard costs three lines and removes the ordering assumption.
L2. Bare-name --filter requires the full scoped name; Nx (and pnpm) match on name segments
Section titled “L2. Bare-name --filter requires the full scoped name; Nx (and pnpm) match on name segments”- Nx behaviour:
utils/find-matching-projects.spec.ts:293should match on name segments—findMatchingProjects(['foo'])returns['@acme/foo', 'foo_bar1', '@acme/nested/foo']; matching is case-insensitive (['Bar1']→foo_bar1) and only whole segments match (['fo']→[],['nested/fo']→[]).:316disambiguates scope-vs-name (['test']→@test/test, not@test/test-e2e). - vx equivalent:
matchProjects/ the name-glob compiler insrc/workspace/filter.ts(rewritten 2026-07-26 so'*'and'*core*'reach scoped names). - Existing vx coverage:
tests/filter.test.tspins exact match, glob match andpreserves scoped glob names— i.e. vx requires--filter '@acme/foo'or an explicit glob. No test states that a bare unscoped name does NOT match a scoped package. - Proposed test: pin the divergence rather than change it —
--filter fooagainst a workspace containing only@acme/fooselects nothing (and therefore warns / errors per the no-match rule), while--filter '*foo'selects it. Add a line todocs/comparison.md§“Deliberate divergences” → Filter DSL. - Value: LOW — a UX divergence, not a correctness one, and vx matches Turbo here rather than Nx. Worth pinning only so the 2026-07-26 name-glob rewrite’s boundary stays deliberate.
L3. The flakiness classifier has no empty-candidate short-circuit test
Section titled “L3. The flakiness classifier has no empty-candidate short-circuit test”- Nx behaviour:
packages/nx/src/native/tests/task_history.spec.ts:60should query flaky tasksends withconst r2 = taskHistory.getFlakyTasks([]);and asserts it contains neither known hash — the empty-candidate set must short-circuit to nothing rather than degenerate to “all”. Nx also restricts the flaky query to cacheable hashes only (tasks-runner/life-cycles/task-history-life-cycle.ts:// Only check for flaky tasks among cacheable tasks). - vx equivalent:
classifyFailureMode/mixedOutcomeKeyCountinsrc/orchestrator/failure-mode.ts, guarded byKEYED_RUNS_SQL(hash <> ''), and the cloud twin inpackages/cloud/src/db/analytics.ts. - Existing vx coverage:
tests/history.test.ts/tests/metrics.test.tscover the verdicts. vx’s rule already MATCHES Nx’s (same key, both failed and succeeded) and is strictly stronger (it also accepts a within-run retry) — this is not a rule gap. What is missing is the degenerate-input pin. - Proposed test:
classifyFailureModewithcounts = {total: 0, failures: 0, retried: 0}and with a project/task pair that has no rows at all →'stable', never a DB error and never a flaky verdict. Mirror on the cloud side for a workspace with zerotask_runs. - Value: LOW — defensive; the
failures > 0short-circuit already keeps the query off the empty path. Included because it is the one Nx flakiness assertion vx has no counterpart for, and because the cloud twin has repeatedly needed exactly this class of empty-set guard.
Deliberately NOT listed (checked, vx is covered or has decided)
Section titled “Deliberately NOT listed (checked, vx is covered or has decided)”Recorded so a later pass does not re-tread them:
- Flakiness rule itself. Nx: same cache hash produced both a failure and a success
(
native/tests/task_history.spec.ts:60). vx: identical rule insrc/orchestrator/failure-mode.tsmixedOutcomeKeyCount, plus a within-run-retry signal Nx lacks, plus the shared-rule guard that stops the two read surfaces drifting. Covered and ahead. - Symlink hashing. Nx/Turbo both special-case it. vx harvests trusted git OIDs only
for
mode 100644 / 100755at stage 0 (src/cache/inputs.ts:643-645), so symlinks and gitlinks always fall back to content hashing — no dirty/clean divergence. - Deleted / renamed / gitignored files. All three already pinned in
tests/inputs.test.ts(deleted-but-tracked existsSync guard, untracked-not-ignored participate, nested.gitignoreanchoring,.git/info/exclude) andtests/affected.test.ts(deleted file selects owner; cross-project rename selects both via--no-renames). validateOutputs-class schema checks. Nxtasks-runner/utils.spec.ts:796. vx’s loader already rejects non-array, non-string, empty-string, absolute and..-containing outputs entries, each with a test intests/project-loader.test.ts:166-232.cache+continuousrejection. Nxproject-configuration-utils.spec.ts:2447. vx:tests/project-loader.test.ts:91rejects cache + persistent.- Duplicate / diamond dependency dedup. Nx
create-task-graph.spec.ts:418,:1305. vx:tests/task-graph.test.ts:229and:174. namedInputs,targetDefaults,{projectRoot}/{workspaceRoot}token substitution,^{projectRoot}filesets,dependentTasksOutputFiles, executor batching,.envauto-loading, project tags. All owner-rejected or explicitly out-of-scope perdocs/comparison.mdand the CLAUDE.md decision log. Nx pins them heavily; vx should not.externalDependenciesinputs. Nx lets a target narrow which npm packages participate. vx folds the whole projectpackage.jsonplus the workspace lockfile fingerprint — coarser, and deliberately so (docs/comparison.md“Where vx is ahead”).
Summary
Section titled “Summary”9 HIGH, 7 MED, 3 LOW. The three sharpest:
- H1 — a negation-only
cache.inputs.filesfolds ZERO file inputs.if (positive.length === 0) return []. A config that reads fine and passes validation produces a task whose key never moves with its source: a permanent stale hit. Nx’sfilterUsingGlobPatternspins the opposite (negation-only = everything minus these). - H3/H4 —
--affectedand the cache key disagree about what an input is.projectsContainingonly knows project directories, so a change to acache.inputs.workspaceFilestarget or to any package-manager lockfile busts every affected key while selecting zero projects —vx run test --affectedexits 0 having run nothing.docs/cli.md:139states the violated principle in as many words: “input hashing sees it, so--affectedmust too.” - H9 —
ESSENTIAL_ENVforwardsNODE_OPTIONS/LC_ALL/CIto every child, and no cache key can see them. An implicit 24-name pass-through that no user declared and none can remove, three entries of which change what a task produces. Same key, different bytes — a wrong cache hit with no malformed config involved.
Two more worth calling out: H8 is a demonstrated git diff --output=<path> arbitrary
file write from a CI-supplied --affected value, currently blocked only by an exit-code
side effect in a different function; H7 is the distributed half — without --frozen,
every agent live-evaluates its own TypeScript configs under its own environment, and
nothing detects, warns about, or tests the diverged graph.