@vzn/vx 0.0.364 → 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.364",
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.364",
25
- "@vzn/vx-linux-arm64": "0.0.364",
26
- "@vzn/vx-darwin-x64": "0.0.364",
27
- "@vzn/vx-darwin-arm64": "0.0.364"
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"
@@ -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
 
@@ -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
+ }
@@ -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.'))