@vzn/vx 0.0.631 → 0.0.633

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 (86) hide show
  1. package/README.md +21 -18
  2. package/docs/caching.md +2247 -0
  3. package/docs/cli.md +3214 -0
  4. package/docs/execution.md +708 -0
  5. package/docs/features.md +172 -0
  6. package/docs/patterns.md +219 -0
  7. package/docs/schema.md +2137 -0
  8. package/docs/security.md +209 -0
  9. package/package.json +14 -5
  10. package/schemas/cache.json +24 -0
  11. package/schemas/docs.json +28 -0
  12. package/schemas/error.json +23 -0
  13. package/schemas/info.json +147 -0
  14. package/schemas/init.json +57 -0
  15. package/schemas/last.json +188 -0
  16. package/schemas/lock.json +21 -0
  17. package/schemas/plan.json +88 -0
  18. package/schemas/show.json +124 -0
  19. package/schemas/summary.json +133 -0
  20. package/schemas/why.json +168 -0
  21. package/skills/vx/SKILL.md +89 -0
  22. package/src/bin.ts +9 -0
  23. package/src/cache/archive.ts +153 -51
  24. package/src/cache/cache.ts +542 -137
  25. package/src/cache/chained-cache.ts +9 -0
  26. package/src/cache/inputs.ts +5 -0
  27. package/src/cache/layer.ts +10 -5
  28. package/src/cache/layered-cache.ts +4 -0
  29. package/src/cache/schema.ts +44 -17
  30. package/src/cache/tar-stream.ts +204 -66
  31. package/src/cache/zstd.ts +1 -1
  32. package/src/cli/cache.ts +14 -14
  33. package/src/cli/docs-corpus.ts +21 -0
  34. package/src/cli/docs-text.d.ts +5 -0
  35. package/src/cli/docs.ts +167 -0
  36. package/src/cli/foreign-flags.ts +1 -1
  37. package/src/cli/help.ts +42 -7
  38. package/src/cli/index.ts +25 -11
  39. package/src/cli/info.ts +38 -9
  40. package/src/cli/init.ts +131 -24
  41. package/src/cli/last.ts +54 -3
  42. package/src/cli/lock.ts +43 -15
  43. package/src/cli/plan-format.ts +23 -1
  44. package/src/cli/plugin-commands.ts +3 -0
  45. package/src/cli/plugin-templates.ts +1 -1
  46. package/src/cli/run.ts +40 -21
  47. package/src/cli/show.ts +10 -7
  48. package/src/cli/upgrade.ts +19 -7
  49. package/src/cli/watch.ts +7 -2
  50. package/src/cli/why.ts +14 -40
  51. package/src/exec/runner.ts +27 -10
  52. package/src/graph/scheduler.ts +36 -0
  53. package/src/graph/task-graph.ts +4 -4
  54. package/src/index.ts +22 -5
  55. package/src/orchestrator/admission.ts +29 -1
  56. package/src/orchestrator/affected-tasks.ts +87 -18
  57. package/src/orchestrator/doctor.ts +14 -1
  58. package/src/orchestrator/execute-task.ts +149 -64
  59. package/src/orchestrator/failure-mode.ts +5 -3
  60. package/src/orchestrator/framed-output.ts +17 -3
  61. package/src/orchestrator/index.ts +11 -0
  62. package/src/orchestrator/logger.ts +57 -6
  63. package/src/orchestrator/metrics.ts +76 -4
  64. package/src/orchestrator/miss-reason.ts +3 -5
  65. package/src/orchestrator/miss-save.ts +6 -5
  66. package/src/orchestrator/options.ts +12 -0
  67. package/src/orchestrator/output-log.ts +14 -1
  68. package/src/orchestrator/path-links.ts +79 -0
  69. package/src/orchestrator/plan.ts +20 -3
  70. package/src/orchestrator/plugin.ts +7 -0
  71. package/src/orchestrator/prepare.ts +14 -4
  72. package/src/orchestrator/projects.ts +2 -2
  73. package/src/orchestrator/run-artifacts.ts +18 -2
  74. package/src/orchestrator/run-failures.ts +192 -0
  75. package/src/orchestrator/run-records.ts +10 -8
  76. package/src/orchestrator/run.ts +87 -22
  77. package/src/orchestrator/task-hash.ts +4 -4
  78. package/src/orchestrator/terminal-signals.ts +16 -0
  79. package/src/orchestrator/upstream.ts +65 -10
  80. package/src/util/errors.ts +55 -1
  81. package/src/util/index.ts +3 -0
  82. package/src/util/secret-mask.ts +13 -1
  83. package/src/util/verbs.ts +1 -0
  84. package/src/workspace/config-schema.ts +10 -8
  85. package/src/workspace/migration.ts +30 -3
  86. package/src/workspace/workspace.ts +1 -0
@@ -0,0 +1,168 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "vx why --format json",
4
+ "description": "A task's run against its previous run. On history older than run ids, `why` and `diff` are null and `explanation` carries the latest cache entry.",
5
+ "oneOf": [
6
+ {
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "required": ["taskId", "runId", "why", "diff", "roots"],
10
+ "properties": {
11
+ "taskId": { "type": "string" },
12
+ "runId": { "type": "string" },
13
+ "why": { "$ref": "#/$defs/why" },
14
+ "diff": { "$ref": "#/$defs/diff" },
15
+ "roots": {
16
+ "type": "array",
17
+ "description": "The tasks under moved upstreams whose own key components moved in this run, each with the chain from the asked task down to it.",
18
+ "items": {
19
+ "type": "object",
20
+ "additionalProperties": false,
21
+ "required": ["chain", "entries"],
22
+ "properties": {
23
+ "chain": { "type": "array", "items": { "type": "string" } },
24
+ "entries": {
25
+ "type": "array",
26
+ "items": {
27
+ "type": "object",
28
+ "additionalProperties": false,
29
+ "required": ["kind", "name", "change", "before", "after"],
30
+ "properties": {
31
+ "kind": { "type": "string" },
32
+ "name": { "type": "string" },
33
+ "change": { "enum": ["added", "removed", "changed"] },
34
+ "before": { "type": ["string", "null"] },
35
+ "after": { "type": ["string", "null"] }
36
+ }
37
+ }
38
+ }
39
+ }
40
+ }
41
+ }
42
+ }
43
+ },
44
+ {
45
+ "type": "object",
46
+ "additionalProperties": false,
47
+ "required": ["taskId", "why", "diff", "explanation"],
48
+ "properties": {
49
+ "taskId": { "type": "string" },
50
+ "why": { "type": "null" },
51
+ "diff": { "type": "null" },
52
+ "explanation": { "$ref": "#/$defs/explanation" }
53
+ }
54
+ }
55
+ ],
56
+ "$defs": {
57
+ "why": {
58
+ "type": "object",
59
+ "additionalProperties": false,
60
+ "required": ["runId", "taskId", "found", "thisRun", "previousRun", "hashChanged", "note"],
61
+ "properties": {
62
+ "runId": { "type": "string" },
63
+ "taskId": { "type": "string" },
64
+ "found": { "const": true },
65
+ "thisRun": {
66
+ "type": "object",
67
+ "additionalProperties": false,
68
+ "required": ["hash", "status", "cacheHit", "restored", "cached", "startedAt"],
69
+ "properties": {
70
+ "hash": {
71
+ "type": "string",
72
+ "description": "empty for a task that never ran (skipped)"
73
+ },
74
+ "status": { "type": "string" },
75
+ "cacheHit": { "type": ["boolean", "null"] },
76
+ "restored": {
77
+ "type": ["boolean", "null"],
78
+ "description": "on a hit: true when outputs were restored, false when up-to-date"
79
+ },
80
+ "cached": { "type": ["boolean", "null"] },
81
+ "startedAt": { "type": "integer", "description": "epoch ms" }
82
+ }
83
+ },
84
+ "previousRun": {
85
+ "type": ["object", "null"],
86
+ "description": "null on the task's first recorded run",
87
+ "additionalProperties": false,
88
+ "required": ["hash", "status", "cacheHit", "restored", "startedAt"],
89
+ "properties": {
90
+ "hash": { "type": "string" },
91
+ "status": { "type": "string" },
92
+ "cacheHit": { "type": ["boolean", "null"] },
93
+ "restored": {
94
+ "type": ["boolean", "null"],
95
+ "description": "on a hit: true when outputs were restored, false when up-to-date"
96
+ },
97
+ "startedAt": { "type": "integer" }
98
+ }
99
+ },
100
+ "hashChanged": {
101
+ "type": ["boolean", "null"],
102
+ "description": "null when either run has no key"
103
+ },
104
+ "note": { "type": "string" }
105
+ }
106
+ },
107
+ "diff": {
108
+ "type": "object",
109
+ "additionalProperties": false,
110
+ "required": [
111
+ "runId",
112
+ "taskId",
113
+ "found",
114
+ "previousRunId",
115
+ "entries",
116
+ "unchangedCount",
117
+ "note"
118
+ ],
119
+ "properties": {
120
+ "runId": { "type": "string" },
121
+ "taskId": { "type": "string" },
122
+ "found": { "type": "boolean" },
123
+ "previousRunId": { "type": ["string", "null"] },
124
+ "entries": {
125
+ "type": "array",
126
+ "items": {
127
+ "type": "object",
128
+ "additionalProperties": false,
129
+ "required": ["kind", "name", "change", "before", "after"],
130
+ "properties": {
131
+ "kind": { "type": "string", "description": "files, env, runtime, upstream, …" },
132
+ "name": { "type": "string" },
133
+ "change": { "enum": ["added", "removed", "changed"] },
134
+ "before": { "type": ["string", "null"] },
135
+ "after": { "type": ["string", "null"] }
136
+ }
137
+ }
138
+ },
139
+ "unchangedCount": { "type": "integer" },
140
+ "note": { "type": "string" }
141
+ }
142
+ },
143
+ "explanation": {
144
+ "type": "object",
145
+ "additionalProperties": false,
146
+ "required": ["taskId", "project", "task", "latestEntry", "note"],
147
+ "properties": {
148
+ "taskId": { "type": "string" },
149
+ "project": { "type": "string" },
150
+ "task": { "type": "string" },
151
+ "latestEntry": {
152
+ "type": ["object", "null"],
153
+ "additionalProperties": false,
154
+ "required": ["hash", "command", "exitCode", "durationMs", "sizeBytes", "createdAt"],
155
+ "properties": {
156
+ "hash": { "type": "string" },
157
+ "command": { "type": "string" },
158
+ "exitCode": { "type": "integer" },
159
+ "durationMs": { "type": "number" },
160
+ "sizeBytes": { "type": "integer" },
161
+ "createdAt": { "type": "integer" }
162
+ }
163
+ },
164
+ "note": { "type": "string" }
165
+ }
166
+ }
167
+ }
168
+ }
@@ -0,0 +1,89 @@
1
+ ---
2
+ name: vx
3
+ description: Run, inspect and debug tasks in a vx monorepo (vx.config.ts, vx.workspace.ts). Use to build or test projects, find what a change affects, read why a task failed or re-ran, and query the cache as JSON.
4
+ ---
5
+
6
+ # vx
7
+
8
+ vx is the workspace's task runner and build cache. Run tasks through it, not
9
+ through package scripts, so results are cached and ordered by their
10
+ dependencies.
11
+
12
+ ## Run
13
+
14
+ ```sh
15
+ vx run build --all # a task in every project that declares it
16
+ vx run test --affected=origin/main # only what the diff since origin/main reaches
17
+ vx run app#build # one project's task (and what it depends on)
18
+ vx run build --all --format json # the result as JSON on stdout; logs go to stderr
19
+ ```
20
+
21
+ The exit code is the verdict: `0` every task passed or hit the cache, `1` a
22
+ task failed or was skipped, or the command was refused. `--format json`
23
+ prints `schemas/summary.json` (`runId`, `ok`, `exitCode`, a row per task).
24
+ Without a TTY a missing task name never prompts: vx exits 1 and lists the
25
+ tasks it found.
26
+
27
+ ## Look before running
28
+
29
+ ```sh
30
+ vx show --format json # projects, their tasks and dependencies
31
+ vx run test --affected=origin/main --dry=json # what would run, cache hit or miss, and why
32
+ ```
33
+
34
+ Under `--affected`, each kept task in `--dry=json` carries `affected`:
35
+ `{ kind, file?, project?, via? }`, where `kind` is `input` (a changed file
36
+ is one of its inputs), `project`, `package`, `named` or `selected`, and
37
+ `via` is the `dependsOn` chain that carried the change.
38
+
39
+ ## When a task fails
40
+
41
+ ```sh
42
+ vx last --failed --format json # the last failed run; a failed row has output and locations
43
+ vx last --log app#build # one task's output: a failure's, or its cached log
44
+ ```
45
+
46
+ A failed row's `output` is the task's output as plain text (secrets masked)
47
+ and `locations` the files it names as `{ file, line?, col? }`. Read those
48
+ files, fix, and re-run the same command: cached tasks that passed replay from the cache.
49
+
50
+ ## When a task re-ran unexpectedly
51
+
52
+ ```sh
53
+ vx why app#build --format json # which input changed, down to the root cause
54
+ ```
55
+
56
+ ## Workspace health
57
+
58
+ ```sh
59
+ vx info --format json # vx and Bun versions, projects, config errors, plugins, cache
60
+ ```
61
+
62
+ ## MCP
63
+
64
+ When `@vzn/vx-mcp` is a plugin in `vx.workspace.ts`, `vx mcp` serves the
65
+ same facts and runs tasks. Adoption does not add it: install
66
+ `@vzn/vx-mcp` and put `mcp()` (from `'@vzn/vx-mcp'`) in `plugins`. Its tools: `listTasks`, `getCacheStats`,
67
+ `getRunHistory`, `explainCacheKey`, `whyDidThisRerun`, `getFailures`, `getTaskLog`, `getConfig`, `checkLock`, `pruneCache`, `planInit`, `searchDocs`,
68
+ `getWorkspaceInfo`, `runTasks`, `planTasks`. `runTasks` answers what
69
+ `vx run <tasks> --format json` prints, `planTasks` what
70
+ `vx run <tasks> --dry=json` prints, `pruneCache` evicts only with
71
+ `dryRun: false`; the others only read.
72
+
73
+ ## Rules
74
+
75
+ - Never delete or edit the cache by hand; vx repairs it itself.
76
+ - A sandboxed task that fails on a denied path: grant that one path in
77
+ `exec.sandbox.allow`. Never remove `sandbox` or widen it further.
78
+ - A cached task declares its inputs (`cache.inputs.files`); if a change
79
+ does not re-run a task, the file is not one of its inputs. Fix the
80
+ config, do not force.
81
+ - Every JSON shape is a JSON Schema in `node_modules/@vzn/vx/schemas/`.
82
+ - Offline reference: `vx docs <words> --format json` searches the CLI, config
83
+ and cache pages that ship with vx; the sections holding every word print
84
+ whole.
85
+ - Docs as markdown: https://vznjs.github.io/vx/llms.txt (its index).
86
+ When that host is unreachable, the same pages are
87
+ `https://raw.githubusercontent.com/vznjs/vx/main/packages/vx-docs/src/content/docs/<page>.md`:
88
+ `quickstart`, `guides/migrate`, `guides/configure`, `guides/agents`,
89
+ `guides/troubleshooting`, `guides/ci`, `guides/plugins`.
package/src/bin.ts CHANGED
@@ -32,12 +32,15 @@ async function main(): Promise<void> {
32
32
  } catch (err) {
33
33
  const {
34
34
  configErrorFrame,
35
+ errorCode,
36
+ errorDocument,
35
37
  fsRefusalHint,
36
38
  isFsRefusal,
37
39
  isOutOfFds,
38
40
  isUserError,
39
41
  maskedLine,
40
42
  OUT_OF_FDS_HINT,
43
+ wantsJson,
41
44
  } = await import('./util/index.js')
42
45
  // A plugin's failure quotes what it was told (a remote's reply, a
43
46
  // header), and a config's own throw is its author's text: a secret in
@@ -64,6 +67,12 @@ async function main(): Promise<void> {
64
67
  const message = err instanceof Error ? (err.stack ?? err.message) : String(err)
65
68
  say(`vx: ${message}\n`)
66
69
  }
70
+ // A verb asked for JSON answers JSON on a refusal too: an agent reading
71
+ // stdout got nothing to parse, and only prose on stderr to branch on.
72
+ if (wantsJson(argv)) {
73
+ const message = err instanceof Error ? err.message : String(err)
74
+ process.stdout.write(errorDocument(errorCode(err), maskedLine(message)))
75
+ }
67
76
  // Same reason as the success path above: a large stderr is truncated by
68
77
  // `process.exit` too, and an error message cut in half is the one a
69
78
  // reader most needs whole.
@@ -50,7 +50,15 @@ import { lstat, mkdir, readlink, realpath, rmdir, stat, unlink, writeFile } from
50
50
  import path from 'node:path'
51
51
  import { UserError } from '../util/index.js'
52
52
  import { WORKSPACE_OUTPUT_PREFIX } from './layer.js'
53
- import { TarFormatError, type TarInput, tarEntries, tarPack, tarSize } from './tar-stream.js'
53
+ import {
54
+ TarFormatError,
55
+ type TarInput,
56
+ tarEntries,
57
+ tarEntriesSync,
58
+ tarPack,
59
+ tarPackInto,
60
+ tarSize,
61
+ } from './tar-stream.js'
54
62
  import { ON_THREAD_MAX } from './zstd.js'
55
63
 
56
64
  /** Archive entry name carrying the per-output mode/mtime sidecar. */
@@ -59,6 +67,10 @@ const META_ENTRY = '.vx-meta.json'
59
67
  /** Archive entry name of the always-present stdout record. */
60
68
  const STDOUT_ENTRY = 'stdout'
61
69
 
70
+ // One decoder, its defaults (a leading BOM dropped), for both scanners.
71
+ // `ignoreBOM`: a leading U+FEFF in a task's stdout is the task's.
72
+ const utf8 = new TextDecoder('utf-8', { ignoreBOM: true })
73
+
62
74
  /**
63
75
  * The last entry: a CRC-32 over every entry before it, each its name, a
64
76
  * NUL, then its body, as 8 lowercase hex digits (v36, L-19). Nothing else
@@ -107,19 +119,30 @@ class SumCheck {
107
119
  name: string,
108
120
  body: AsyncIterable<Uint8Array>,
109
121
  ): Promise<AsyncIterable<Uint8Array> | null> {
122
+ if (!this.enter(name)) return this.sum.through(body)
123
+ this.verify(await textOf(body))
124
+ return null
125
+ }
126
+ /**
127
+ * The next regular entry: true when it is the sum entry, whose text the
128
+ * caller hands to `verify`; otherwise the caller hands its bytes to `add`.
129
+ */
130
+ enter(name: string): boolean {
110
131
  if (this.seen) throw new TarFormatError(`entry ${name} follows the artifact's checksum`)
111
- if (name === SUM_ENTRY) {
112
- const got = await textOf(body)
113
- if (got !== this.sum.hex()) {
114
- throw new TarFormatError(
115
- `artifact content does not match its checksum (${got}, read ${this.sum.hex()})`,
116
- )
117
- }
118
- this.seen = true
119
- return null
120
- }
132
+ if (name === SUM_ENTRY) return true
121
133
  this.sum.entry(name)
122
- return this.sum.through(body)
134
+ return false
135
+ }
136
+ add(bytes: Uint8Array): void {
137
+ this.sum.add(bytes)
138
+ }
139
+ verify(got: string): void {
140
+ if (got !== this.sum.hex()) {
141
+ throw new TarFormatError(
142
+ `artifact content does not match its checksum (${got}, read ${this.sum.hex()})`,
143
+ )
144
+ }
145
+ this.seen = true
123
146
  }
124
147
  done(): void {
125
148
  if (!this.seen) throw new TarFormatError('artifact carries no checksum')
@@ -373,16 +396,12 @@ async function* summed(inputs: readonly TarInput[]): AsyncGenerator<TarInput> {
373
396
  * otherwise (measured 2026-09-03: 238 → 293 ms per 1 000 saves).
374
397
  */
375
398
  export async function packArtifactBytes(plan: ArtifactPlan): Promise<Uint8Array> {
376
- // A small artifact's files are read on this thread (`ON_THREAD_MAX`).
377
- const onThread = plan.size <= ON_THREAD_MAX
399
+ // A small artifact is read and packed on this thread (`ON_THREAD_MAX`):
400
+ // no promise or generator hop per file or block.
401
+ if (plan.size <= ON_THREAD_MAX) return packSmall(plan)
378
402
  const inputs = await Promise.all(
379
403
  plan.inputs.map(async (i) =>
380
- i.body instanceof Blob
381
- ? {
382
- ...i,
383
- body: onThread ? readFileSync((i.body as Bun.BunFile).name!) : await i.body.bytes(),
384
- }
385
- : i,
404
+ i.body instanceof Blob ? { ...i, body: await i.body.bytes() } : i,
386
405
  ),
387
406
  )
388
407
  const out = new Uint8Array(plan.size)
@@ -395,6 +414,28 @@ export async function packArtifactBytes(plan: ArtifactPlan): Promise<Uint8Array>
395
414
  return out
396
415
  }
397
416
 
417
+ /** `summed` + `tarPack` for a plan whose bodies fit in memory, synchronously. */
418
+ function packSmall(plan: ArtifactPlan): Uint8Array {
419
+ const sum = new EntrySum()
420
+ const inputs: (TarInput & { body: Uint8Array | string })[] = []
421
+ for (const i of plan.inputs) {
422
+ const body =
423
+ i.body instanceof Blob
424
+ ? readFileSync((i.body as Bun.BunFile).name!)
425
+ : typeof i.body === 'string'
426
+ ? new TextEncoder().encode(i.body)
427
+ : (i.body as Uint8Array)
428
+ sum.entry(i.name)
429
+ sum.add(body)
430
+ inputs.push({ ...i, body })
431
+ }
432
+ inputs.push({ name: SUM_ENTRY, size: SUM_SIZE, body: sum.hex() })
433
+ const out = new Uint8Array(plan.size)
434
+ const n = tarPackInto(inputs, out)
435
+ if (n !== plan.size) throw new TarFormatError(`packed ${n} bytes, planned ${plan.size}`)
436
+ return out
437
+ }
438
+
398
439
  /** Plan and pack in one go, in memory. */
399
440
  export async function packArtifact(args: PackArgs): Promise<Uint8Array> {
400
441
  return await packArtifactBytes(await planArtifact(args))
@@ -413,6 +454,51 @@ function streamOf(gen: AsyncGenerator<Uint8Array>): ReadableStream<Uint8Array> {
413
454
  })
414
455
  }
415
456
 
457
+ /** What a scan of an artifact reads out of it. */
458
+ interface ArtifactScan {
459
+ entries: ArchiveEntry[]
460
+ stdout: string | null
461
+ exec: ExecUsage | undefined
462
+ key: string | undefined
463
+ }
464
+
465
+ /** A scan's result, built entry by entry in tar order: the one fold both scanners share. */
466
+ class ScanFold {
467
+ private readonly seen: Array<{ name: string; size: number; mtimeMs: number }> = []
468
+ private files: MetaFile['files'] = {}
469
+ private exec: ExecUsage | undefined
470
+ private key: string | undefined
471
+ stdout: string | null = null
472
+
473
+ sidecar(text: string): void {
474
+ const parsed = JSON.parse(text) as MetaFile
475
+ this.files = parsed.files ?? {}
476
+ // The ingest side is the untrusted boundary: a foreign sidecar's
477
+ // usage is taken only when it is a plain non-negative number.
478
+ this.exec = usageOf(parsed.exec)
479
+ if (typeof parsed.key === 'string') this.key = parsed.key
480
+ }
481
+
482
+ entry(name: string, size: number, mtimeMs: number): void {
483
+ this.seen.push({ name, size, mtimeMs })
484
+ }
485
+
486
+ result(): ArtifactScan {
487
+ return {
488
+ entries: this.seen.map((s) => {
489
+ const m = this.files[s.name]
490
+ // A foreign artifact (or one whose sidecar lost an entry) restores
491
+ // readable-but-not-executable rather than failing the hit: the
492
+ // sidecar is metadata, never the authority on whether bytes exist.
493
+ return { name: s.name, size: s.size, mode: m?.[0] ?? 0o644, mtimeMs: m?.[1] ?? s.mtimeMs }
494
+ }),
495
+ stdout: this.stdout,
496
+ exec: this.exec,
497
+ key: this.key,
498
+ }
499
+ }
500
+ }
501
+
416
502
  /**
417
503
  * List an artifact's regular entries with their restore metadata, and
418
504
  * its stdout text (`null` when the entry is absent — not a vx artifact),
@@ -420,49 +506,55 @@ function streamOf(gen: AsyncGenerator<Uint8Array>): ReadableStream<Uint8Array> {
420
506
  * name is unsafe — a partial reading of a poisoned artifact is the
421
507
  * outcome the traversal defense exists to prevent.
422
508
  */
423
- export async function scanArtifact(tar: ReadableStream<Uint8Array>): Promise<{
424
- entries: ArchiveEntry[]
425
- stdout: string | null
426
- exec: ExecUsage | undefined
427
- key: string | undefined
428
- }> {
429
- const seen: Array<{ name: string; size: number; mtimeMs: number }> = []
430
- let meta: MetaFile['files'] = {}
431
- let exec: ExecUsage | undefined
432
- let key: string | undefined
433
- let stdout: string | null = null
509
+ export async function scanArtifact(tar: ReadableStream<Uint8Array>): Promise<ArtifactScan> {
510
+ const fold = new ScanFold()
434
511
  const check = new SumCheck()
435
512
  for await (const e of tarEntries(tar)) {
436
513
  if (e.type !== '0') continue
437
514
  const body = await check.body(e.name, e.body)
438
515
  if (body === null) continue
439
516
  if (e.name === META_ENTRY) {
440
- const parsed = JSON.parse(await textOf(body)) as MetaFile
441
- meta = parsed.files ?? {}
442
- // The ingest side is the untrusted boundary: a foreign sidecar's
443
- // usage is taken only when it is a plain non-negative number.
444
- exec = usageOf(parsed.exec)
445
- if (typeof parsed.key === 'string') key = parsed.key
517
+ fold.sidecar(await textOf(body))
446
518
  continue
447
519
  }
448
520
  assertSafeName(e.name)
449
- if (e.name === STDOUT_ENTRY) stdout = await textOf(body)
521
+ if (e.name === STDOUT_ENTRY) fold.stdout = await textOf(body)
450
522
  else await drain(body)
451
- seen.push({ name: e.name, size: e.size, mtimeMs: e.mtimeMs })
523
+ fold.entry(e.name, e.size, e.mtimeMs)
452
524
  }
453
525
  check.done()
454
- return {
455
- entries: seen.map((s) => {
456
- const m = meta[s.name]
457
- // A foreign artifact (or one whose sidecar lost an entry) restores
458
- // readable-but-not-executable rather than failing the hit: the
459
- // sidecar is metadata, never the authority on whether bytes exist.
460
- return { name: s.name, size: s.size, mode: m?.[0] ?? 0o644, mtimeMs: m?.[1] ?? s.mtimeMs }
461
- }),
462
- stdout,
463
- exec,
464
- key,
526
+ return fold.result()
527
+ }
528
+
529
+ /**
530
+ * `scanArtifact` over a tar in memory, synchronously: a save scans the
531
+ * tar it just packed. Same decoder, same checks in the same order, same
532
+ * result. The async generators, per-chunk promises and drain were most
533
+ * of a small save's scan: one 400 B output, 49 → 14 µs (X-191).
534
+ */
535
+ export function scanTarBytes(tar: Uint8Array): ArtifactScan {
536
+ const fold = new ScanFold()
537
+ const check = new SumCheck()
538
+ for (const e of tarEntriesSync(tar)) {
539
+ if (e.type !== '0') continue
540
+ if (check.enter(e.name)) {
541
+ check.verify(utf8.decode(e.body()))
542
+ continue
543
+ }
544
+ if (e.name === META_ENTRY) {
545
+ const body = e.body()
546
+ check.add(body)
547
+ fold.sidecar(utf8.decode(body))
548
+ continue
549
+ }
550
+ assertSafeName(e.name)
551
+ const body = e.body()
552
+ check.add(body)
553
+ if (e.name === STDOUT_ENTRY) fold.stdout = utf8.decode(body)
554
+ fold.entry(e.name, e.size, e.mtimeMs)
465
555
  }
556
+ check.done()
557
+ return fold.result()
466
558
  }
467
559
 
468
560
  /** The usage worth carrying: each axis a finite non-negative number, or absent; nothing → undefined. */
@@ -569,7 +661,7 @@ async function bytesOf(body: AsyncIterable<Uint8Array>): Promise<Uint8Array> {
569
661
  }
570
662
 
571
663
  const textOf = async (body: AsyncIterable<Uint8Array>): Promise<string> =>
572
- new TextDecoder().decode(await bytesOf(body))
664
+ utf8.decode(await bytesOf(body))
573
665
 
574
666
  /** One staged entry: written at `tmp`, renamed to `target` on commit. */
575
667
  interface Staged {
@@ -1012,6 +1104,12 @@ function assertSafeName(name: string): void {
1012
1104
  if (name.includes('//')) {
1013
1105
  throw new ArchiveSecurityError(`archive entry name has empty path component (unsafe): ${name}`)
1014
1106
  }
1107
+ // vx never packs a `.` segment, and `outputs/.` names the anchor itself:
1108
+ // ingest kept it, and every hit then failed renaming a file over the
1109
+ // project directory, blamed on the user's tree.
1110
+ if (hasDotSegment(name)) {
1111
+ throw new ArchiveSecurityError(`archive entry name has a '.' segment (unsafe): ${name}`)
1112
+ }
1015
1113
  // No backslash refusal: vx runs on Linux and macOS (Windows through WSL),
1016
1114
  // where it is a name character, and the save scans its own artifact with
1017
1115
  // these checks, so refusing one made `dist/back\slash` uncacheable.
@@ -1049,6 +1147,10 @@ const NAME_MAX = 255
1049
1147
  /** Bytes a path may hold, its terminating NUL included (limits.h). */
1050
1148
  const PATH_MAX = process.platform === 'darwin' ? 1024 : 4096
1051
1149
 
1150
+ function hasDotSegment(p: string): boolean {
1151
+ return p === '.' || p.startsWith('./') || p.endsWith('/.') || p.includes('/./')
1152
+ }
1153
+
1052
1154
  function hasParentSegment(p: string): boolean {
1053
1155
  if (p === '..' || p.startsWith('../') || p.endsWith('/..')) return true
1054
1156
  return p.includes('/../')