@vzn/vx 0.0.363 → 0.0.365

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
@@ -34,12 +34,9 @@ Same graph, commands and concurrency: [how it is measured](https://vznjs.github.
34
34
 
35
35
  ## Why it is faster
36
36
 
37
- - **Nothing boots per task.** No daemon, no Node process per task: vx
38
- spawns your command directly.
39
- - **Hashing without reading.** On a clean tree, cache keys come from
40
- git's index; no file is read.
41
- - **One pass over the repo.** One git walk per run; each stat and
42
- lookup happens once.
37
+ - **Nothing boots per task:** no daemon, no Node process; vx spawns your command.
38
+ - **No file reads to hash:** on a clean tree, keys come from git's index.
39
+ - **One pass:** one git walk per run; each stat and lookup happens once.
43
40
 
44
41
  ## Install
45
42
 
@@ -47,9 +44,8 @@ Same graph, commands and concurrency: [how it is measured](https://vznjs.github.
47
44
  npm install -D @vzn/vx # or: pnpm add -D -w · yarn add -D (-W on Yarn 1) · bun add -d
48
45
  ```
49
46
 
50
- The package ships a prebuilt binary for Linux and macOS, x64 and arm64;
51
- no Bun needed. The binary alone, from a
52
- [release](https://github.com/vznjs/vx/releases), needs no Node either.
47
+ A prebuilt binary for Linux and macOS (x64, arm64); no Bun needed. The
48
+ [release](https://github.com/vznjs/vx/releases) binary needs no Node either.
53
49
  Windows: use WSL.
54
50
 
55
51
  ## Quick start
@@ -60,7 +56,7 @@ In a git repo with a `package.json` workspace:
60
56
  npx vx init # a vx.config.ts per package, from its scripts
61
57
  ```
62
58
 
63
- Give a task a cache block (what it reads, what it writes):
59
+ Give a task a cache block, what it reads and writes:
64
60
 
65
61
  ```ts
66
62
  // packages/app/vx.config.ts
@@ -89,8 +85,8 @@ npx vx run build --all # restored from the cache, not rebuilt
89
85
 
90
86
  <img src="https://vznjs.github.io/vx/demo.svg" width="760" alt="vx run ci --all on examples/basic: cold, three tasks run; again, three up-to-date.">
91
87
 
92
- Or copy [`examples/basic`](examples/basic), a two-package starter CI
93
- runs on every commit. More: [Quickstart](https://vznjs.github.io/vx/quickstart/) ·
88
+ Starter: [`examples/basic`](examples/basic), run by CI on every commit.
89
+ More: [Quickstart](https://vznjs.github.io/vx/quickstart/) ·
94
90
  [Configure](https://vznjs.github.io/vx/guides/configure/) ·
95
91
  [CLI](packages/vx/docs/cli.md) · [every config field](packages/vx/docs/schema.md) ·
96
92
  [playground](https://vznjs.github.io/vx/playground/) (the planner, in your browser).
@@ -98,24 +94,21 @@ runs on every commit. More: [Quickstart](https://vznjs.github.io/vx/quickstart/)
98
94
  ## Coming from Turbo or Nx
99
95
 
100
96
  `bunx @vzn/vx-migrate` writes a `vx.config.ts` per package from
101
- `turbo.json` or the Nx graph; `vx init` in such a repo writes a
102
- `vx.workspace.ts` that reads them in place. Either is a temporary start:
103
- move to native vx config, which is what the numbers above measure.
104
- [Migration guide](https://vznjs.github.io/vx/guides/migrate/).
97
+ `turbo.json` or the Nx graph. `vx init` there writes a `vx.workspace.ts`
98
+ that reads them in place: a temporary start. The numbers above are native
99
+ vx config. [Migration guide](https://vznjs.github.io/vx/guides/migrate/).
105
100
 
106
101
  ## More than speed
107
102
 
108
- - **A cache you can trust.** Configs are TypeScript, evaluated before
109
- hashing, so imports and computed values are part of the key. Declared
110
- outputs are wiped before every run and every restore: no stale file
111
- survives.
112
- - **Sandboxed tasks.** Opt in per task: a read the task did not
113
- declare fails the build instead of poisoning the cache.
114
- - **Clean exits.** Ctrl-C reaps every child. No orphaned dev servers.
115
- - **Plugins at every stage.** Remote cache, remote execution,
116
- telemetry and new CLI verbs are plugins on documented seams, the way
117
- Vite does it. Core ships none and needs none.
118
- - **Free.** MIT. No paywall, no cloud, no account.
103
+ - **A cache you can trust:** TypeScript configs are evaluated before
104
+ hashing, so imports and computed values are in the key. Outputs are
105
+ wiped before every run and restore: no stale file survives.
106
+ - **Sandboxed tasks:** opt in per task; an undeclared read fails the
107
+ build instead of poisoning the cache.
108
+ - **Clean exits:** Ctrl-C reaps every child.
109
+ - **Plugins at every stage:** remote cache, remote execution, telemetry,
110
+ CLI verbs. Core ships none and needs none.
111
+ - **Free:** MIT. No paywall, no cloud, no account.
119
112
 
120
113
  ## How it compares
121
114
 
@@ -136,8 +129,7 @@ Every gap, and when another tool is the better pick:
136
129
 
137
130
  ## Plugins
138
131
 
139
- Declared in `vx.workspace.ts`. Without one, vx runs and caches on this
140
- machine.
132
+ Declared in `vx.workspace.ts`; without any, vx runs and caches locally.
141
133
 
142
134
  | Package | What it adds |
143
135
  | ---------------------------------------------------------- | ----------------------------------------------------------------------------- |
@@ -156,9 +148,8 @@ Write your own: [plugin guide](https://vznjs.github.io/vx/guides/plugins/) ·
156
148
 
157
149
  ## Status
158
150
 
159
- **Pre-alpha**, dogfooded: this repo lints, tests and builds itself
160
- with vx. Over 4,000 core tests plus the package suites, CI green on
161
- every commit. The schema may still change; what 1.0 will promise is in
151
+ **Pre-alpha**, dogfooded: this repo lints, tests and builds itself with
152
+ vx. The schema may still change; 1.0's promise:
162
153
  [versioning-1.0.md](packages/vx/docs/design/versioning-1.0.md).
163
154
 
164
155
  | Surface | Maturity |
@@ -176,9 +167,9 @@ bun install
176
167
  bun packages/vx/src/bin.ts run ci --all # lint, test, docs build: every package
177
168
  ```
178
169
 
179
- Needs Bun ≥ 1.4. Every dev task is a vx task; there are no
180
- `package.json` scripts. Start with [CONTRIBUTING.md](CONTRIBUTING.md)
181
- and [STATUS.md](packages/vx/docs/STATUS.md), the living handoff.
170
+ Needs Bun ≥ 1.4. Every dev task is a vx task; no `package.json`
171
+ scripts. Start with [CONTRIBUTING.md](CONTRIBUTING.md) and
172
+ [STATUS.md](packages/vx/docs/STATUS.md).
182
173
 
183
174
  ## License
184
175
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.363",
3
+ "version": "0.0.365",
4
4
  "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
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.363",
25
- "@vzn/vx-linux-arm64": "0.0.363",
26
- "@vzn/vx-darwin-x64": "0.0.363",
27
- "@vzn/vx-darwin-arm64": "0.0.363"
24
+ "@vzn/vx-linux-x64": "0.0.365",
25
+ "@vzn/vx-linux-arm64": "0.0.365",
26
+ "@vzn/vx-darwin-x64": "0.0.365",
27
+ "@vzn/vx-darwin-arm64": "0.0.365"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/src/bin.ts CHANGED
@@ -9,10 +9,14 @@ async function main(): Promise<void> {
9
9
  // `vx --version` needs one leaf module; the dispatcher and the util
10
10
  // barrel it pulled in were 20 ms of its 28 (2026-10-01).
11
11
  if (argv.length === 1 && (argv[0] === '--version' || argv[0] === 'version')) {
12
- process.stdout.write(`vx ${(await import('./version.js')).VERSION}\n`)
12
+ // `Bun.stdout`, not `process.stdout`: the Node stream's first touch is
13
+ // ~7 ms, half of this verb (2026-10-02). A reader gone (EPIPE) is no
14
+ // failure of the line.
15
+ await Bun.write(Bun.stdout, `vx ${(await import('./version.js')).VERSION}\n`).catch(() => {})
13
16
  settled = true
14
17
  return
15
18
  }
19
+ listenForReadersGone()
16
20
  const { registerCoreAlias, run } = await import('./cli/index.js')
17
21
  // Every `import … from '@vzn/vx'` this process evaluates — a plugin
18
22
  // package, a workspace or project config — resolves to THIS core, not to
@@ -86,8 +90,11 @@ process.on('beforeExit', () => {
86
90
  // and exits with its own verdict; what was going to the reader goes
87
91
  // nowhere. Same for stderr (`2>&1 | head`). Here and not in the logger:
88
92
  // an embedder's streams are the embedder's.
89
- for (const stream of [process.stdout, process.stderr]) {
90
- stream.on('error', () => {})
93
+ // Set up past `--version`, which never touches the Node streams.
94
+ function listenForReadersGone(): void {
95
+ for (const stream of [process.stdout, process.stderr]) {
96
+ stream.on('error', () => {})
97
+ }
91
98
  }
92
99
 
93
100
  void main()
@@ -439,6 +439,7 @@ export class Cache implements CacheLayer {
439
439
  private readonly insertEntry: ReturnType<Database['prepare']>
440
440
  private readonly deleteEntryRow: ReturnType<Database['prepare']>
441
441
  private readonly selectEntry: ReturnType<Database['prepare']>
442
+ private readonly entryExists: ReturnType<Database['prepare']>
442
443
  private readonly bumpAccessed: ReturnType<Database['prepare']>
443
444
  private readonly touched = new Set<string>()
444
445
  private readonly insertEntryInput: ReturnType<Database['prepare']>
@@ -711,6 +712,12 @@ export class Cache implements CacheLayer {
711
712
  peak_rss_bytes = excluded.peak_rss_bytes
712
713
  `)
713
714
  this.selectEntry = this.db.prepare('SELECT * FROM entries WHERE hash = ?')
715
+ // `has` asks only whether the row is there: the whole row, stdout
716
+ // included, was built and dropped once per task of a `--dry` plan. A
717
+ // column off the index, so the table row is still read and a corrupt
718
+ // table refuses here as it does on `get` (`SELECT 1` answers from the
719
+ // index alone); stdout's overflow pages are not.
720
+ this.entryExists = this.db.prepare('SELECT exit_code FROM entries WHERE hash = ?')
714
721
  this.bumpAccessed = this.db.prepare('UPDATE entries SET accessed_at = ? WHERE hash = ?')
715
722
  // INSERT OR IGNORE: re-saving the same hash (idempotent ingest /
716
723
  // overlapping concurrent saves) leaves the existing rows untouched —
@@ -981,8 +988,7 @@ export class Cache implements CacheLayer {
981
988
 
982
989
  private async hasEntry(hash: string): Promise<'local' | 'remote' | null> {
983
990
  if (!this.read) return null
984
- const row = this.selectEntry.get(hash) as EntryRow | undefined
985
- if (!row) return null
991
+ if (this.entryExists.get(hash) === null) return null
986
992
  return existsSync(this.tarPath(hash)) ? 'local' : null
987
993
  }
988
994
 
@@ -53,6 +53,8 @@ function octal(h: Uint8Array, off: number, len: number): number {
53
53
  for (let i = off + 1; i < off + len; i++) n = n * 256 + h[i]!
54
54
  return n
55
55
  }
56
+ const plain = plainOctal(h, off, len)
57
+ if (plain !== null) return plain
56
58
  const s = field(h, off, len).trim()
57
59
  if (s === '') return 0
58
60
  // Every digit, not the longest parseable prefix: `parseInt` would read
@@ -61,10 +63,33 @@ function octal(h: Uint8Array, off: number, len: number): number {
61
63
  return parseInt(s, 8)
62
64
  }
63
65
 
66
+ /**
67
+ * The field every writer emits — octal digits between ASCII spaces, ended
68
+ * by a NUL or the field's end — read off the bytes; null for anything
69
+ * else, which `octal` reads the slow way. Three fields a header through a
70
+ * decoder and a regex were most of a small artifact's read (2026-10-02).
71
+ */
72
+ function plainOctal(h: Uint8Array, off: number, len: number): number | null {
73
+ const end = off + len
74
+ let i = off
75
+ while (i < end && h[i] === 0x20) i++
76
+ let n = 0
77
+ while (i < end && h[i]! >= 0x30 && h[i]! <= 0x37) n = n * 8 + (h[i++]! - 0x30)
78
+ while (i < end && h[i] === 0x20) i++
79
+ return i === end || h[i] === 0 ? n : null
80
+ }
81
+
82
+ function isZeroBlock(h: Uint8Array): boolean {
83
+ for (let i = 0; i < BLOCK; i++) if (h[i] !== 0) return false
84
+ return true
85
+ }
86
+
64
87
  function checksumOk(h: Uint8Array): boolean {
65
88
  const stored = octal(h, 148, 8)
66
- let sum = 0
67
- for (let i = 0; i < BLOCK; i++) sum += i >= 148 && i < 156 ? 32 : h[i]!
89
+ // The checksum field counts as eight spaces.
90
+ let sum = 8 * 32
91
+ for (let i = 0; i < 148; i++) sum += h[i]!
92
+ for (let i = 156; i < BLOCK; i++) sum += h[i]!
68
93
  return sum === stored
69
94
  }
70
95
 
@@ -107,6 +132,18 @@ class Source {
107
132
 
108
133
  /** Exactly `n` bytes, or null at a clean end (nothing buffered), or a TarFormatError mid-entry. */
109
134
  async exact(n: number, what: string): Promise<Uint8Array | null> {
135
+ // Within one chunk, a view: the copy below was a third of a small
136
+ // artifact's read. Nothing writes to what this returns.
137
+ const c0 = this.chunks[0]
138
+ if (c0 !== undefined && c0.byteLength - this.head >= n) {
139
+ const view = c0.subarray(this.head, this.head + n)
140
+ this.head += n
141
+ if (this.head === c0.byteLength) {
142
+ this.chunks.shift()
143
+ this.head = 0
144
+ }
145
+ return view
146
+ }
110
147
  const out = new Uint8Array(n)
111
148
  let got = 0
112
149
  while (got < n) {
@@ -163,7 +200,7 @@ export async function* tarEntries(stream: ReadableStream<Uint8Array>): AsyncGene
163
200
  if (zeroBlocks === 0) throw new TarFormatError('archive has no end-of-archive marker')
164
201
  return
165
202
  }
166
- if (h.every((b) => b === 0)) {
203
+ if (isZeroBlock(h)) {
167
204
  zeroBlocks++
168
205
  if (zeroBlocks >= 2) return
169
206
  continue
@@ -89,6 +89,7 @@ import {
89
89
  } from './sandbox-deny-scan.js'
90
90
  import {
91
91
  parseStraceViolations,
92
+ refusedConnections,
92
93
  refusedWrites,
93
94
  refusedWritesOutside,
94
95
  reportableViolations,
@@ -1732,6 +1733,7 @@ async function runSandboxedOnce(
1732
1733
  bindableWrites(args.config.allowWrite),
1733
1734
  scratch,
1734
1735
  ),
1736
+ ...refusedConnections(records.map((v) => v.line)),
1735
1737
  ]
1736
1738
  : []
1737
1739
  if (straceLog) {
@@ -419,6 +419,8 @@ function matchesIgnore(
419
419
  * crossing, and the task can grant it.
420
420
  */
421
421
  function describeMacViolation(line: string): Partial<SandboxViolation> {
422
+ const proxy = PROXY_DENY_RE.exec(line)
423
+ if (proxy !== null) return { target: proxy[1]!, ignorable: ['network'] }
422
424
  const m = /deny\(\d+\)\s+(\S+)\s+(.+?)\s*$/.exec(line)
423
425
  if (m === null) return {}
424
426
  const [op, target] = [m[1]!, m[2]!]
@@ -589,6 +591,28 @@ function filterIgnored(
589
591
  return violations.filter((v) => !matchesIgnore(v, ignore))
590
592
  }
591
593
 
594
+ /**
595
+ * SRT's filtering proxy records a connection it refused as `deny
596
+ * network-outbound <host>:<port> (<reason>)`, on both platforms: the one
597
+ * component that knows the host. Read as its own shape, so `ignore.network`
598
+ * silences it by host; the seatbelt pattern above wants `deny(<n>)`.
599
+ */
600
+ const PROXY_DENY_RE = /^deny network-outbound (\S+) \([^)]*\)$/
601
+
602
+ /**
603
+ * Linux: the connections the proxy refused, from the store records. Only
604
+ * the write observer's records were read there, so a task denied a host
605
+ * failed with its own `403` and no report, and one that survived the
606
+ * refusal passed, where macOS reports the same record (2026-10-02).
607
+ */
608
+ export function refusedConnections(records: readonly string[]): SandboxViolation[] {
609
+ return [...new Set(records.filter((r) => PROXY_DENY_RE.test(r)))].map((line) => ({
610
+ line,
611
+ timestamp: new Date(),
612
+ ...describeMacViolation(line),
613
+ }))
614
+ }
615
+
592
616
  /**
593
617
  * The writes SRT's Linux observer saw that this task's binds do not cover.
594
618
  * The observer reports every write-intent syscall as `deny <syscall>
@@ -612,6 +636,11 @@ export function refusedWrites(
612
636
  const m = /^deny (\S+) (\/.*)$/.exec(record)
613
637
  if (m === null) continue
614
638
  const [, syscall, raw] = m as unknown as [string, string, string]
639
+ // A descriptor or pseudo-file is the task's, and resolving it here
640
+ // reads vx's own: strace's log is `/dev/fd/5`, and a vx started with
641
+ // `5>out.log` in the project reported a write to `out.log` and failed
642
+ // a clean task (2026-10-02).
643
+ if (/^\/(?:dev|proc)\//.test(raw)) continue
615
644
  const abs = toRealPath(raw)
616
645
  if (isUnderAny(abs, binds) || underGlob(abs, globs)) continue
617
646
  const key = `${syscall}|${abs}`
@@ -915,7 +915,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
915
915
  signal: res.signal,
916
916
  hidden: req.sandbox && ((f: string) => !sandboxReads(req.sandbox!, f)),
917
917
  })
918
- if (verdict !== undefined) log.taskStderr(node, `\n${verdict}\n`)
918
+ // The line quotes the command's first word, which a config may have
919
+ // built from a secret: masked as the task's own output is (L-37).
920
+ if (verdict !== undefined) log.taskStderr(node, `\n${secrets?.mask(verdict) ?? verdict}\n`)
919
921
  // A committed file another task's clean removed, still gone: vx
920
922
  // cleans outputs before a run where Turbo does not, so a reader with
921
923
  // no edge to the producer fails naming only the file (A-48).
@@ -12,8 +12,8 @@
12
12
  // holds it.
13
13
  //
14
14
  // Keyed by the workspace, not the cache directory (`--cache-dir` must not
15
- // make two runs strangers), and kept under the temp directory so a
16
- // read-only checkout can take it.
15
+ // make two runs strangers), and kept in this user's own directory under
16
+ // the temp directory (L-47), so a read-only checkout can take it.
17
17
  //
18
18
  // The lock is a directory, HELD exactly while it is not empty. It holds
19
19
  // one entry, `h-<pid>-<start>-<n>`, naming the holder and unique to this
@@ -60,7 +60,7 @@
60
60
  // serialize what it chose to overlap. The lock is taken by the first of
61
61
  // them and left by the last to release.
62
62
 
63
- import { readFileSync, realpathSync } from 'node:fs'
63
+ import { chmodSync, lstatSync, mkdirSync, readFileSync, realpathSync } from 'node:fs'
64
64
  import { rmdirSync, unlinkSync } from 'node:fs'
65
65
  import { mkdir, readdir, readFile, rename, rm, rmdir, unlink, writeFile } from 'node:fs/promises'
66
66
  import os from 'node:os'
@@ -110,7 +110,7 @@ const SAY_AFTER_MS = 1_000
110
110
  const LEGACY_PID = 'pid'
111
111
 
112
112
  export interface RunLockOptions {
113
- /** Where the lock directories live; `os.tmpdir()` unless a test says otherwise. */
113
+ /** Where the lock directories live; this user's own directory in `os.tmpdir()` unless a test says otherwise. */
114
114
  dir?: string
115
115
  /** A status line for the waiting notice and the unlocked warning. */
116
116
  log: (line: string) => void
@@ -119,12 +119,12 @@ export interface RunLockOptions {
119
119
  }
120
120
 
121
121
  /**
122
- * The lock directory for a workspace root: stable across runs and users,
122
+ * The lock directory for a workspace root: stable across this user's runs,
123
123
  * private to this machine. Keyed by the REAL path: a CLI's cwd comes back
124
124
  * canonical while a caller may hold a symlinked spelling (macOS's /var ->
125
125
  * /private/var), and two spellings of one workspace must meet.
126
126
  */
127
- export function runLockPath(workspaceRoot: string, dir = os.tmpdir()): string {
127
+ export function runLockPath(workspaceRoot: string, dir = userLockRoot(os.tmpdir())): string {
128
128
  const resolved = path.resolve(workspaceRoot)
129
129
  let real = resolved
130
130
  try {
@@ -135,6 +135,46 @@ export function runLockPath(workspaceRoot: string, dir = os.tmpdir()): string {
135
135
  return path.join(dir, `vx-run-${xxh3hex(real)}`)
136
136
  }
137
137
 
138
+ /**
139
+ * This user's own directory for locks under `dir`. A lock named in the
140
+ * shared temp dir itself was anyone's to plant: another local user put a
141
+ * held lock naming a live pid there and every run on the workspace waited
142
+ * on it (L-47). Two users on one checkout no longer exclude each other;
143
+ * the files of one are rarely the other's to write.
144
+ */
145
+ function userLockRoot(dir: string): string {
146
+ const uid = process.getuid?.()
147
+ return uid === undefined ? dir : path.join(dir, `vx-runs-${uid}`)
148
+ }
149
+
150
+ /** The roots this process has made or checked. */
151
+ const ownedRoots = new Set<string>()
152
+
153
+ /**
154
+ * Make the lock's root, or refuse it when it is not this user's own
155
+ * directory (a link, another owner): a lock read there is anyone's.
156
+ */
157
+ function ownLockRoot(lockDir: string): void {
158
+ const root = path.dirname(lockDir)
159
+ const uid = process.getuid?.()
160
+ if (uid === undefined || ownedRoots.has(root)) return
161
+ // Not recursive: a temp directory that is not there is the warning
162
+ // that names TMPDIR, never one vx makes.
163
+ try {
164
+ mkdirSync(root, { mode: 0o700 })
165
+ } catch (err) {
166
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
167
+ }
168
+ const st = lstatSync(root)
169
+ if (!st.isDirectory() || st.uid !== uid) {
170
+ throw new Error(
171
+ `${root} is not this user's own directory (a link or another owner's); remove it`,
172
+ )
173
+ }
174
+ if ((st.mode & 0o077) !== 0) chmodSync(root, 0o700)
175
+ ownedRoots.add(root)
176
+ }
177
+
138
178
  interface Holder {
139
179
  pid: number
140
180
  /** The holder's start time as `startTime` read it; null where the lock names none. */
@@ -309,6 +349,8 @@ export async function acquireRunLock(
309
349
  }
310
350
  let h: Holder | 'free' | null
311
351
  try {
352
+ // A caller's own `dir` (a test's) is theirs to vouch for.
353
+ if (opts.dir === undefined) ownLockRoot(lockDir)
312
354
  const entry = await place(lockDir)
313
355
  if (entry !== null) {
314
356
  heldHere.set(lockDir, 1)
@@ -42,6 +42,24 @@ import { nonJsonMessage, nonJsonPaths, type NonJsonValue } from './json-data.js'
42
42
  /** Why a config's `process.exit` throws, on both load paths (D-65). */
43
43
  export const CONFIG_EXIT = 'a config exports its object; it cannot end the run'
44
44
 
45
+ /**
46
+ * The built-ins a config must not change: vx reads every other config and
47
+ * makes every cache key through them (D-74, D-75). The loader watches them
48
+ * in this process; the worker, asked to blame, in its own.
49
+ */
50
+ export const WATCHED_BUILTIN_NAMES = [
51
+ 'Object.prototype',
52
+ 'Array.prototype',
53
+ 'Bun',
54
+ 'Bun.hash',
55
+ 'JSON',
56
+ 'Math',
57
+ 'String.prototype',
58
+ 'Map.prototype',
59
+ 'Set.prototype',
60
+ 'Promise.prototype',
61
+ ] as const
62
+
45
63
  const WORKER_SRC = `
46
64
  const nonJsonPaths = ${nonJsonPaths.toString()}
47
65
  // The parent's stdout is a verb's JSON or vx mcp's JSON-RPC stream, and a
@@ -83,6 +101,29 @@ self.onmessage = async (e) => {
83
101
  const live = globalThis.process.env
84
102
  for (const k of Object.keys(live)) if (!(k in env)) delete live[k]
85
103
  Object.assign(live, env)
104
+ // A blame request reports the built-ins and env vars this one evaluation
105
+ // changed, for a round whose loads overlapped and saw a change (D-119).
106
+ const watched = e.data.blame
107
+ ? ${JSON.stringify(WATCHED_BUILTIN_NAMES)}.map((n) => [n, n.split('.').reduce((o, k) => o[k], globalThis)])
108
+ : null
109
+ const own = (o) => new Map(Reflect.ownKeys(o).map((k) => [k, Object.getOwnPropertyDescriptor(o, k)]))
110
+ const before = watched?.map(([, o]) => own(o))
111
+ const envBefore = watched ? { ...live } : null
112
+ const same = (a, b) =>
113
+ a !== undefined && b !== undefined && a.value === b.value && a.get === b.get && a.set === b.set &&
114
+ a.writable === b.writable && a.enumerable === b.enumerable && a.configurable === b.configurable
115
+ const changed = () => {
116
+ if (watched === null) return undefined
117
+ const out = []
118
+ watched.forEach(([n, o], i) => {
119
+ const now = own(o)
120
+ for (const k of new Set([...before[i].keys(), ...now.keys()]))
121
+ if (!same(before[i].get(k), now.get(k))) out.push(n + '.' + String(k))
122
+ })
123
+ for (const k of new Set([...Object.keys(envBefore), ...Object.keys(live)]))
124
+ if (envBefore[k] !== live[k]) out.push('process.env.' + k)
125
+ return out
126
+ }
86
127
  try {
87
128
  const ns = await import(path)
88
129
  // Awaited as the in-process load's async return flattens it: a Promise
@@ -96,6 +137,7 @@ self.onmessage = async (e) => {
96
137
  ok: true,
97
138
  nonJson,
98
139
  json: isObject && nonJson.length === 0 ? JSON.stringify(mod) : null,
140
+ changed: changed(),
99
141
  })
100
142
  } catch (err) {
101
143
  postMessage({
@@ -104,6 +146,7 @@ self.onmessage = async (e) => {
104
146
  name: err?.name ?? 'Error',
105
147
  message: err?.message ?? String(err),
106
148
  stack: err?.stack ?? null,
149
+ changed: changed(),
107
150
  position:
108
151
  err?.position && typeof err.position === 'object'
109
152
  ? { file: err.position.file, line: err.position.line, column: err.position.column }
@@ -295,3 +338,31 @@ export async function evaluateConfigFresh(configPath: string): Promise<unknown>
295
338
  retireIfIdle()
296
339
  }
297
340
  }
341
+
342
+ /**
343
+ * What evaluating `configPath` alone changes among the watched built-ins
344
+ * and env vars, in a worker of its own that is then discarded: a round
345
+ * whose overlapping loads saw a change asks each config in turn, so the
346
+ * refusal names the one that made it (D-119). Empty when the evaluation
347
+ * changes nothing, fails before reporting, or outlives the budget.
348
+ */
349
+ export async function builtinsChangedBy(configPath: string): Promise<string[]> {
350
+ const w = new Worker(WORKER_URL)
351
+ try {
352
+ return await new Promise<string[]>((resolve) => {
353
+ const timer = setTimeout(() => resolve([]), evalBudgetMs())
354
+ timer.unref?.()
355
+ w.onmessage = (event: MessageEvent): void => {
356
+ clearTimeout(timer)
357
+ resolve((event.data as { changed?: string[] }).changed ?? [])
358
+ }
359
+ w.onerror = (): void => {
360
+ clearTimeout(timer)
361
+ resolve([])
362
+ }
363
+ w.postMessage({ id: 0, path: path.resolve(configPath), env: { ...process.env }, blame: true })
364
+ })
365
+ } finally {
366
+ w.terminate()
367
+ }
368
+ }
@@ -777,6 +777,36 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
777
777
  'a command run outside vx: a task never reads the terminal (its stdin is EOF, or a pipe vx holds under `persistent`)',
778
778
  },
779
779
  ],
780
+ // wireit's `files` / `output` and include-style spellings written into
781
+ // a cache block, refused with no word on where they go (D-118).
782
+ [
783
+ CACHE_FIELDS,
784
+ {
785
+ files: '`inputs.files`',
786
+ output: '`outputs.files`',
787
+ env: '`inputs.env`',
788
+ dependencies: "the task's `dependsOn` (an upstream's key folds into this one)",
789
+ enabled: 'no field: a task with a `cache` block caches, one without runs every time',
790
+ },
791
+ ],
792
+ [
793
+ CACHE_INPUT_FIELDS,
794
+ {
795
+ globs: '`files`',
796
+ include: '`files`',
797
+ patterns: '`files`',
798
+ exclude: "`files` with a `!` entry (`'!**/*.test.ts'`)",
799
+ ignore: "`files` with a `!` entry (`'!**/*.test.ts'`)",
800
+ },
801
+ ],
802
+ [
803
+ CACHE_OUTPUT_FIELDS,
804
+ {
805
+ globs: '`files`',
806
+ include: '`files`',
807
+ exclude: "`files` with a `!` entry (`'!dist/cache/**'`)",
808
+ },
809
+ ],
780
810
  // A package's turbo.json and Nx's project.json keys, written into a
781
811
  // project's vx.config (D-56). `tags` had read as a typo of `tasks`.
782
812
  [
@@ -3,7 +3,14 @@ import path from 'node:path'
3
3
  import type { ProjectConfig, WorkspaceConfig } from '../config.js'
4
4
  import { UserError, xxh3hex } from '../util/index.js'
5
5
  import { validateProjectConfig, validateWorkspace } from './config-schema.js'
6
- import { beginEvalRound, CONFIG_EXIT, evalBudgetMs, evaluateConfigFresh } from './config-eval.js'
6
+ import {
7
+ beginEvalRound,
8
+ builtinsChangedBy,
9
+ CONFIG_EXIT,
10
+ evalBudgetMs,
11
+ evaluateConfigFresh,
12
+ WATCHED_BUILTIN_NAMES,
13
+ } from './config-eval.js'
7
14
  import { hasEsmExport, unprovidedBareImports } from './config-imports.js'
8
15
  import {
9
16
  configEvalKey,
@@ -434,6 +441,9 @@ export async function loadProjectConfigs(
434
441
  // `node:stream`), ~7 ms of a two-config warm run where every load hit.
435
442
  let builtins: BuiltinSnapshot = []
436
443
  let env: Readonly<Record<string, string | undefined>> = {}
444
+ // More than one evaluation in flight: a change seen after one load may
445
+ // be another's.
446
+ let overlapping = false
437
447
  const loadOne = async (entry: (typeof prepared)[number]): Promise<Loaded> => {
438
448
  const { configPath, cacheKey } = entry
439
449
  // A fast key that missed: the closure is stale or the file changed.
@@ -475,7 +485,12 @@ export async function loadProjectConfigs(
475
485
  // Before anything reads through them: a replaced `Array.prototype.includes`
476
486
  // turned the JSON-data walk's own check into "a cyclic reference".
477
487
  const changed = repeat ? [] : [...restoreBuiltins(builtins), ...restoreEnv(env)]
478
- if (changed.length > 0) throw builtinsChanged(changed, configPath)
488
+ // Loads overlap, so another config's change can surface after this
489
+ // one: named here, the refusal blamed the wrong file (D-119). The round
490
+ // finds the one that made it.
491
+ if (changed.length > 0) {
492
+ throw overlapping ? new ChangedInRound(changed) : builtinsChanged(changed, configPath)
493
+ }
479
494
  assertDefaultObject(mod, 'Project', configPath)
480
495
  // Validation runs HERE, on whichever object we ended up with, so a
481
496
  // malformed config reports the identical UserError whether it was
@@ -522,6 +537,7 @@ export async function loadProjectConfigs(
522
537
  builtins = builtinSnapshot()
523
538
  env = { ...process.env }
524
539
  }
540
+ overlapping = misses.length > 1
525
541
  let next = 0
526
542
  const lane = async (): Promise<void> => {
527
543
  while (next < misses.length) {
@@ -539,6 +555,14 @@ export async function loadProjectConfigs(
539
555
  }
540
556
  }
541
557
  const changed = misses.length > 0 ? [...restoreBuiltins(builtins), ...restoreEnv(env)] : []
558
+ if (first?.failed instanceof ChangedInRound) {
559
+ for (const i of misses) {
560
+ const configPath = prepared[i]!.configPath
561
+ const own = await builtinsChangedBy(configPath)
562
+ if (own.length > 0) throw builtinsChanged(own, configPath)
563
+ }
564
+ throw builtinsChanged(first.failed.changed)
565
+ }
542
566
  if (first !== undefined) throw first.failed
543
567
  if (changed.length > 0) throw builtinsChanged(changed)
544
568
  return results.map((r) => (r as Loaded).config)
@@ -565,20 +589,18 @@ export async function loadProjectConfigs(
565
589
  * load runs in this process, so the round compares them before and after,
566
590
  * puts back what changed (a failed load too) and refuses.
567
591
  */
568
- const WATCHED_BUILTINS: ReadonlyArray<readonly [string, object]> = [
569
- ['Object.prototype', Object.prototype],
570
- ['Array.prototype', Array.prototype],
571
- // What vx itself runs on: `Bun.hash.xxHash3 = () => 7n` gave every task
572
- // the key 00000000, and a changed command replayed the old output (D-75).
573
- ['Bun', Bun],
574
- ['Bun.hash', Bun.hash],
575
- ['JSON', JSON],
576
- ['Math', Math],
577
- ['String.prototype', String.prototype],
578
- ['Map.prototype', Map.prototype],
579
- ['Set.prototype', Set.prototype],
580
- ['Promise.prototype', Promise.prototype],
581
- ]
592
+ // `Bun` and `Bun.hash` are what vx itself runs on: `Bun.hash.xxHash3 = ()
593
+ // => 7n` gave every task the key 00000000, and a changed command replayed
594
+ // the old output (D-75).
595
+ const WATCHED_BUILTINS: ReadonlyArray<readonly [string, object]> = WATCHED_BUILTIN_NAMES.map(
596
+ (name) =>
597
+ [
598
+ name,
599
+ name
600
+ .split('.')
601
+ .reduce<unknown>((o, k) => (o as Record<string, unknown>)[k], globalThis) as object,
602
+ ] as const,
603
+ )
582
604
 
583
605
  interface OwnProperties {
584
606
  keys: PropertyKey[]
@@ -614,7 +636,12 @@ function unchanged(proto: object, keys: readonly PropertyKey[], was: OwnProperti
614
636
  return true
615
637
  }
616
638
 
617
- /** Loads run together, so the config named is the one whose load saw the change. */
639
+ /** A change seen by one of several overlapping loads; the round names its config. */
640
+ class ChangedInRound {
641
+ constructor(readonly changed: readonly string[]) {}
642
+ }
643
+
644
+ /** Without `configPath`, no config alone was found to make the change. */
618
645
  function builtinsChanged(changed: readonly string[], configPath?: string): UserError {
619
646
  const who = configPath === undefined ? 'a project config' : configPath
620
647
  const env = changed.some((c) => c.startsWith('process.env.'))