@vzn/vx 0.0.635 → 0.0.636

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -138,17 +138,17 @@ Every gap, and when another tool is the better pick:
138
138
 
139
139
  <!-- bench:start — generated by packages/vx-bench/update-site.ts from results.json; do not hand-edit -->
140
140
 
141
- | Time added over the ideal run, 1,601 projects, 9,603 tasks | vx | Turborepo | Nx | Vite Task |
142
- | ---------------------------------------------------------- | ----------- | ------------------------- | -------------------------- | ------------------------ |
143
- | Nothing changed | **1.07 s** | 898 ms (vx 19% slower) | 25.45 s (vx 24× faster) | 11.75 s (vx 11× faster) |
144
- | Nothing changed, outputs restored | **1.27 s** | 844 ms (vx 51% slower) | 23.82 s (vx 19× faster) | 11.83 s (vx 9.3× faster) |
145
- | Cold build | **7.07 s** | 9.15 s (vx 29% faster) | 3 min 48 s (vx 32× faster) | 8.16 s (vx 15% faster) |
146
- | Cold build: CPU the runner burns | **32.28 s** | 1 min 3 s (vx 94% faster) | 6 min 6 s (vx 11× faster) | 24.82 s (vx 30% slower) |
141
+ | Time added over the ideal run, 1,601 projects, 9,603 tasks | vx | Turborepo | Nx | Vite Task |
142
+ | ---------------------------------------------------------- | ----------- | --------- | ---------- | --------- |
143
+ | Nothing changed | **856 ms** | 855 ms | 23.22 s | 11.24 s |
144
+ | Nothing changed, outputs restored | **1.45 s** | 1.50 s | 21.64 s | 11.21 s |
145
+ | Cold build | **7.73 s** | 10.32 s | 3 min 23 s | 9.44 s |
146
+ | Cold build: CPU the runner burns | **36.42 s** | 1 min 3 s | 5 min 35 s | 25.64 s |
147
147
 
148
- Time each tool adds over the ideal run; vx N% or N× faster means that tool adds N% more or N times as much as vx.
148
+ Time each tool adds over the ideal run; lower is better.
149
149
 
150
150
  Benchmark workload: a synthetic monorepo of 1,601 projects and 9,603 tasks in 30 dependency levels, five core libraries a quarter of the projects use; build 300 ms, test and typecheck 150 ms, lint 75 ms, publish 30 ms; real repos with uneven task times will differ.
151
- Run 2026-10-09 on linux x64, 4 cores: vx at commit 3d48a981d, Turborepo 2.11.7, Nx 23.3.0, Vite Task (vite-plus) 1.1.0.
151
+ Run 2026-10-12 on linux x64, 4 cores: vx at commit 126d60a93, Turborepo 2.11.7, Nx 23.3.0, Vite Task (vite-plus) 1.1.0.
152
152
 
153
153
  Same graph, commands and concurrency: [how it is measured](https://vznjs.github.io/vx/benchmarks/).
154
154
 
package/docs/caching.md CHANGED
@@ -200,7 +200,11 @@ add` under a clean filter (`core.autocrlf=true`, a `text` rule)
200
200
 
201
201
  Ref storage is not a key input: every ref vx reads comes from a git
202
202
  command, so a repository in reftable storage (git 2.45) keys every
203
- task as its files-backend twin (`tests/git-reftable.unsafe.test.ts`).
203
+ task as its files-backend twin. A sha256 repository keys from its
204
+ 64-hex index OIDs, and a dirty file is hashed in-process as sha256,
205
+ so Git 3's defaults (reftable and sha256 together) run, hit and
206
+ select `--affected` as a sha1 repository does
207
+ (`tests/git-reftable.unsafe.test.ts`).
204
208
 
205
209
  A **submodule or an embedded repository** is enumerated by its own
206
210
  git: the workspace repository lists the nested one as a single entry
@@ -707,10 +711,12 @@ names a file other than `package.json` (TanStack Router's committed
707
711
  `routeTree.gen.ts`, rewritten by every build). That holds on CI too: a
708
712
  task of the same project running alongside can change an input and put
709
713
  it back. A task that rewrites its own input to identical bytes (tsdown's
710
- `package.json`) sets `cache.rewrittenInputs: 'compare'`, and a write
714
+ `package.json`) sets `cache.inputStability: { strategy: 'mutable' }`, and a write
711
715
  during its command is then judged by its bytes, as an earlier write is;
712
- different bytes still withhold the entry. `VX_REWRITTEN_INPUTS=compare`
713
- makes that the default (owner, 2026-10-12). A file ADDED under an
716
+ different bytes still withhold the entry. `VX_INPUT_STABILITY=mutable`
717
+ makes that the default (owner, 2026-10-12); `'unchecked'` skips the
718
+ check, and `severity: 'error'` fails the task instead of warning
719
+ (`docs/schema.md`). A file ADDED under an
714
720
  input glob since the listing the key filtered (`addedInput`) withholds
715
721
  the save the same way, named on the same line: the listing's
716
722
  directories a glob reaches are `lstat`ed, one whose ctime moved since
package/docs/cli.md CHANGED
@@ -480,6 +480,7 @@ than run with the args unheard.
480
480
  | `--no-cache` | boolean | off | Disable caching entirely (no reads, no writes); output globs are NOT cleaned. |
481
481
  | `--force` | boolean | off | Re-execute everything (skip cache reads) but still REFRESH the cache (writes stay on). Output globs are cleaned (so the saved snapshot is clean). |
482
482
  | `--cache <spec>` | value | all axes on | Per-layer read/write control. See below. An EMPTY spec (`--cache=`) is a parse error — it applied nothing and left every axis on; pass `--no-cache` to disable them all. |
483
+ | `--cache-scope <name>` | value | `VX_CACHE_SCOPE` / workspace `cacheScope` | This run's `cacheScope` (`trusted`, `read-only`, `pr-123`), above `VX_CACHE_SCOPE`, `vx.workspace`'s and `github()`'s. A name `cacheScope` would refuse is refused. `--cache-scope=<name>` form too. Never folded into a cache key. |
483
484
  | `--cache-dir <path>` | value | workspace `cacheDir` / `~/.vx/<id>/cache` | Cache directory override, resolved relative to cwd (absolute paths used as-is). Beats the `defineWorkspace({ cacheDir })` field, `VX_CACHE_DIR` and the default; like them, the directory then holds the whole cache, shared through no store, for every cache the run opens — the config-evaluation cache that `--affected` owners, the picker and the watch sweep read included, so the workspace's default dir is not created beside it. A per-run knob — never folded into a cache key. `--cache-dir=<path>` form too; the space form rejects a value starting with `-`. A directory this user cannot write into fails the run before any task with `cache directory <path> is not writable (EACCES: …)` — every run records its history there. |
484
485
  | `--retry <n>` | value | `0` | Re-run a failed task up to `n` more times; never a persistent one. Run-level default only: a task's own `exec.retries` wins (even an explicit `0`). Never affects cache keys. `--retry=<n>` form too. |
485
486
  | `--continue[=<mode>]` | value | `deps-ok` | What a failed task takes down with it. `never` stops dispatch on the first failure; `deps-ok` (default) skips only its dependents; `always` (bare `--continue`) runs dependents anyway. See § Failure propagation. |
@@ -571,7 +572,8 @@ The cache has four independent axes — **localRead**, **localWrite**,
571
572
  them in this precedence order:
572
573
 
573
574
  1. Start with every axis **on** (the default).
574
- 2. Apply each `--cache=<spec>` segment (the base).
575
+ 2. Apply each `--cache=<spec>` segment (the base), or, with no
576
+ `--cache`, the `VX_CACHE` environment variable's spec.
575
577
  3. If `--no-cache` was passed, force **all four off**.
576
578
  4. If `--force` was passed, force **both reads off** (writes stay
577
579
  whatever the base / `--cache` left them).
@@ -3023,32 +3025,21 @@ REAPI endpoints, the GitHub token) are in its README; what a TASK sees
3023
3025
  is `exec.env` in `docs/schema.md`, and the two markers vx sets on every
3024
3026
  task are the last row.
3025
3027
 
3026
- | Var | Value | Default | Effect |
3027
- | --------------------------------- | --------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
3028
- | `VX_TIMING` | any non-empty | off | Print the stage table to stderr after a run, a `--dry`, or each `vx watch` cycle (`docs/modules/timing.md`). |
3029
- | `VX_TASK_TIMEOUT` | positive integer (ms) | none | The default timeout for tasks without their own `exec.timeout`, one rung below `--timeout` / `RunOptions.timeout` and one above the workspace `timeout`. Empty, non-integer or non-positive is ignored; a value past the largest timer (~24.8 days) is clamped to it, never refused. |
3030
- | `VX_CACHE_DIR` | a path | none | The workspace's cache dir, relative to the workspace root, below `--cache-dir` and `vx.workspace`'s `cacheDir`. A named cache dir holds the whole cache and shares nothing; unset, entries live in the user's shared store (`docs/caching.md`). Empty is ignored; whitespace alone is refused, as `cacheDir` is. |
3031
- | `VX_CACHE_SCOPE` | a `cacheScope` value | none | The run's `cacheScope` (`trusted`, `read-only`, `pr-123`), above `vx.workspace`'s and `github()`'s. Empty is ignored; a value `cacheScope` would refuse is refused before any task. |
3032
- | `VX_REWRITTEN_INPUTS` | `withhold`, `compare` | `withhold` | What a write to an input during the command means for a task that does not set `cache.rewrittenInputs`: `compare` saves the entry when the file holds the bytes the key folded, anything else withholds it (`docs/schema.md`). |
3033
- | `VX_KILL_GRACE_MS` | positive integer (ms) | 2000 | The SIGTERM → SIGKILL grace a child that ignores SIGTERM gets: on a timeout, on a signal, and at the end-of-run shutdown of persistent tasks. Out of range falls back to the default. |
3034
- | `VX_READY_NOTICE_MS` | positive integer (ms) | 10000 | How long a persistent task may take to match `readyWhen` before vx says once what it waits for. Out of range falls back to the default. |
3035
- | `VX_TEARDOWN_TIMEOUT_MS` | integer (ms) | 3000 | The bound on one plugin's end-of-run flush or teardown, and on its `telemetry()` setup, so a third party's I/O cannot hold the run. Out of range falls back to the default, never clamps: a bound of 24.8 days is no bound. |
3036
- | `VX_CONFIG_WORKER_TIMEOUT_MS` | integer (ms) | 30000 | How long one `vx.config.ts` evaluation may take, in process or in its worker, before the load fails naming the config (a real evaluation is ~10 ms). A synchronous loop on a first load holds the thread the deadline needs, so it hangs until killed. Out of range falls back to the default. |
3037
- | `VX_WATCH_POLL` | any non-empty | off | `vx watch` polls every 250 ms from the start instead of probing the OS watcher (§ `vx watch` › How changes are seen). |
3038
- | `VX_RUN_WORKSPACE`, `VX_RUN_TASK` | set by vx | — | Set on every task's environment (the workspace root; `project#task`). Read back by a `vx run` a task starts: one in the same workspace is refused, since a nested run is invisible to the outer graph and a loop back to its own task forks without bound (`docs/schema.md` § `env`). |
3039
- | Var | Value | Default | Effect |
3040
- | --------------------------------- | --------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
3041
- | `VX_TIMING` | any non-empty | off | Print the stage table to stderr after a run, a `--dry`, or each `vx watch` cycle (`docs/modules/timing.md`). |
3042
- | `VX_TASK_TIMEOUT` | positive integer (ms) | none | The default timeout for tasks without their own `exec.timeout`, one rung below `--timeout` / `RunOptions.timeout` and one above the workspace `timeout`. Empty, non-integer or non-positive is ignored; a value past the largest timer (~24.8 days) is clamped to it, never refused. |
3043
- | `VX_CACHE_DIR` | a path | none | The workspace's cache dir, relative to the workspace root, below `--cache-dir` and `vx.workspace`'s `cacheDir`. A named cache dir holds the whole cache and shares nothing; unset, entries live in the user's shared store (`docs/caching.md`). Empty is ignored. |
3044
- | `VX_CACHE_SCOPE` | a `cacheScope` value | none | The run's `cacheScope` (`trusted`, `read-only`, `pr-123`), above `vx.workspace`'s and `github()`'s. Empty is ignored; a value `cacheScope` would refuse is refused before any task. |
3045
- | `VX_REWRITTEN_INPUTS` | `withhold`, `compare` | `withhold` | What a write to an input during the command means for a task that does not set `cache.rewrittenInputs`: `compare` saves the entry when the file holds the bytes the key folded, anything else withholds it (`docs/schema.md`). |
3046
- | `VX_KILL_GRACE_MS` | positive integer (ms) | 2000 | The SIGTERM → SIGKILL grace a child that ignores SIGTERM gets: on a timeout, on a signal, and at the end-of-run shutdown of persistent tasks. Out of range falls back to the default. |
3047
- | `VX_READY_NOTICE_MS` | positive integer (ms) | 10000 | How long a persistent task may take to match `readyWhen` before vx says once what it waits for. Out of range falls back to the default. |
3048
- | `VX_TEARDOWN_TIMEOUT_MS` | integer (ms) | 3000 | The bound on one plugin's end-of-run flush or teardown, and on its `telemetry()` setup, so a third party's I/O cannot hold the run. Out of range falls back to the default, never clamps: a bound of 24.8 days is no bound. |
3049
- | `VX_CONFIG_WORKER_TIMEOUT_MS` | integer (ms) | 30000 | How long one `vx.config.ts` evaluation may take, in process or in its worker, before the load fails naming the config (a real evaluation is ~10 ms). A synchronous loop on a first load holds the thread the deadline needs, so it hangs until killed. Out of range (0, or past 2³¹−1) falls back to the default. |
3050
- | `VX_WATCH_POLL` | any non-empty | off | `vx watch` polls every 250 ms from the start instead of probing the OS watcher (§ `vx watch` › How changes are seen). |
3051
- | `VX_RUN_WORKSPACE`, `VX_RUN_TASK` | set by vx | — | Set on every task's environment (the workspace root; `project#task`). Read back by a `vx run` a task starts: one in the same workspace is refused, since a nested run is invisible to the outer graph and a loop back to its own task forks without bound (`docs/schema.md` § `env`). |
3028
+ | Var | Value | Default | Effect |
3029
+ | --------------------------------- | ----------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
3030
+ | `VX_TIMING` | any non-empty | off | Print the stage table to stderr after a run, a `--dry`, or each `vx watch` cycle (`docs/modules/timing.md`). |
3031
+ | `VX_TASK_TIMEOUT` | positive integer (ms) | none | The default timeout for tasks without their own `exec.timeout`, one rung below `--timeout` / `RunOptions.timeout` and one above the workspace `timeout`. Empty, non-integer or non-positive is ignored; a value past the largest timer (~24.8 days) is clamped to it, never refused. |
3032
+ | `VX_CACHE_DIR` | a path | none | The workspace's cache dir, relative to the workspace root, below `--cache-dir` and `vx.workspace`'s `cacheDir`. A named cache dir holds the whole cache and shares nothing; unset, entries live in the user's shared store (`docs/caching.md`). Empty is ignored; whitespace alone is refused, as `cacheDir` is. |
3033
+ | `VX_CACHE` | a `--cache` spec | all axes on | The run's cache policy when no `--cache` is passed (`local:rw,remote:r`); `--no-cache` and `--force` still apply on top. Empty is ignored; a bad spec is refused before any task. |
3034
+ | `VX_CACHE_SCOPE` | a `cacheScope` value | none | The run's `cacheScope` (`trusted`, `read-only`, `pr-123`), above `vx.workspace`'s and `github()`'s; `--cache-scope` beats it. Empty is ignored; a value `cacheScope` would refuse is refused before any task. |
3035
+ | `VX_INPUT_STABILITY` | `immutable`, `mutable`, `unchecked` | `immutable` | The `inputStability.strategy` of every task, over its own and the workspace's, for the run: which writes to an input during the command withhold the save. `mutable` lets a rewrite to the bytes the key folded through, `unchecked` skips the check. Any other value is refused before a task runs (`docs/schema.md`). |
3036
+ | `VX_INPUT_STABILITY_SEVERITY` | `warn`, `error` | `warn` | The `inputStability.severity` of every task, over its own and the workspace's, for the run: `error` fails a task whose save a moved input withheld, so the run fails. Any other value is refused before a task runs. |
3037
+ | `VX_KILL_GRACE_MS` | positive integer (ms) | 2000 | The SIGTERM → SIGKILL grace a child that ignores SIGTERM gets: on a timeout, on a signal, and at the end-of-run shutdown of persistent tasks. Out of range falls back to the default. |
3038
+ | `VX_READY_NOTICE_MS` | positive integer (ms) | 10000 | How long a persistent task may take to match `readyWhen` before vx says once what it waits for. Out of range falls back to the default. |
3039
+ | `VX_TEARDOWN_TIMEOUT_MS` | integer (ms) | 3000 | The bound on one plugin's end-of-run flush or teardown, and on its `telemetry()` setup, so a third party's I/O cannot hold the run. Out of range falls back to the default, never clamps: a bound of 24.8 days is no bound. |
3040
+ | `VX_CONFIG_WORKER_TIMEOUT_MS` | integer (ms) | 30000 | How long one `vx.config.ts` evaluation may take, in process or in its worker, before the load fails naming the config (a real evaluation is ~10 ms). A synchronous loop on a first load holds the thread the deadline needs, so it hangs until killed. Out of range falls back to the default. |
3041
+ | `VX_WATCH_POLL` | any non-empty | off | `vx watch` polls every 250 ms from the start instead of probing the OS watcher (§ `vx watch` › How changes are seen). |
3042
+ | `VX_RUN_WORKSPACE`, `VX_RUN_TASK` | set by vx | — | Set on every task's environment (the workspace root; `project#task`). Read back by a `vx run` a task starts: one in the same workspace is refused, since a nested run is invisible to the outer graph and a loop back to its own task forks without bound (`docs/schema.md` § `env`). |
3052
3043
 
3053
3044
  Colors are the two conventions in § Output format › Colors (`NO_COLOR`,
3054
3045
  `FORCE_COLOR`). A truthy `CI` picks the CI output flow and
package/docs/execution.md CHANGED
@@ -45,7 +45,7 @@ terminal and a task succeeding or failing. Read it alongside
45
45
  │ 3. loadWorkspacePlugins — loadWorkspaceConfig reads the optional
46
46
  │ vx.workspace.{ts,mts,js,mjs,cts,cjs} at the root (concurrency /
47
47
  │ cacheDir / timeout / cacheRetention / affectedBase / cacheScope /
48
- │ rules / schedule / plugins),
48
+ │ rules / schedule / inputStability / plugins),
49
49
  │ then the plugin `config` stage runs on it.
50
50
  │ 4. listProjects — globs every workspace member's package.json,
51
51
  │ finds sibling vx.config.* files, detects duplicate package
package/docs/features.md CHANGED
@@ -81,16 +81,16 @@ config key or an environment variable is missing here.
81
81
  - **Opt-in, explicit inputs** (`cache.inputs.files`) — a task caches only when it names its inputs. [page](https://vznjs.github.io/vx/features/explicit-inputs/) · post: [Explicit over magical](https://vznjs.github.io/vx/blog/explicit-over-magical/)
82
82
  - **Inputs** (`cache.inputs.env`, `cache.inputs.runtime`, `cache.inputs.tasks`, `cache.inputs.workspaceFiles`, `cache.inputs.workspaceRuntime`) — env vars, tool versions, upstream tasks and workspace files in the key. [page](https://vznjs.github.io/vx/features/key-inputs/) · post: [What goes into a key, and what comes back](https://vznjs.github.io/vx/blog/inside-a-cache-hit/)
83
83
  - **Outputs** (`cache.outputs.files`, `cache.outputs.workspaceFiles`) — what a hit restores. [page](https://vznjs.github.io/vx/features/cache-outputs/) · post: [What goes into a key, and what comes back](https://vznjs.github.io/vx/blog/inside-a-cache-hit/)
84
- - **Rewritten inputs** (`cache.rewrittenInputs`, `VX_REWRITTEN_INPUTS`) — an input written while the command runs withholds the save, on CI too; `compare` accepts a task's identical rewrite of its own input. no post
84
+ - **Input stability** (`cache.inputStability`, workspace `inputStability`, `VX_INPUT_STABILITY`, `VX_INPUT_STABILITY_SEVERITY`) — an input written while the command runs withholds the save, on CI too, and the saves of every task that ran over its outputs; `strategy: 'mutable'` accepts a task's identical rewrite of its own input, `'unchecked'` skips the check; `severity: 'error'` fails the run instead of warning. no post
85
85
  - **Outputs are exactly the snapshot** (`exclusiveOutputs`) — a hit leaves the tree as the run did; two tasks may not own one output without an edge. [page](https://vznjs.github.io/vx/features/strict-outputs/) · post: [The tree is exactly the snapshot](https://vznjs.github.io/vx/blog/strict-output-ownership/)
86
86
  - **Keys from git's index** — tracked clean files hash by their blob id, no read. [page](https://vznjs.github.io/vx/features/keys-from-git/) · post: [Your cache key is already in git's index](https://vznjs.github.io/vx/blog/keys-from-git/)
87
87
  - **Config keyed as evaluated** — the key sees the resolved config object. [page](https://vznjs.github.io/vx/features/typescript-config/) · post: [Configs are programs](https://vznjs.github.io/vx/blog/resolved-config-hashing/)
88
88
  - **Cascade through inputs** — a task's key folds its upstream input keys, never outputs. [page](https://vznjs.github.io/vx/features/cascade/) · post: [Cascade through dependencies](https://vznjs.github.io/vx/blog/cascade-through-inputs/)
89
89
  - **Lockfile-aware keys** (`@vzn/vx-lockfile`: `pnpm()`, `bun()`, `npm()`, `yarn()`) — a bump re-keys only the projects whose closure changed. [page](https://vznjs.github.io/vx/features/lockfile-keys/) · post: [A lockfile bump should re-key two tasks](https://vznjs.github.io/vx/blog/lockfile-aware-keys/)
90
90
  - **Upfront keys** (`upfrontKeys`) — refuses an input glob a same-project task's outputs could match, so every key is known before anything runs. [page](https://vznjs.github.io/vx/features/upfront-keys/) · post: [Cascade through dependencies by folding input keys, never outputs](https://vznjs.github.io/vx/blog/cascade-through-inputs/)
91
- - **Cache controls** (`--no-cache`, `--force`, `--cache`) — off, refresh, or per-layer read/write. [page](https://vznjs.github.io/vx/features/cache-controls/) · post: [The cache on your terms](https://vznjs.github.io/vx/blog/the-cache-on-your-terms/)
91
+ - **Cache controls** (`--no-cache`, `--force`, `--cache`, `VX_CACHE`) — off, refresh, or per-layer read/write. [page](https://vznjs.github.io/vx/features/cache-controls/) · post: [The cache on your terms](https://vznjs.github.io/vx/blog/the-cache-on-your-terms/)
92
92
  - **Cache location** (`--cache-dir`, `cacheDir`, `VX_CACHE_DIR`) — where the cache lives. [page](https://vznjs.github.io/vx/features/cache-location/) · post: [The cache on your terms](https://vznjs.github.io/vx/blog/the-cache-on-your-terms/)
93
- - **Cache scope** (`cacheScope`, `VX_CACHE_SCOPE`) — trusted CI writes the remote cache; a laptop reads it. [page](https://vznjs.github.io/vx/features/cache-scope/) · post: [The cache on your terms](https://vznjs.github.io/vx/blog/the-cache-on-your-terms/)
93
+ - **Cache scope** (`cacheScope`, `VX_CACHE_SCOPE`, `--cache-scope`) — trusted CI writes the remote cache; a laptop reads it. [page](https://vznjs.github.io/vx/features/cache-scope/) · post: [The cache on your terms](https://vznjs.github.io/vx/blog/the-cache-on-your-terms/)
94
94
  - **Cache pruning** (`vx cache prune`, `--older-than`, `--max-size`, `--dry-run`, `cacheRetention`, `maxSize`, `olderThan`) — evict by age or size, LRU. [page](https://vznjs.github.io/vx/features/cache-prune/) · post: [One command to know your workspace](https://vznjs.github.io/vx/blog/know-your-workspace/)
95
95
  - **Remote outputs** (`--download`) — all, top-level only, or none. [page](https://vznjs.github.io/vx/features/remote-downloads/) · post: [A remote server you can trust in production](https://vznjs.github.io/vx/blog/reapi-in-production/)
96
96
  - **One store for every checkout** (`~/.vx/<id>/cache`) — clones and worktrees of a repo share cache entries. [page](https://vznjs.github.io/vx/features/shared-store/) · post: [The cache on your terms](https://vznjs.github.io/vx/blog/the-cache-on-your-terms/)
package/docs/patterns.md CHANGED
@@ -183,17 +183,15 @@ Sharing the patterns doesn't mean sharing the overhead. On the
183
183
  476-package / 1,428-node synthetic workspace
184
184
  (`packages/vx-bench/compare.ts 20 25 1`, 2026-09-02):
185
185
 
186
- | Runner | Warm (no restore) | Warm (restore) |
187
- | ------ | ----------------------- | ----------------------- |
188
- | **vx** | **297 ms** | **416 ms** |
189
- | Turbo | 342 ms (vx 15% faster) | 612 ms (vx 47% faster) |
190
- | Nx | 1.38 s (vx 4.6× faster) | 1.33 s (vx 3.2× faster) |
186
+ | Runner | Warm (no restore) | Warm (restore) |
187
+ | ------ | ----------------- | -------------- |
188
+ | **vx** | **297 ms** | **416 ms** |
189
+ | Turbo | 342 ms | 612 ms |
190
+ | Nx | 1.38 s | 1.33 s |
191
191
 
192
192
  A cached run is all runner overhead. Cold builds are compared by the
193
193
  time the runner adds, on [`benchmarks.md`](./benchmarks.md).
194
194
 
195
- vx N% faster: that tool takes N% longer than vx; N× faster: N times as long.
196
-
197
195
  Full breakdown + methodology in [`benchmarks.md`](./benchmarks.md).
198
196
 
199
197
  ## Where the lineage diverges
package/docs/schema.md CHANGED
@@ -704,19 +704,47 @@ Semantics:
704
704
  interface CacheConfig {
705
705
  inputs: CacheInputs // required when `cache` is provided
706
706
  outputs: CacheOutputs // required when `cache` is provided
707
- rewrittenInputs?: 'withhold' | 'compare' // optional, default 'withhold'
707
+ inputStability?: InputStability // optional
708
+ }
709
+
710
+ interface InputStability {
711
+ strategy?: 'immutable' | 'mutable' | 'unchecked' // default 'immutable'
712
+ severity?: 'warn' | 'error' // default 'warn'
708
713
  }
709
714
  ```
710
715
 
711
- `rewrittenInputs` says what a write to an input while the command runs
712
- means. `'withhold'` (the default, on CI too): the result stands but the
713
- entry is not saved, since an edit undone mid-run, or another task
714
- changing the file and putting it back, cannot be told from no edit.
715
- `'compare'`: a file rewritten to the bytes the key folded counts as
716
- unchanged and the entry is saved, for a task that rewrites its own input
717
- identically (tsdown's `package.json`); different bytes still withhold
718
- it. `VX_REWRITTEN_INPUTS=compare` sets the default for every task that
719
- does not say (owner, 2026-10-12).
716
+ `inputStability` says what a write to an input while the command runs
717
+ means. `strategy` says which writes count:
718
+
719
+ - `'immutable'` (the default, on CI too): any write. An edit undone
720
+ mid-run, or another task changing the file and putting it back, cannot
721
+ be told from no edit.
722
+ - `'mutable'`: a file rewritten to the bytes the key folded counts as
723
+ unchanged, for a task that rewrites its own input identically
724
+ (tsdown's `package.json`); different bytes still count.
725
+ - `'unchecked'`: none. The entry is saved under the key taken before the
726
+ command, whatever the command did to its inputs; `severity` is ignored.
727
+ A key that no longer held before the command started still withholds
728
+ it.
729
+
730
+ A write that counts withholds the entry, and `severity` says what else
731
+ happens: `'warn'` (the default) keeps the result, says why on the status
732
+ line, and the run goes on; `'error'` fails the task, so the run fails.
733
+ Either way, every task that ran over the task's outputs, down the graph,
734
+ withholds its save too, and a task of any project that declares the
735
+ moved file as an input withholds its own.
736
+
737
+ Each field is resolved on its own, highest first:
738
+
739
+ 1. `VX_INPUT_STABILITY` (`immutable`, `mutable`, `unchecked`) and
740
+ `VX_INPUT_STABILITY_SEVERITY` (`warn`, `error`), for the whole run
741
+ (a CI job that wants `error` sets it without editing configs); any
742
+ other value is refused before a task runs;
743
+ 2. the task's own `cache.inputStability`;
744
+ 3. the workspace's `inputStability` (`vx.workspace.ts`);
745
+ 4. the default, `'immutable'` and `'warn'`.
746
+
747
+ Never folded into a cache key.
720
748
 
721
749
  ```ts
722
750
  import { defineProject } from '@vzn/vx/config'
@@ -724,12 +752,13 @@ import { defineProject } from '@vzn/vx/config'
724
752
  export default defineProject({
725
753
  tasks: {
726
754
  build: {
727
- // tsdown rewrites package.json with the same bytes on every build.
755
+ // tsdown rewrites package.json with the same bytes on every build;
756
+ // different bytes fail the run rather than rebuild next time.
728
757
  exec: { command: 'tsdown' },
729
758
  cache: {
730
759
  inputs: { files: ['src/**', 'package.json'] },
731
760
  outputs: { files: ['dist/**'] },
732
- rewrittenInputs: 'compare',
761
+ inputStability: { strategy: 'mutable', severity: 'error' },
733
762
  },
734
763
  },
735
764
  },
@@ -1564,6 +1593,7 @@ export default defineWorkspace({
1564
1593
  cacheScope: 'trusted',
1565
1594
  rules: { exclusiveOutputs: true, upfrontKeys: true },
1566
1595
  schedule: 'most-work',
1596
+ inputStability: { strategy: 'immutable', severity: 'warn' },
1567
1597
  plugins: [otel()],
1568
1598
  })
1569
1599
  ```
@@ -1586,6 +1616,8 @@ interface WorkspaceConfig {
1586
1616
  rules?: WorkspaceRules
1587
1617
  /** Which ready task starts first with no timings: 'most-work' (default), 'critical-path', 'direct-dependents', 'ready-order'. */
1588
1618
  schedule?: 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order'
1619
+ /** Every task's cache.inputStability default: { strategy, severity }. */
1620
+ inputStability?: InputStability
1589
1621
  /** Run-level plugins (cache / executor / telemetry capabilities). */
1590
1622
  plugins?: readonly (Plugin | undefined | null | false)[]
1591
1623
  }
@@ -1659,7 +1691,7 @@ run` takes `'read-only'` off CI (`CI` unset, `0` or `false`) and
1659
1691
  each other. A scope's key is derived from the task key, so every
1660
1692
  remote wire stores it unchanged. A clamp on `--cache`, never a
1661
1693
  widening. `github()` sets it on Actions: `pr-<n>`, `ref-<name>` off
1662
- the default branch. `VX_CACHE_SCOPE` beats both. A client-side convention, not a security
1694
+ the default branch. `VX_CACHE_SCOPE` beats both, and `vx run --cache-scope` beats all three. A client-side convention, not a security
1663
1695
  boundary: a run holding a write credential can write any key, so only
1664
1696
  a cache server that scopes writes by token can refuse one
1665
1697
  (`docs/security.md` § Cache poisoning). Not folded into any cache key.
@@ -1674,6 +1706,10 @@ run` takes `'read-only'` off CI (`CI` unset, `0` or `false`) and
1674
1706
  their ties. The default costs what it did; another strategy is
1675
1707
  computed only when named. Any other value is refused. Never folded
1676
1708
  into a cache key.
1709
+ - **`inputStability`** — the `cache.inputStability` of every task that
1710
+ does not set its own field, same shape (`{ strategy, severity }`, §
1711
+ `cache`). The env vars still override it for the run. Never folded
1712
+ into a cache key.
1677
1713
  - **`rules`** — checks on the task graph, each on unless set to
1678
1714
  `false`. Turning one off allows a shape vx runs correctly but more
1679
1715
  slowly; never folded into a cache key. A value that is not a boolean,
@@ -2088,7 +2124,9 @@ a user meets most:
2088
2124
  | `cache.inputs.tasks must be an array of non-empty strings` | Non-string / empty entry, or a bare string. |
2089
2125
  | `cache.inputs.tasks: "<name>" names no task in <task>.dependsOn` | An exact entry no `dependsOn` entry of its form names. |
2090
2126
  | `cache.outputs is required when cache is set` | Forgot `outputs`. |
2091
- | `cache.rewrittenInputs must be 'withhold' or 'compare'` | Any other value (`'bytes'`, `true`). |
2127
+ | `cache.inputStability must be an object` | A bare string (`inputStability: 'mutable'`). |
2128
+ | `cache.inputStability.strategy must be 'immutable', 'mutable' or 'unchecked'` | Any other value (`'content'`, `false`). |
2129
+ | `cache.inputStability.severity must be 'warn' or 'error'` | Any other value (`'fail'`, `true`). |
2092
2130
  | `cache.outputs must be an object` | Present but not an object: a string (`outputs: 'dist'`). An array is the row above. |
2093
2131
  | `cache.outputs.files must be an array` | Wrong shape. |
2094
2132
  | `cache.inputs.files: every entry is a negation, which selects NOTHING` | Only `!` globs — nothing to subtract from. |
package/docs/security.md CHANGED
@@ -42,7 +42,7 @@ namespace), or none.
42
42
  `cacheScope` is the client half. `'pr-123'` reads its own keys,
43
43
  then the trusted ones, and writes only its own, which `github()` sets on
44
44
  Actions (`pr-<n>`, `ref-<name>`; the default branch stays trusted)
45
- and `VX_CACHE_SCOPE` sets on any CI.
45
+ and `VX_CACHE_SCOPE` or `--cache-scope` sets on any CI.
46
46
  It keeps honest runs apart and stops a non-hermetic PR build from
47
47
  landing where main reads; it is not a boundary, since the PR's own
48
48
  config decides it and any run holding a write credential can write any
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.635",
3
+ "version": "0.0.636",
4
4
  "description": "Task orchestration you own: correct, sandboxed, extensible, with a content-addressed build cache.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.635",
25
- "@vzn/vx-linux-arm64": "0.0.635",
26
- "@vzn/vx-darwin-x64": "0.0.635",
27
- "@vzn/vx-darwin-arm64": "0.0.635"
24
+ "@vzn/vx-linux-x64": "0.0.636",
25
+ "@vzn/vx-linux-arm64": "0.0.636",
26
+ "@vzn/vx-darwin-x64": "0.0.636",
27
+ "@vzn/vx-darwin-arm64": "0.0.636"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/schemas/show.json CHANGED
@@ -116,7 +116,14 @@
116
116
  "workspaceFiles": { "$ref": "#/$defs/stringList" }
117
117
  }
118
118
  },
119
- "rewrittenInputs": { "enum": ["withhold", "compare"] }
119
+ "inputStability": {
120
+ "type": "object",
121
+ "additionalProperties": false,
122
+ "properties": {
123
+ "strategy": { "enum": ["immutable", "mutable", "unchecked"] },
124
+ "severity": { "enum": ["warn", "error"] }
125
+ }
126
+ }
120
127
  }
121
128
  }
122
129
  }
package/src/cli/help.ts CHANGED
@@ -140,7 +140,9 @@ export function helpText(pluginCommands: readonly string[] = []): string {
140
140
  ' (layer = local|remote, flags ⊆ rw). A named layer is set EXACTLY',
141
141
  ' to its flags; unnamed layers keep their value. Examples:',
142
142
  ' local:rw,remote:r (remote read-only), remote: (remote off),',
143
- ' local:r (local read-only).',
143
+ ' local:r (local read-only). Without it, VX_CACHE holds the spec.',
144
+ " --cache-scope <name> This run's cacheScope: trusted | read-only | a scope (pr-123).",
145
+ ' Beats VX_CACHE_SCOPE and the workspace cacheScope.',
144
146
  ' --cache-dir <path> Cache directory override (cwd-relative). Beats VX_CACHE_DIR,',
145
147
  ' the workspace cacheDir field and the ~/.vx/<id>/cache default.',
146
148
  ' --retry <n> Re-run a failed task up to <n> more times (default for tasks',
package/src/cli/run.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { appendFileSync, mkdirSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { isatty } from 'node:tty'
4
- import { findWorkspaceRoot } from '../workspace/index.js'
4
+ import { cacheScopeError, findWorkspaceRoot, isCacheScope } from '../workspace/index.js'
5
5
  import { translateForeign } from './foreign-flags.js'
6
6
  import { flagHint, formatValue, refuse, seeHelp } from './help.js'
7
7
  import {
@@ -52,6 +52,8 @@ export interface RunArgs {
52
52
  remoteRequested?: boolean
53
53
  /** `--cache-dir <path>`: override the cache directory (cwd-relative). */
54
54
  cacheDir: string | undefined
55
+ /** `--cache-scope <name>`: this run's `cacheScope`, above `VX_CACHE_SCOPE` and the workspace's. */
56
+ cacheScope?: string
55
57
  frozen: boolean
56
58
  /**
57
59
  * `--retry <n>` / `--retry=<n>`: run-level retry default for tasks
@@ -158,6 +160,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
158
160
  // beats `--force`, and `--cache` is the base both may override.
159
161
  let cachePolicy: CachePolicy = { ...FULL_CACHE_POLICY }
160
162
  let cacheSpecError: string | undefined
163
+ let cacheFlag = false
161
164
  let noCacheFlag = false
162
165
  let forceFlag = false
163
166
 
@@ -282,7 +285,13 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
282
285
  }
283
286
  }
284
287
  out.cacheDir = v
288
+ } else if (a === '--cache-scope' || a?.startsWith('--cache-scope=')) {
289
+ const v = a === '--cache-scope' ? before[++i] : a.slice('--cache-scope='.length)
290
+ if (v === undefined || !isCacheScope(v))
291
+ return { ...out, error: cacheScopeError('--cache-scope') }
292
+ out.cacheScope = v
285
293
  } else if (a === '--cache' || a?.startsWith('--cache=')) {
294
+ cacheFlag = true
286
295
  const v = a === '--cache' ? before[++i] : a.slice('--cache='.length)
287
296
  if (v === undefined)
288
297
  return {
@@ -407,6 +416,21 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
407
416
  if (cacheSpecError !== undefined) {
408
417
  return { ...out, error: cacheSpecError }
409
418
  }
419
+ // `VX_CACHE` is the spec a run takes when no `--cache` names one.
420
+ const envSpec = process.env['VX_CACHE'] || undefined
421
+ if (!cacheFlag && envSpec !== undefined) {
422
+ if (!envSpec.split(',').some((seg) => seg.trim().length > 0))
423
+ return {
424
+ ...out,
425
+ error: `VX_CACHE needs a spec like local:r, local:rw, remote:, or local:,remote:rw`,
426
+ }
427
+ try {
428
+ cachePolicy = parseCachePolicy(envSpec, cachePolicy)
429
+ } catch (err) {
430
+ return { ...out, error: `VX_CACHE: ${err instanceof Error ? err.message : String(err)}` }
431
+ }
432
+ if (/remote:[rw]/.test(envSpec)) out.remoteRequested = true
433
+ }
410
434
  // Precedence: --no-cache (all off) beats --force (reads off, writes
411
435
  // kept), both layered on top of any --cache spec.
412
436
  if (noCacheFlag) {
@@ -611,6 +635,7 @@ export async function resolveRunOptions(
611
635
  tasks: [...tasks],
612
636
  cache: parsed.cache,
613
637
  ...(parsed.remoteRequested ? { remoteRequested: true } : {}),
638
+ ...(parsed.cacheScope !== undefined ? { cacheScope: parsed.cacheScope } : {}),
614
639
  // A laptop reads the shared cache and never writes it (owner,
615
640
  // 2026-10-03); naming the remote in `--cache` is asking to.
616
641
  ...(!parsed.remoteRequested && !onCI(process.env['CI'])
package/src/config.ts CHANGED
@@ -42,7 +42,8 @@ export interface WorkspaceConfig {
42
42
  * then the trusted ones, and writes go to the scope's only, so a PR never
43
43
  * writes what the default branch reads. A client-side convention, not a
44
44
  * security boundary: only a cache server that scopes writes by token
45
- * can refuse a run that ignores it. `VX_CACHE_SCOPE` beats it. Never
45
+ * can refuse a run that ignores it. `VX_CACHE_SCOPE` beats it, and
46
+ * `vx run --cache-scope` beats both. Never
46
47
  * folded into a cache key.
47
48
  */
48
49
  cacheScope?: string
@@ -55,6 +56,12 @@ export interface WorkspaceConfig {
55
56
  * `schedule` plugin's weights still come first; this breaks their ties.
56
57
  */
57
58
  schedule?: 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order'
59
+ /**
60
+ * The `cache.inputStability` of every task that does not set its own
61
+ * field. `VX_INPUT_STABILITY` / `VX_INPUT_STABILITY_SEVERITY` override
62
+ * both for the run.
63
+ */
64
+ inputStability?: InputStability
58
65
  /**
59
66
  * Graph rules a run checks before it schedules anything. Each is on unless
60
67
  * set to `false`; turning one off allows a shape vx handles correctly but
@@ -491,15 +498,29 @@ export interface CacheConfig {
491
498
  /** What this task produces. Captured for restore on a cache hit. */
492
499
  outputs: CacheOutputs
493
500
  /**
494
- * What a write to an input while the command runs means. `'withhold'`
495
- * (the default): the entry is not saved, whatever the file holds after,
496
- * since an edit undone mid-run cannot be told from no edit. `'compare'`:
497
- * a file rewritten to the bytes the key folded counts as unchanged, for
498
- * a task that rewrites its own input identically (tsdown's
499
- * `package.json`). `VX_REWRITTEN_INPUTS=compare` sets the default for
500
- * every task that does not say.
501
+ * What a write to an input while the command runs means. Each field,
502
+ * highest first: its env var, this, the workspace's `inputStability`,
503
+ * the default (`'immutable'`, `'warn'`).
504
+ */
505
+ inputStability?: InputStability
506
+ }
507
+
508
+ export interface InputStability {
509
+ /**
510
+ * `'immutable'` (the default): any write to an input during the command
511
+ * withholds the entry, whatever the file holds after, since an edit
512
+ * undone mid-run cannot be told from no edit. `'mutable'`: a file
513
+ * rewritten to the bytes the key folded counts as unchanged, for a task
514
+ * that rewrites its own input identically (tsdown's `package.json`).
515
+ * `'unchecked'`: no check.
516
+ */
517
+ strategy?: 'immutable' | 'mutable' | 'unchecked'
518
+ /**
519
+ * What a withheld entry does to the run. `'warn'` (the default): the
520
+ * result stands, vx says why, and the run goes on. `'error'`: the task
521
+ * fails, so the run fails.
501
522
  */
502
- rewrittenInputs?: 'withhold' | 'compare'
523
+ severity?: 'warn' | 'error'
503
524
  }
504
525
 
505
526
  export interface CacheOutputs {
package/src/index.ts CHANGED
@@ -48,6 +48,7 @@ export type {
48
48
  CacheConfig,
49
49
  CacheInputs,
50
50
  CacheOutputs,
51
+ InputStability,
51
52
  SandboxConfig,
52
53
  SandboxGrants,
53
54
  SandboxDenials,
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path'
2
- import type { ExecConfig, TaskConfig, CacheConfig } from '../config.js'
2
+ import type { ExecConfig, TaskConfig, CacheConfig, InputStability } from '../config.js'
3
3
  import {
4
4
  ArtifactVanishedError,
5
5
  type CacheEntry,
@@ -175,6 +175,11 @@ export interface ExecuteArgs {
175
175
  * `exec.timeout` wins. Threaded as an option only — never hashed.
176
176
  */
177
177
  timeout?: number
178
+ /**
179
+ * The run's `inputStability` layers around the task's own: `run` from
180
+ * the env vars (above the task), `workspace` from `vx.workspace` (below).
181
+ */
182
+ inputStability?: { run: InputStability; workspace?: InputStability }
178
183
  /** Per-run memo for `git ls-files` (one entry per project dir). */
179
184
  gitFilesCache?: GitFilesCache
180
185
  /** Per-run memo for derived hashes (package.json bytes + task config). */
@@ -338,15 +343,21 @@ async function restoreOrMiss(
338
343
  }
339
344
 
340
345
  /**
341
- * Does a write to an input during the command count as unchanged when it
342
- * left the bytes the key folded (`movedInput`'s `writesByBytes`)? Only when
343
- * the task says `compare`, or says nothing and `VX_REWRITTEN_INPUTS` is
344
- * `compare`: by default any such write withholds the save, on CI too,
345
- * since a concurrent task can change an input and put it back (owner,
346
- * 2026-10-12).
346
+ * What a write to an input during the command means, each field highest
347
+ * first: the env var (`layers.run`), the task's `cache.inputStability`,
348
+ * the workspace's, then `'immutable'` and `'warn'` (owner, 2026-10-12), so
349
+ * by default any such write withholds the save, on CI too, since a
350
+ * concurrent task can change an input and put it back.
347
351
  */
348
- function rewritesByBytes(cache: CacheConfig | undefined): boolean {
349
- return (cache?.rewrittenInputs ?? process.env['VX_REWRITTEN_INPUTS']) === 'compare'
352
+ function inputStabilityOf(
353
+ cache: CacheConfig | undefined,
354
+ layers: ExecuteArgs['inputStability'],
355
+ ): Required<InputStability> {
356
+ const task = cache?.inputStability
357
+ return {
358
+ strategy: layers?.run.strategy ?? task?.strategy ?? layers?.workspace?.strategy ?? 'immutable',
359
+ severity: layers?.run.severity ?? task?.severity ?? layers?.workspace?.severity ?? 'warn',
360
+ }
350
361
  }
351
362
 
352
363
  /**
@@ -1462,6 +1473,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1462
1473
 
1463
1474
  const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
1464
1475
 
1476
+ const stability = inputStabilityOf(args.node.config.cache, args.inputStability)
1477
+ let movedFails = false
1465
1478
  let own: Awaited<ReturnType<typeof ownOutputs>>
1466
1479
  if (effectiveExitCode === 0 && willSave && deferralRequested) {
1467
1480
  // The outputs never landed here: no artifact, no rows. A partial local
@@ -1576,7 +1589,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1576
1589
  described!.facts,
1577
1590
  cache,
1578
1591
  described!.describedAt,
1579
- rewritesByBytes(args.node.config.cache),
1592
+ stability.strategy === 'mutable',
1580
1593
  )) ?? described!.added()
1581
1594
  )
1582
1595
  }
@@ -1597,6 +1610,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1597
1610
  unkeyed = true
1598
1611
  return false
1599
1612
  }
1613
+ // `'unchecked'` skips the check of the command's own window; a key the
1614
+ // re-derive before the command already disagreed with still withholds,
1615
+ // since it never described the bytes the command read.
1616
+ if (stability.strategy === 'unchecked' && described!.hash === hash) return true
1600
1617
  const endCheck = span('miss: recheck inputs')
1601
1618
  const moved = await movedSinceKey()
1602
1619
  endCheck()
@@ -1611,15 +1628,25 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1611
1628
  moved === null || path.basename(moved) === 'package.json'
1612
1629
  ? ''
1613
1630
  : '; if the task writes it, declare it in cache.outputs'
1614
- log.status(
1615
- `[vx] ${node.id}: ${what} changed after its key was taken — the result stands, ` +
1616
- `but is not saved under a key that no longer describes it${hint}`,
1617
- )
1631
+ if (stability.severity === 'error') {
1632
+ log.status(
1633
+ `[vx] ${node.id}: ${what} changed after its key was taken — the result is not saved, ` +
1634
+ `and inputStability.severity is 'error', so the task fails${hint}`,
1635
+ )
1636
+ movedFails = true
1637
+ } else {
1638
+ log.status(
1639
+ `[vx] ${node.id}: ${what} changed after its key was taken — the result stands, ` +
1640
+ `but is not saved under a key that no longer describes it${hint}`,
1641
+ )
1642
+ }
1618
1643
  forgetUndeclaredWrites(args, wsOutputs.length > 0 ? 'workspace' : 'project')
1619
1644
  unkeyed = true
1620
1645
  return false
1621
1646
  }
1622
1647
 
1648
+ if (movedFails) effectiveExitCode = 1
1649
+
1623
1650
  if (unkeyedUpstream !== undefined && willWrite && effectiveExitCode === 0) {
1624
1651
  log.status(
1625
1652
  `[vx] ${node.id}: ran over ${unkeyedUpstream.node.id}'s outputs, which its key no longer ` +
@@ -86,6 +86,8 @@ export interface RunOptions {
86
86
  * saying it is not.
87
87
  */
88
88
  remoteRequested?: boolean
89
+ /** This run's `cacheScope` (the CLI's `--cache-scope`), above `VX_CACHE_SCOPE` and `vx.workspace`. */
90
+ cacheScope?: string
89
91
  /**
90
92
  * The `cacheScope` a run takes when `vx.workspace` names none. The CLI
91
93
  * passes `'read-only'` off CI (`CI` unset, `0` or `false`) unless
@@ -288,13 +288,13 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
288
288
 
289
289
  // The local cache opens BEFORE the configs load: it is also where their
290
290
  // cached evaluations live.
291
- // `VX_CACHE_SCOPE` wins over the workspace (and `github()`'s guess):
291
+ // `--cache-scope`, then `VX_CACHE_SCOPE`, win over the workspace (and `github()`'s guess):
292
292
  // a CI job that knows its trust level says so without editing config.
293
293
  const envScope = process.env['VX_CACHE_SCOPE'] || undefined
294
294
  if (envScope !== undefined && !isCacheScope(envScope)) throw cacheScopeEnvError()
295
295
  const policy: CachePolicy = scopeCachePolicy(
296
296
  options.cache ?? FULL_CACHE_POLICY,
297
- envScope ?? workspaceConfig?.cacheScope ?? options.defaultCacheScope,
297
+ options.cacheScope ?? envScope ?? workspaceConfig?.cacheScope ?? options.defaultCacheScope,
298
298
  )
299
299
  const localCache = new Cache(
300
300
  cacheDir,
@@ -3,7 +3,12 @@
3
3
  // so the layers can be swapped without touching the others.
4
4
 
5
5
  import type { ProjectEntry } from '../workspace/index.js'
6
- import { loadWorkspace, unreachedHint, unreachedPackages } from '../workspace/index.js'
6
+ import {
7
+ inputStabilityEnv,
8
+ loadWorkspace,
9
+ unreachedHint,
10
+ unreachedPackages,
11
+ } from '../workspace/index.js'
7
12
  import { realpathSync } from 'node:fs'
8
13
  import path from 'node:path'
9
14
  import {
@@ -610,6 +615,13 @@ async function runOnBus(
610
615
  // ignored (a bad timeout must not silently disable a task's own limit).
611
616
  const taskTimeoutDefault =
612
617
  options.timeout ?? readTaskTimeoutEnv() ?? workspaceConfig?.timeout ?? undefined
618
+ // Read once, before any task: a bad value is refused here, not mid-run.
619
+ const inputStability = {
620
+ run: inputStabilityEnv(),
621
+ ...(workspaceConfig?.inputStability !== undefined
622
+ ? { workspace: workspaceConfig.inputStability }
623
+ : {}),
624
+ }
613
625
 
614
626
  // Run-scoped registries of live subprocesses:
615
627
  // - `liveChildren`: in-flight children. The runner adds/removes
@@ -1056,6 +1068,7 @@ async function runOnBus(
1056
1068
  }
1057
1069
  if (options.retries !== undefined) a.retries = options.retries
1058
1070
  if (taskTimeoutDefault !== undefined) a.timeout = taskTimeoutDefault
1071
+ a.inputStability = inputStability
1059
1072
  if (download.modeOf.get(node.id) === 'deferred') a.download = 'deferred'
1060
1073
  if (placements.remoteOnlyNoop.has(node.id)) a.remoteOnlyNoop = true
1061
1074
  if (placements.remoteOnly.has(node.id)) a.remoteOnly = true
@@ -238,7 +238,7 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
238
238
  * input changed and changed BACK while the command ran matched its digest
239
239
  * again, and the entry saved the other content's output under the key (item
240
240
  * 1015). A write before it — an upstream's — is judged by content, as before.
241
- * `writesByBytes` (`cache.rewrittenInputs: 'compare'`) judges a write
241
+ * `writesByBytes` (`inputStability.strategy: 'mutable'`) judges a write
242
242
  * during the command by content too: the task says the same bytes are its
243
243
  * own identical rewrite (tsdown's package.json), not an edit undone.
244
244
  */
@@ -10,6 +10,7 @@ import {
10
10
  PLUGIN_FUNCTION_HOOKS,
11
11
  PLUGIN_HOOKS,
12
12
  PLUGIN_PACKAGE,
13
+ type InputStability,
13
14
  type Plugin,
14
15
  type ProjectConfig,
15
16
  type WorkspaceConfig,
@@ -43,9 +44,65 @@ export function isCacheScope(v: unknown): v is string {
43
44
  return typeof v === 'string' && /^[\w.@/-]{1,128}$/.test(v)
44
45
  }
45
46
 
46
- /** Why `VX_CACHE_SCOPE` is refused, in `cacheScope`'s words. */
47
+ /** Why a `VX_CACHE_SCOPE` or `--cache-scope` value is refused, in `cacheScope`'s words. */
48
+ export function cacheScopeError(source = 'VX_CACHE_SCOPE'): string {
49
+ return `${source} ${CACHE_SCOPE_RULE}`
50
+ }
51
+
52
+ /** Why `VX_CACHE_SCOPE` is refused, as the run's error. */
47
53
  export function cacheScopeEnvError(): UserError {
48
- return new UserError(`VX_CACHE_SCOPE ${CACHE_SCOPE_RULE}`)
54
+ return new UserError(cacheScopeError())
55
+ }
56
+
57
+ const STRATEGIES = ['immutable', 'mutable', 'unchecked'] as const
58
+ const SEVERITIES = ['warn', 'error'] as const
59
+ const INPUT_STABILITY_FIELDS = new Set(['strategy', 'severity'])
60
+ const oneOf = (values: readonly string[]): string =>
61
+ `must be ${values
62
+ .map((v) => `'${v}'`)
63
+ .join(', ')
64
+ .replace(/, ([^,]*)$/, ' or $1')}`
65
+
66
+ function envOneOf<T extends string>(
67
+ name: string,
68
+ raw: string | undefined,
69
+ values: readonly T[],
70
+ ): T | undefined {
71
+ if (raw === undefined || raw === '') return undefined
72
+ if (!(values as readonly string[]).includes(raw)) throw new UserError(`${name} ${oneOf(values)}`)
73
+ return raw as T
74
+ }
75
+
76
+ function validateInputStability(stability: unknown, at: string): void {
77
+ if (typeof stability !== 'object' || stability === null || Array.isArray(stability)) {
78
+ throw new UserError(`${at} must be an object — \`inputStability: { strategy: 'mutable' }\``)
79
+ }
80
+ assertKnownFields(stability, INPUT_STABILITY_FIELDS, at)
81
+ const { strategy, severity } = stability as Record<string, unknown>
82
+ if (strategy !== undefined && !STRATEGIES.includes(strategy as never)) {
83
+ throw new UserError(`${at}.strategy ${oneOf(STRATEGIES)}`)
84
+ }
85
+ if (severity !== undefined && !SEVERITIES.includes(severity as never)) {
86
+ throw new UserError(`${at}.severity ${oneOf(SEVERITIES)}`)
87
+ }
88
+ }
89
+
90
+ /**
91
+ * `VX_INPUT_STABILITY` and `VX_INPUT_STABILITY_SEVERITY`, which override
92
+ * every task's `cache.inputStability` for the run: each undefined when
93
+ * unset or empty, refused when it names no value.
94
+ */
95
+ export function inputStabilityEnv(): InputStability {
96
+ const strategy = envOneOf('VX_INPUT_STABILITY', process.env['VX_INPUT_STABILITY'], STRATEGIES)
97
+ const severity = envOneOf(
98
+ 'VX_INPUT_STABILITY_SEVERITY',
99
+ process.env['VX_INPUT_STABILITY_SEVERITY'],
100
+ SEVERITIES,
101
+ )
102
+ return {
103
+ ...(strategy !== undefined ? { strategy } : {}),
104
+ ...(severity !== undefined ? { severity } : {}),
105
+ }
49
106
  }
50
107
 
51
108
  const WORKSPACE_FIELDS = new Set([
@@ -57,6 +114,7 @@ const WORKSPACE_FIELDS = new Set([
57
114
  'cacheScope',
58
115
  'rules',
59
116
  'schedule',
117
+ 'inputStability',
60
118
  'plugins',
61
119
  ])
62
120
 
@@ -198,6 +256,9 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
198
256
  throw new UserError(`${configPath}: \`cacheScope\` ${CACHE_SCOPE_RULE}`)
199
257
  }
200
258
  if (config.rules !== undefined) validateRules(config.rules, configPath)
259
+ if (config.inputStability !== undefined) {
260
+ validateInputStability(config.inputStability, `${configPath}: \`inputStability\``)
261
+ }
201
262
  if (config.schedule !== undefined && !Object.hasOwn(SCHEDULES, config.schedule)) {
202
263
  throw new UserError(
203
264
  `${configPath}: \`schedule\` must be one of ${Object.keys(SCHEDULES)
@@ -668,10 +729,9 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
668
729
  )
669
730
  }
670
731
  assertKnownFields(cache, CACHE_FIELDS, `${where}.cache`)
671
- const rewritten = (cache as { rewrittenInputs?: unknown }).rewrittenInputs
672
- if (rewritten !== undefined && rewritten !== 'withhold' && rewritten !== 'compare') {
673
- throw new UserError(`${where}.cache.rewrittenInputs must be 'withhold' or 'compare'`)
674
- }
732
+ const stability = (cache as { inputStability?: unknown }).inputStability
733
+ if (stability !== undefined)
734
+ validateInputStability(stability, `${where}.cache.inputStability`)
675
735
  const inputs = (cache as { inputs?: unknown }).inputs
676
736
  const outputs = (cache as { outputs?: unknown }).outputs
677
737
  // A present field of the wrong shape (`outputs: 'dist'`) was told it
@@ -908,7 +968,7 @@ const EXEC_FIELDS = new Set([
908
968
  ])
909
969
  const PERSISTENT_FIELDS = new Set(['readyWhen'])
910
970
  const ENV_FIELDS = new Set(['passThrough', 'define', 'secret'])
911
- const CACHE_FIELDS = new Set(['inputs', 'outputs', 'rewrittenInputs'])
971
+ const CACHE_FIELDS = new Set(['inputs', 'outputs', 'inputStability'])
912
972
  const CACHE_INPUT_FIELDS = new Set([
913
973
  'files',
914
974
  'workspaceFiles',
@@ -951,6 +1011,15 @@ export const REMOVED_FIELDS: ReadonlyMap<
951
1011
  },
952
1012
  },
953
1013
  ],
1014
+ [
1015
+ CACHE_FIELDS,
1016
+ {
1017
+ rewrittenInputs: {
1018
+ removedIn: '0.0.636',
1019
+ use: "`inputStability: { strategy: 'immutable' | 'mutable' }` ('withhold', 'compare')",
1020
+ },
1021
+ },
1022
+ ],
954
1023
  ])
955
1024
 
956
1025
  /**
@@ -37,6 +37,8 @@ export {
37
37
  } from './project-loader.js'
38
38
  export {
39
39
  cacheScopeEnvError,
40
+ cacheScopeError,
41
+ inputStabilityEnv,
40
42
  isCacheScope,
41
43
  validateProjectConfig,
42
44
  validateWorkspace,