@vzn/vx 0.0.633 → 0.0.635

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.
Files changed (53) hide show
  1. package/README.md +51 -52
  2. package/docs/caching.md +11 -2
  3. package/docs/cli.md +192 -56
  4. package/docs/execution.md +1 -1
  5. package/docs/features.md +6 -3
  6. package/docs/patterns.md +8 -5
  7. package/docs/schema.md +48 -2
  8. package/docs/security.md +6 -0
  9. package/launcher.cjs +0 -0
  10. package/launcher.sh +50 -0
  11. package/package.json +17 -8
  12. package/schemas/error.json +5 -1
  13. package/schemas/last.json +5 -1
  14. package/schemas/plan.json +4 -0
  15. package/schemas/show.json +2 -1
  16. package/schemas/why.json +4 -1
  17. package/skills/vx/SKILL.md +7 -1
  18. package/src/cache/cache.ts +60 -5
  19. package/src/cache/inputs.ts +66 -30
  20. package/src/cache/layered-cache.ts +3 -1
  21. package/src/cli/docs.ts +9 -3
  22. package/src/cli/help.ts +2 -1
  23. package/src/cli/init.ts +9 -2
  24. package/src/cli/last.ts +9 -1
  25. package/src/cli/plan-format.ts +3 -1
  26. package/src/cli/plugin-templates.ts +1 -1
  27. package/src/cli/run.ts +17 -8
  28. package/src/cli/select.ts +17 -11
  29. package/src/cli/show.ts +2 -2
  30. package/src/config.ts +23 -2
  31. package/src/graph/priorities.ts +72 -6
  32. package/src/graph/scheduler.ts +31 -7
  33. package/src/graph/task-graph.ts +75 -20
  34. package/src/index.ts +6 -1
  35. package/src/orchestrator/execute-task.ts +30 -1
  36. package/src/orchestrator/framed-output.ts +27 -1
  37. package/src/orchestrator/history.ts +75 -1
  38. package/src/orchestrator/index.ts +1 -0
  39. package/src/orchestrator/path-links.ts +5 -3
  40. package/src/orchestrator/plugin.ts +3 -1
  41. package/src/orchestrator/prepare.ts +15 -12
  42. package/src/orchestrator/remote-prefetch.ts +8 -2
  43. package/src/orchestrator/run-failures.ts +20 -3
  44. package/src/orchestrator/run.ts +24 -2
  45. package/src/orchestrator/sandbox-fix.ts +123 -0
  46. package/src/orchestrator/task-hash.ts +7 -1
  47. package/src/util/errors.ts +9 -2
  48. package/src/util/paths.ts +32 -0
  49. package/src/workspace/affected.ts +16 -4
  50. package/src/workspace/config-schema.ts +30 -2
  51. package/src/workspace/lockfile.ts +29 -2
  52. package/src/workspace/migration.ts +51 -4
  53. package/src/workspace/project-loader.ts +3 -1
package/docs/patterns.md CHANGED
@@ -183,11 +183,14 @@ 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 | Fresh (cold) | Warm (no restore) | Warm (restore) |
187
- | ------ | --------------------- | ----------------------- | ----------------------- |
188
- | **vx** | 1m 40s | **297 ms** | **416 ms** |
189
- | Turbo | 1m 40s (vx same) | 342 ms (vx 15% faster) | 612 ms (vx 47% faster) |
190
- | Nx | 3m 23s (vx 2× faster) | 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 (vx 15% faster) | 612 ms (vx 47% faster) |
190
+ | Nx | 1.38 s (vx 4.6× faster) | 1.33 s (vx 3.2× faster) |
191
+
192
+ A cached run is all runner overhead. Cold builds are compared by the
193
+ time the runner adds, on [`benchmarks.md`](./benchmarks.md).
191
194
 
192
195
  vx N% faster: that tool takes N% longer than vx; N× faster: N times as long.
193
196
 
package/docs/schema.md CHANGED
@@ -704,9 +704,38 @@ 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
708
  }
708
709
  ```
709
710
 
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).
720
+
721
+ ```ts
722
+ import { defineProject } from '@vzn/vx/config'
723
+
724
+ export default defineProject({
725
+ tasks: {
726
+ build: {
727
+ // tsdown rewrites package.json with the same bytes on every build.
728
+ exec: { command: 'tsdown' },
729
+ cache: {
730
+ inputs: { files: ['src/**', 'package.json'] },
731
+ outputs: { files: ['dist/**'] },
732
+ rewrittenInputs: 'compare',
733
+ },
734
+ },
735
+ },
736
+ })
737
+ ```
738
+
710
739
  **Caching is opt-in.** Omit `cache` and the task always runs (no read,
711
740
  no write). Provide it and caching is active for that task. The
712
741
  project loader requires _both_ `inputs` and `outputs` when `cache` is
@@ -1534,6 +1563,7 @@ export default defineWorkspace({
1534
1563
  affectedBase: 'origin/main',
1535
1564
  cacheScope: 'trusted',
1536
1565
  rules: { exclusiveOutputs: true, upfrontKeys: true },
1566
+ schedule: 'most-work',
1537
1567
  plugins: [otel()],
1538
1568
  })
1539
1569
  ```
@@ -1554,8 +1584,10 @@ interface WorkspaceConfig {
1554
1584
  cacheScope?: string
1555
1585
  /** Graph rules checked before anything runs; each on unless set to false. */
1556
1586
  rules?: WorkspaceRules
1587
+ /** Which ready task starts first with no timings: 'most-work' (default), 'critical-path', 'direct-dependents', 'ready-order'. */
1588
+ schedule?: 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order'
1557
1589
  /** Run-level plugins (cache / executor / telemetry capabilities). */
1558
- plugins?: readonly Plugin[]
1590
+ plugins?: readonly (Plugin | undefined | null | false)[]
1559
1591
  }
1560
1592
 
1561
1593
  interface WorkspaceRules {
@@ -1631,6 +1663,17 @@ run` takes `'read-only'` off CI (`CI` unset, `0` or `false`) and
1631
1663
  boundary: a run holding a write credential can write any key, so only
1632
1664
  a cache server that scopes writes by token can refuse one
1633
1665
  (`docs/security.md` § Cache poisoning). Not folded into any cache key.
1666
+ - **`schedule`** — which ready task starts first when more are ready
1667
+ than workers, with no timings. `most-work` (the default): the one the
1668
+ most tasks wait on, at any depth. `critical-path`: the head of the
1669
+ longest chain of tasks below it. `direct-dependents`: the one the most
1670
+ tasks wait on directly (Nx's rule). `ready-order`: the one that became
1671
+ ready first (Turbo's rule). Each loses some graph shape the others win
1672
+ (`blog/scheduling-strategies`). A `schedule` plugin's weights
1673
+ (`@vzn/vx-schedule-history`) still sort first, and the strategy breaks
1674
+ their ties. The default costs what it did; another strategy is
1675
+ computed only when named. Any other value is refused. Never folded
1676
+ into a cache key.
1634
1677
  - **`rules`** — checks on the task graph, each on unless set to
1635
1678
  `false`. Turning one off allows a shape vx runs correctly but more
1636
1679
  slowly; never folded into a cache key. A value that is not a boolean,
@@ -1668,7 +1711,9 @@ false } in vx.workspace.ts to let it wait for its producer.` Such a key
1668
1711
  Declaration order is precedence: every `executor` is consulted in
1669
1712
  order per task (first to accept runs it; what all decline runs
1670
1713
  locally); every `cache` layer is chained (lookup walks, save reaches
1671
- all, the local store last). Each entry is a `VxPlugin` contributing
1714
+ all, the local store last). An `undefined`, `null` or `false` entry
1715
+ is skipped, as Vite skips one: a factory that declines (`github()` off
1716
+ Actions) or `cond && plugin()` needs no filter. Each entry is a `VxPlugin` contributing
1672
1717
  any subset of the fourteen hooks, in pipeline order: `config` (edit
1673
1718
  the workspace config before anything derives from it), `discover`
1674
1719
  (name directories to make projects beyond the members), `project`
@@ -2043,6 +2088,7 @@ a user meets most:
2043
2088
  | `cache.inputs.tasks must be an array of non-empty strings` | Non-string / empty entry, or a bare string. |
2044
2089
  | `cache.inputs.tasks: "<name>" names no task in <task>.dependsOn` | An exact entry no `dependsOn` entry of its form names. |
2045
2090
  | `cache.outputs is required when cache is set` | Forgot `outputs`. |
2091
+ | `cache.rewrittenInputs must be 'withhold' or 'compare'` | Any other value (`'bytes'`, `true`). |
2046
2092
  | `cache.outputs must be an object` | Present but not an object: a string (`outputs: 'dist'`). An array is the row above. |
2047
2093
  | `cache.outputs.files must be an array` | Wrong shape. |
2048
2094
  | `cache.inputs.files: every entry is a negation, which selects NOTHING` | Only `!` globs — nothing to subtract from. |
package/docs/security.md CHANGED
@@ -89,6 +89,12 @@ writes whatever its config says. Never give the write secret to a
89
89
  out a pull request's code: it runs with main's ref and secrets, and
90
90
  `github()` scopes it `pr-<n>` only on the client side.
91
91
 
92
+ A boundary that holds whoever can write the store is a signing cache
93
+ plugin: main's CI signs each upload, and a reader refuses, as it streams,
94
+ a download no trusted key signed, so the hit is a miss. Core needs no
95
+ change for it; the plugins guide (§ Your own cache › Signed artifacts) has a
96
+ complete one, run by `tests/signed-cache-guide.unsafe.test.ts`.
97
+
92
98
  ## What vx checks on bytes it did not write
93
99
 
94
100
  Every artifact that arrives from a remote, and every one read back from
package/launcher.cjs CHANGED
File without changes
package/launcher.sh ADDED
@@ -0,0 +1,50 @@
1
+ #!/bin/sh
2
+ # npm launcher for a vx command: the published package's `bin`. It execs the
3
+ # platform package's prebuilt binary straight from the shell, since Node's
4
+ # own start was ~40 ms of every `vx` call made through node_modules/.bin
5
+ # (45-53 ms against 12-14 ms for `vx --version`, 2026-10-12). Anything it
6
+ # cannot settle (no sibling platform package, musl, Yarn PnP, an unknown
7
+ # platform) goes to launcher.cjs beside it, which resolves through Node and
8
+ # owns the fallbacks and the error messages.
9
+
10
+ # The real file: npm's .bin entry is a symlink to it. Parameter expansion,
11
+ # not dirname/basename, and no `cd`: each fork is a millisecond, and the
12
+ # kernel resolves `<dir>/..` through a symlinked directory (pnpm's
13
+ # node_modules/@vzn/vx) to its real parent on its own.
14
+ self=$0
15
+ hops=0
16
+ while [ -L "$self" ] && [ "$hops" -lt 40 ]; do
17
+ hops=$((hops + 1))
18
+ target=$(readlink "$self")
19
+ case $target in
20
+ /*) self=$target ;;
21
+ *) self=${self%/*}/$target ;;
22
+ esac
23
+ done
24
+ case $self in
25
+ */*) here=${self%/*} ;;
26
+ *) here=. ;;
27
+ esac
28
+ base=${here##*/}
29
+ scope=${here%/*}
30
+ scope=${scope##*/}
31
+
32
+ key=
33
+ loader=
34
+ case $(uname -sm) in
35
+ 'Linux x86_64') key=linux-x64 loader=/lib64/ld-linux-x86-64.so.2 ;;
36
+ 'Linux aarch64' | 'Linux arm64') key=linux-arm64 loader=/lib/ld-linux-aarch64.so.1 ;;
37
+ 'Darwin x86_64') key=darwin-x64 ;;
38
+ 'Darwin arm64') key=darwin-arm64 ;;
39
+ esac
40
+
41
+ # The platform package sits beside this one under npm, pnpm and Bun
42
+ # (node_modules/@vzn/vx-<key>), or under this package's own node_modules
43
+ # when npm could not hoist it. The glibc binary needs its loader (musl
44
+ # has none); launcher.cjs names that cause.
45
+ if [ -n "$key" ] && { [ -z "$loader" ] || [ -e "$loader" ]; }; then
46
+ for bin in "$here/../$base-$key/$base" "$here/node_modules/$scope/$base-$key/$base"; do
47
+ if [ -x "$bin" ]; then exec "$bin" "$@"; fi
48
+ done
49
+ fi
50
+ exec node "$here/launcher.cjs" "$@"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.633",
4
- "description": "The fastest task runner and build cache for JavaScript monorepos.",
3
+ "version": "0.0.635",
4
+ "description": "Task orchestration you own: correct, sandboxed, extensible, with a content-addressed build cache.",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {
@@ -15,16 +15,16 @@
15
15
  },
16
16
  "types": "./src/index.ts",
17
17
  "bin": {
18
- "vx": "./launcher.cjs"
18
+ "vx": "./launcher.sh"
19
19
  },
20
20
  "engines": {
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.633",
25
- "@vzn/vx-linux-arm64": "0.0.633",
26
- "@vzn/vx-darwin-x64": "0.0.633",
27
- "@vzn/vx-darwin-arm64": "0.0.633"
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"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -42,6 +42,7 @@
42
42
  "docs/patterns.md",
43
43
  "docs/security.md",
44
44
  "docs/features.md",
45
+ "launcher.sh",
45
46
  "launcher.cjs",
46
47
  "README.md",
47
48
  "LICENSE"
@@ -64,6 +65,14 @@
64
65
  "nx",
65
66
  "bun",
66
67
  "workspaces",
67
- "ci"
68
+ "ci",
69
+ "task-orchestration",
70
+ "turborepo-alternative",
71
+ "nx-alternative",
72
+ "remote-cache",
73
+ "sandbox",
74
+ "incremental-build",
75
+ "mcp",
76
+ "pnpm"
68
77
  ]
69
78
  }
@@ -16,7 +16,11 @@
16
16
  "type": "string",
17
17
  "description": "Stable; branch on it. VX_E_USAGE, VX_E_UNKNOWN_COMMAND, VX_E_UNKNOWN_TASK, VX_E_UNKNOWN_PROJECT, VX_E_NO_HISTORY, VX_E_AMBIGUOUS, VX_E_CONFIG, VX_E_CYCLE, VX_E_NO_WORKSPACE, VX_E_CACHE_VERSION, VX_E_REFUSED, VX_E_FS, VX_E_FDS, VX_E_INTERNAL."
18
18
  },
19
- "message": { "type": "string", "description": "The refusal in prose; may change." }
19
+ "message": { "type": "string", "description": "The refusal in prose; may change." },
20
+ "docs": {
21
+ "type": "string",
22
+ "description": "For a core code (VX_E_…): its section of the CLI reference, which says what to do. A plugin's code has none."
23
+ }
20
24
  }
21
25
  }
22
26
  }
package/schemas/last.json CHANGED
@@ -170,7 +170,7 @@
170
170
  },
171
171
  "locations": {
172
172
  "type": "array",
173
- "description": "on a failed row: the existing files its output names, in order of first appearance",
173
+ "description": "on a failed row: the existing files its output names, in order of first appearance; a file it also names with a line drops its bare mention",
174
174
  "items": {
175
175
  "type": "object",
176
176
  "additionalProperties": false,
@@ -181,6 +181,10 @@
181
181
  "col": { "type": "integer" }
182
182
  }
183
183
  }
184
+ },
185
+ "fixedIn": {
186
+ "type": "string",
187
+ "description": "on a failed row: the first later run where this task passed (ran or hit); absent while it has not"
184
188
  }
185
189
  }
186
190
  }
package/schemas/plan.json CHANGED
@@ -6,6 +6,10 @@
6
6
  "additionalProperties": false,
7
7
  "required": ["tasks"],
8
8
  "properties": {
9
+ "affectedBase": {
10
+ "type": "string",
11
+ "description": "under --affected: the ref the diff was taken against (a bare --affected guesses it: origin/HEAD, a trunk branch, else HEAD~1)"
12
+ },
9
13
  "tasks": {
10
14
  "type": "array",
11
15
  "items": {
package/schemas/show.json CHANGED
@@ -115,7 +115,8 @@
115
115
  "files": { "$ref": "#/$defs/stringList" },
116
116
  "workspaceFiles": { "$ref": "#/$defs/stringList" }
117
117
  }
118
- }
118
+ },
119
+ "rewrittenInputs": { "enum": ["withhold", "compare"] }
119
120
  }
120
121
  }
121
122
  }
package/schemas/why.json CHANGED
@@ -77,7 +77,10 @@
77
77
  "type": ["boolean", "null"],
78
78
  "description": "on a hit: true when outputs were restored, false when up-to-date"
79
79
  },
80
- "cached": { "type": ["boolean", "null"] },
80
+ "cached": {
81
+ "type": ["boolean", "null"],
82
+ "description": "whether the task declares a cache; not whether this run hit it"
83
+ },
81
84
  "startedAt": { "type": "integer", "description": "epoch ms" }
82
85
  }
83
86
  },
@@ -21,6 +21,9 @@ vx run build --all --format json # the result as JSON on stdout; logs go to s
21
21
  The exit code is the verdict: `0` every task passed or hit the cache, `1` a
22
22
  task failed or was skipped, or the command was refused. `--format json`
23
23
  prints `schemas/summary.json` (`runId`, `ok`, `exitCode`, a row per task).
24
+ A refusal under `--format json` is one line on stdout,
25
+ `{"ok":false,"error":{"code","message","docs"}}`: branch on `code`;
26
+ `docs` (or `vx docs <code>` offline) says what to do.
24
27
  Without a TTY a missing task name never prompts: vx exits 1 and lists the
25
28
  tasks it found.
26
29
 
@@ -31,7 +34,10 @@ vx show --format json # projects, their tasks and depen
31
34
  vx run test --affected=origin/main --dry=json # what would run, cache hit or miss, and why
32
35
  ```
33
36
 
34
- Under `--affected`, each kept task in `--dry=json` carries `affected`:
37
+ Under `--affected`, `--dry=json` states `affectedBase` (the ref it diffed
38
+ against; name one, `--affected=origin/main`, rather than trust the guess)
39
+ and each task you asked for carries `affected` (a dependency it pulls in
40
+ does not):
35
41
  `{ kind, file?, project?, via? }`, where `kind` is `input` (a changed file
36
42
  is one of its inputs), `project`, `package`, `named` or `selected`, and
37
43
  `via` is the `dependsOn` chain that carried the change.
@@ -122,6 +122,13 @@ import { CACHE_VERSION, foldKey } from './key-fold.js'
122
122
  */
123
123
  const ORPHAN_GRACE_MS = 60 * 60 * 1000
124
124
 
125
+ /**
126
+ * Row-less lookups answered by a stat each before the artifact directory
127
+ * is listed once instead: a warm run's few misses keep their stat, a
128
+ * cold run's thousands share one listing.
129
+ */
130
+ const ADOPT_STATS = 64
131
+
125
132
  /**
126
133
  * A hit renews an entry's `accessed_at` only once it is older than this:
127
134
  * renewing every hit rewrote 2,180 rows at a warm 1,090-package run's
@@ -730,6 +737,10 @@ export class Cache implements CacheLayer {
730
737
  private readonly heldInlineAt = new Map<string, number>()
731
738
  /** Keys the last lookups found an inline artifact for and no row: `adopt`'s to read. */
732
739
  private readonly rowless = new Set<string>()
740
+ /** Row-less lookups that stat'ed their artifact path (`artifactFileMayExist`). */
741
+ private adoptStats = 0
742
+ /** The artifact directory's names, listed once past `ADOPT_STATS`; this process's writes kept in it. */
743
+ private artifactNames: Set<string> | undefined
733
744
  private readonly insertEntryInput: ReturnType<Database['prepare']>
734
745
  /** The per-file (mtime, size) → blob-OID memo behind `hashFile`. */
735
746
  private readonly files: FileHashStore
@@ -1472,20 +1483,21 @@ export class Cache implements CacheLayer {
1472
1483
  * reader reads it, and indexed in one transaction with its bytes.
1473
1484
  */
1474
1485
  private async adopt(hash: string, ctx: CacheGetContext): Promise<boolean> {
1475
- const meta: IngestMeta = {
1486
+ // Built only for an artifact found: its command is masked on read.
1487
+ const meta = (): IngestMeta => ({
1476
1488
  taskId: ctx.taskId,
1477
1489
  command: ctx.command,
1478
1490
  durationMs: 0,
1479
1491
  ...(ctx.outputs !== undefined ? { outputs: ctx.outputs } : {}),
1480
- }
1492
+ })
1481
1493
  // Only a key whose lookup saw a row-less inline artifact asks for it: a
1482
1494
  // statement per miss otherwise, on every cold save.
1483
1495
  const blob = this.rowless.delete(hash)
1484
1496
  ? (this.selectBlob.get(hash) as { bytes: Uint8Array } | null)
1485
1497
  : null
1486
- if (blob !== null) return this.adopted(this.writeArtifactAndIndex(hash, blob.bytes, meta))
1498
+ if (blob !== null) return this.adopted(this.writeArtifactAndIndex(hash, blob.bytes, meta()))
1487
1499
  const finalPath = this.tarPath(hash)
1488
- if (!existsSync(finalPath)) return false
1500
+ if (!this.artifactFileMayExist(hash)) return false
1489
1501
  const tmpPath = this.tempPath(hash)
1490
1502
  try {
1491
1503
  // A second name for the same bytes: the index step renames it over
@@ -1500,7 +1512,28 @@ export class Cache implements CacheLayer {
1500
1512
  } catch {
1501
1513
  return false
1502
1514
  }
1503
- return this.adopted(this.writeArtifactAndIndex(hash, { tmpPath }, meta))
1515
+ return this.adopted(this.writeArtifactAndIndex(hash, { tmpPath }, meta()))
1516
+ }
1517
+
1518
+ /**
1519
+ * Whether `hash` may have a file artifact: a stat for the first
1520
+ * `ADOPT_STATS` asks, then one listing of the directory. A cold
1521
+ * 1,090-package run asked 3,270 times before its first task ran. A
1522
+ * name listed and since removed fails the adopt's link, a miss; a file
1523
+ * another process writes after the listing is missed too, and its own
1524
+ * row serves it.
1525
+ */
1526
+ private artifactFileMayExist(hash: string): boolean {
1527
+ const name = `${hash}.tar.zst`
1528
+ if (this.artifactNames === undefined) {
1529
+ if (++this.adoptStats <= ADOPT_STATS) return existsSync(path.join(this.artifactDir, name))
1530
+ try {
1531
+ this.artifactNames = new Set(readdirSync(this.artifactDir))
1532
+ } catch {
1533
+ this.artifactNames = new Set()
1534
+ }
1535
+ }
1536
+ return this.artifactNames.has(name)
1504
1537
  }
1505
1538
 
1506
1539
  /** An adoption's verdict: an artifact that fails the checks is a miss. */
@@ -1562,6 +1595,27 @@ export class Cache implements CacheLayer {
1562
1595
  return entry
1563
1596
  }
1564
1597
 
1598
+ /**
1599
+ * Whether no lookup here can hit: no artifact, inline or file. Every
1600
+ * entry has one, and a row-less one is still a hit (`adopt`). The listing
1601
+ * is kept for `adopt`.
1602
+ */
1603
+ holdsNothing(): boolean {
1604
+ if (!this.read) return true
1605
+ if (this.guard(() => this.db.query('SELECT 1 FROM artifacts LIMIT 1').get()) != null) {
1606
+ return false
1607
+ }
1608
+ let names: string[]
1609
+ try {
1610
+ names = readdirSync(this.artifactDir)
1611
+ } catch (err) {
1612
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return false
1613
+ names = []
1614
+ }
1615
+ this.artifactNames ??= new Set(names)
1616
+ return !names.some((n) => n.endsWith('.tar.zst'))
1617
+ }
1618
+
1565
1619
  /**
1566
1620
  * `get` for many hashes at once: one `entries` query and one
1567
1621
  * `output_files` query per chunk instead of two per hash, with the
@@ -2391,6 +2445,7 @@ export class Cache implements CacheLayer {
2391
2445
  } else {
2392
2446
  aside = moveAside()
2393
2447
  renameSync(tmpPath!, finalPath)
2448
+ this.artifactNames?.add(path.basename(finalPath))
2394
2449
  renamed = true
2395
2450
  deleteBlob.run(hash)
2396
2451
  }
@@ -219,7 +219,7 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
219
219
  // Dedupe: when the project dir IS the workspace root (or a glob
220
220
  // overlaps), the same absolute path can arrive via both lists —
221
221
  // it must contribute to the key exactly once.
222
- if (wsFiles.length > 0) files = [...new Set([...projectFiles, ...wsFiles])].sort()
222
+ if (wsFiles.length > 0) files = mergeSorted(projectFiles, wsFiles)
223
223
  }
224
224
  // Two awaited empty resolutions per task were ~3 ms of a 1000-task warm
225
225
  // run (profiled 2026-09-09); a task declaring neither skips the fan-out.
@@ -285,7 +285,6 @@ function resolveWorkspaceFiles(args: {
285
285
  }
286
286
  if (positive.length === 0) return undefined
287
287
 
288
- const isExcluded = anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)])
289
288
  // A path the task's own outputs take back with `!` is no output, so it
290
289
  // stays an input (A-44).
291
290
  const ownOutput = outputMatcher(args.ownWorkspaceOutputs)
@@ -310,40 +309,44 @@ function resolveWorkspaceFiles(args: {
310
309
  // The memo is valid for the snapshot it was computed over: a task that
311
310
  // wrote workspace outputs mid-run replaces the partition, and the next
312
311
  // caller sees a different array and scans again.
313
- const memoKey =
314
- args.memo === undefined
315
- ? undefined
316
- : JSON.stringify([
317
- positive,
318
- negative,
319
- args.ownWorkspaceOutputs,
320
- args.ownOutputs,
321
- args.projectRel,
322
- ])
312
+ let isExcluded: ((rel: string) => boolean) | undefined
323
313
  let isPositive: ((rel: string) => boolean) | undefined
324
- const excluded = (rel: string): boolean => isExcluded(rel) || ownOutput(rel) || ownProject(rel)
314
+ const excludedByDeclaration = (rel: string): boolean =>
315
+ (isExcluded ??= anyTaskGlob([...ALWAYS_IGNORE, ...asTrees(negative)]))(rel)
316
+ const own = (rel: string): boolean => ownOutput(rel) || ownProject(rel)
325
317
  const listing: InputListing = {
326
318
  root: args.workspaceRoot,
327
319
  listed: gitFiles,
328
- isInput: (rel) => (isPositive ??= anyTaskGlob(asTrees(positive)))(rel) && !excluded(rel),
320
+ isInput: (rel) =>
321
+ (isPositive ??= anyTaskGlob(asTrees(positive)))(rel) &&
322
+ !excludedByDeclaration(rel) &&
323
+ !own(rel),
329
324
  nested: () => false,
330
325
  ...reachOf(positive),
331
326
  }
332
- if (memoKey !== undefined) {
333
- const hit = args.memo!.get(memoKey)
334
- if (hit !== undefined && hit.snapshot === gitFiles) return { files: hit.result, listing }
327
+ // Two levels: the declaration's files over the snapshot, shared by every
328
+ // task that declares it, then the task's own outputs taken out. A
329
+ // Turbo-mapped repo gives two thousand tasks one 400-entry list, and a
330
+ // memo keyed by the project too scanned, resolved and sorted the 4,000
331
+ // files it matched once per task (astro, 2026-10-09).
332
+ const declared = JSON.stringify([positive, negative])
333
+ let base: Promise<string[]> | undefined
334
+ const hit = args.memo?.get(declared)
335
+ if (hit !== undefined && hit.snapshot === gitFiles) base = hit.result
336
+ if (base === undefined) {
337
+ isPositive ??= anyTaskGlob(asTrees(positive))
338
+ base = resolveWorkspaceFilesOver(args, gitFiles, positive, isPositive, excludedByDeclaration)
339
+ args.memo?.set(declared, { snapshot: gitFiles, result: base })
335
340
  }
336
- isPositive ??= anyTaskGlob(asTrees(positive))
337
- const result = resolveWorkspaceFilesOver(
338
- args,
339
- gitFiles,
340
- positive,
341
- isPositive,
342
- excluded,
343
- undecodable,
344
- )
345
- if (memoKey !== undefined) args.memo!.set(memoKey, { snapshot: gitFiles, result })
346
- return { files: result, listing }
341
+ const ownsAny =
342
+ splitNegations(args.ownWorkspaceOutputs).positive.length > 0 ||
343
+ splitNegations(args.ownOutputs).positive.length > 0
344
+ const files = base.then((all) => {
345
+ const kept = ownsAny ? all.filter((abs) => !own(relPosix(args.workspaceRoot, abs))) : all
346
+ refuseUndecodable(kept, undecodable, args.workspaceRoot, 'workspaceFiles')
347
+ return kept
348
+ })
349
+ return { files, listing }
347
350
  }
348
351
 
349
352
  async function resolveWorkspaceFilesOver(
@@ -352,7 +355,6 @@ async function resolveWorkspaceFilesOver(
352
355
  positive: readonly string[],
353
356
  isPositive: (rel: string) => boolean,
354
357
  excluded: (rel: string) => boolean,
355
- undecodable: ReadonlySet<string> | undefined,
356
358
  ): Promise<string[]> {
357
359
  // Second call site of the literal-input guard. `resolveWorkspaceFiles`
358
360
  // carries its own copy of the filter-over-git-set design, so the same
@@ -371,13 +373,47 @@ async function resolveWorkspaceFilesOver(
371
373
  if (unmatchedLiterals.size > 0) {
372
374
  await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.workspaceRoot, 'workspaceFiles')
373
375
  }
374
- refuseUndecodable(candidates, undecodable, args.workspaceRoot, 'workspaceFiles')
375
376
  // Same OID-trust shortcut as project files: a clean-per-status
376
377
  // tracked file necessarily exists on disk.
377
378
  const oids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
378
379
  return candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs)).sort()
379
380
  }
380
381
 
382
+ /**
383
+ * The sorted union of two path lists, each once. Both sides arrive sorted
384
+ * and distinct, so one merge does what a Set and a sort of the
385
+ * concatenation did: 3.7 s of an astro plan, where 2,000 tasks each joined
386
+ * a few project files to the same 4,000 workspace files (2026-10-09). A
387
+ * side out of order takes the general path.
388
+ */
389
+ function mergeSorted(a: readonly string[], b: readonly string[]): string[] {
390
+ if (a.length === 0 && isStrictlySorted(b)) return b as string[]
391
+ if (!isStrictlySorted(a) || !isStrictlySorted(b)) return [...new Set([...a, ...b])].sort()
392
+ const out: string[] = []
393
+ let i = 0
394
+ let j = 0
395
+ while (i < a.length && j < b.length) {
396
+ const x = a[i]!
397
+ const y = b[j]!
398
+ if (x <= y) {
399
+ out.push(x)
400
+ i++
401
+ if (x === y) j++
402
+ } else {
403
+ out.push(y)
404
+ j++
405
+ }
406
+ }
407
+ while (i < a.length) out.push(a[i++]!)
408
+ while (j < b.length) out.push(b[j++]!)
409
+ return out
410
+ }
411
+
412
+ function isStrictlySorted(list: readonly string[]): boolean {
413
+ for (let i = 1; i < list.length; i++) if (!(list[i - 1]! < list[i]!)) return false
414
+ return true
415
+ }
416
+
381
417
  /** A project-relative matcher asked of root-relative paths: false outside the project. */
382
418
  function underProject(
383
419
  projectRel: string,
@@ -76,7 +76,9 @@ export interface RemoteCacheLayer {
76
76
  * straight into the local cache — an HTTP wire returns its `fetch`
77
77
  * `Response`, a chunked wire `new Response(stream)`, bytes in hand
78
78
  * `new Blob([bytes])`. `durationMs` is the producing task's duration
79
- * when the wire carries it.
79
+ * when the wire carries it. A body that errors before its end stores
80
+ * nothing and the read is a miss, so a layer can check bytes as they pass
81
+ * (a digest, a signature) and refuse at the end.
80
82
  */
81
83
  get(hash: string): Promise<{ body: Blob | Response; durationMs: number | undefined } | null>
82
84
  /**
package/src/cli/docs.ts CHANGED
@@ -48,7 +48,7 @@ export interface DocsHit {
48
48
  }
49
49
 
50
50
  function words(s: string): string[] {
51
- return s.toLowerCase().match(/[a-z0-9][a-z0-9._-]*/g) ?? []
51
+ return s.toLowerCase().match(/[a-z0-9][a-z0-9_.-]*/g) ?? []
52
52
  }
53
53
 
54
54
  /** GitHub's heading anchor, which Starlight shares: lowercase, punctuation dropped, spaces to `-`. */
@@ -99,7 +99,11 @@ function count(hay: string, term: string): number {
99
99
  return n
100
100
  }
101
101
 
102
- /** The sections holding every term, best first: a term in the heading outweighs any count in the text. */
102
+ /**
103
+ * The sections holding every term, best first: a term in the heading
104
+ * outweighs any count in the text. A section titled by the one word asked
105
+ * (an error code) is the answer alone.
106
+ */
103
107
  export function searchDocs(
104
108
  pages: Readonly<Record<string, string>>,
105
109
  terms: readonly string[],
@@ -123,7 +127,9 @@ export function searchDocs(
123
127
  }
124
128
  }
125
129
  scored.sort((a, b) => b.score - a.score || a.order - b.order)
126
- return scored.slice(0, limit).map(({ s }) => ({
130
+ const exact =
131
+ terms.length === 1 ? scored.filter(({ s }) => words(s.heading).join(' ') === terms[0]) : []
132
+ return (exact.length > 0 ? exact : scored).slice(0, limit).map(({ s }) => ({
127
133
  page: s.page,
128
134
  heading: s.heading,
129
135
  url: s.heading === '' ? pageUrl(s.page) : `${pageUrl(s.page)}#${anchor(s.heading)}`,
package/src/cli/help.ts CHANGED
@@ -261,7 +261,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
261
261
  'Docs:',
262
262
  ' vx docs <query> Search the reference offline (cli, schema, caching, execution,',
263
263
  ' patterns, security, features): the sections holding every',
264
- ' word of the query print whole, best first, with their URL.',
264
+ ' word of the query print whole, best first, with their URL;',
265
+ ' an error code (vx docs VX_E_CYCLE) prints its fix alone.',
265
266
  ' --limit N How many sections (default 3).',
266
267
  ' --format json {query, hits: [{page, heading, url, text}]}.',
267
268
  '',