@vzn/vx 0.0.353 → 0.0.354

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.353",
3
+ "version": "0.0.354",
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.353",
25
- "@vzn/vx-linux-arm64": "0.0.353",
26
- "@vzn/vx-darwin-x64": "0.0.353",
27
- "@vzn/vx-darwin-arm64": "0.0.353"
24
+ "@vzn/vx-linux-x64": "0.0.354",
25
+ "@vzn/vx-linux-arm64": "0.0.354",
26
+ "@vzn/vx-darwin-x64": "0.0.354",
27
+ "@vzn/vx-darwin-arm64": "0.0.354"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -360,7 +360,7 @@ export function runGitLsFiles(cwd: string): GitLsResult {
360
360
  )
361
361
  }
362
362
  const { text, undecodable } = decodeGitZ(proc.stdout)
363
- const parsed = parseLsFilesOutput(text, undecodable)
363
+ const parsed = parseLsFilesOutput(text.length === 0 ? [] : text.split('\0'), undecodable)
364
364
  const nested = expandNestedRepos(cwd, parsed.files, parsed.gitlinks)
365
365
  parsed.files = nested.files
366
366
  parsed.undecodable.push(...nested.undecodable)
@@ -445,15 +445,22 @@ export function fileIdentity(mode: string, oid: string): string {
445
445
  // `h`, …) in front. `--others` paths print bare; with `-z`, core.quotePath
446
446
  // quoting is off, so a bare path containing a literal tab still cannot
447
447
  // match the fixed-form prefix. Both answers come from ONE spawn.
448
- function parseLsFilesOutput(out: string, undecodableRecords: ReadonlySet<string>): GitLsResult {
448
+ //
449
+ // `debug`, for a `--debug` listing: each record's recorded size (aligned
450
+ // with `records`) in, and each regular stage-0 entry's raw OID and size out.
451
+ function parseLsFilesOutput(
452
+ records: readonly string[],
453
+ undecodableRecords: ReadonlySet<string>,
454
+ debug?: { sizes: readonly number[]; indexed: Map<string, { oid: string; size: number }> },
455
+ ): GitLsResult {
449
456
  const files: string[] = []
450
457
  const oids = new Map<string, string>()
451
458
  const flagged = new Set<string>()
452
459
  const gitlinks = new Set<string>()
453
460
  const undecodable: string[] = []
454
- if (out.length === 0) return { files, oids, flagged, gitlinks, undecodable }
455
461
  // NUL-separated; trailing NUL produces an empty segment we skip.
456
- for (const record of out.split('\0')) {
462
+ for (let i = 0; i < records.length; i++) {
463
+ const record = records[i]!
457
464
  if (record.length === 0) continue
458
465
  const m = LS_FILES_STAGE_RE.exec(record)
459
466
  const filePath = m === null ? record : record.slice(m[0].length) // --others: bare path
@@ -464,6 +471,9 @@ function parseLsFilesOutput(out: string, undecodableRecords: ReadonlySet<string>
464
471
  const stage = m[4]!
465
472
  if ((mode === '100644' || mode === '100755' || mode === '120000') && stage === '0') {
466
473
  oids.set(filePath, fileIdentity(mode, m[3]!))
474
+ if (debug !== undefined && mode !== '120000') {
475
+ debug.indexed.set(filePath, { oid: m[3]!, size: debug.sizes[i]! })
476
+ }
467
477
  } else if (mode === '160000') {
468
478
  gitlinks.add(filePath)
469
479
  }
@@ -477,21 +487,23 @@ function parseLsFilesOutput(out: string, undecodableRecords: ReadonlySet<string>
477
487
  }
478
488
 
479
489
  /**
480
- * `ls-files -s -v -z --debug` as the plain `-s -v -z` stream, plus the size
481
- * the index records for each regular stage-0 entry's worktree file and its
482
- * raw OID. With `-z`, each record's NUL is followed by its five
483
- * newline-ended debug lines (ctime, mtime, dev/ino, uid/gid, size/flags),
484
- * then the next record.
490
+ * `ls-files -s -v -z --debug` as the plain `-s -v -z` records, plus the size
491
+ * the index records for each, aligned. With `-z`, each record's NUL is
492
+ * followed by its five newline-ended debug lines (ctime, mtime, dev/ino,
493
+ * uid/gid, size/flags), then the next record. The records go to
494
+ * `parseLsFilesOutput` as they are: joining them for it to split again,
495
+ * a regex per size line and a second regex per record were ~14 ms of a
496
+ * 12,905-file listing.
485
497
  */
486
- function stripLsDebug(run: GitRun): {
487
- plain: string
498
+ function splitLsDebug(run: GitRun): {
499
+ records: string[]
500
+ sizes: number[]
488
501
  undecodable: Set<string>
489
- indexed: Map<string, { oid: string; size: number }>
490
502
  } {
491
503
  const segs = run.stdout.split('\0')
492
504
  const records: string[] = []
505
+ const sizes: number[] = []
493
506
  const undecodable = new Set<string>()
494
- const indexed = new Map<string, { oid: string; size: number }>()
495
507
  let record = segs[0]!
496
508
  let recordRaw = record
497
509
  for (let i = 1; i <= segs.length; i++) {
@@ -504,7 +516,13 @@ function stripLsDebug(run: GitRun): {
504
516
  const nl = seg.indexOf('\n', at)
505
517
  if (nl < 0) at = -1
506
518
  else {
507
- if (k === 4) size = Number(/size: (\d+)/.exec(seg.slice(at, nl))?.[1] ?? -1)
519
+ if (k === 4) {
520
+ const field = seg.indexOf('size: ', at)
521
+ if (field >= 0 && field < nl) {
522
+ const n = parseInt(seg.slice(field + 6, nl), 10)
523
+ if (!Number.isNaN(n)) size = n
524
+ }
525
+ }
508
526
  at = nl + 1
509
527
  }
510
528
  }
@@ -512,16 +530,13 @@ function stripLsDebug(run: GitRun): {
512
530
  }
513
531
  if (record.length > 0) {
514
532
  records.push(record)
533
+ sizes.push(size)
515
534
  if (run.undecodable.has(recordRaw)) undecodable.add(record)
516
- const m = LS_FILES_STAGE_RE.exec(record)
517
- if (m !== null && m[4] === '0' && (m[2] === '100644' || m[2] === '100755')) {
518
- indexed.set(record.slice(m[0].length), { oid: m[3]!, size })
519
- }
520
535
  }
521
536
  record = rest
522
537
  recordRaw = seg ?? ''
523
538
  }
524
- return { plain: records.join('\0'), undecodable, indexed }
539
+ return { records, sizes, undecodable }
525
540
  }
526
541
 
527
542
  /** Blob sizes learned once and kept: a blob's size is fixed for its OID. `Cache` is one. */
@@ -546,9 +561,16 @@ export interface BlobSizeMemo {
546
561
  async function dropResizedOids(enumeration: GitEnumeration, memo?: BlobSizeMemo): Promise<void> {
547
562
  const { trusted, indexed } = enumeration
548
563
  const wanted = new Set<string>()
564
+ // Each trusted path's index entry, found once: a second lookup by path
565
+ // in the check below was half of this pass on a 12,905-file tree.
566
+ const rels: string[] = []
567
+ const entries: Array<{ oid: string; size: number }> = []
549
568
  for (const rel of trusted.keys()) {
550
569
  const entry = indexed.get(rel)
551
- if (entry !== undefined) wanted.add(entry.oid)
570
+ if (entry === undefined) continue // a symlink: its blob is its target string
571
+ rels.push(rel)
572
+ entries.push(entry)
573
+ wanted.add(entry.oid)
552
574
  }
553
575
  if (wanted.size === 0) return
554
576
  const sizes = memo?.knownBlobSizes([...wanted]) ?? new Map<string, number>()
@@ -569,12 +591,11 @@ async function dropResizedOids(enumeration: GitEnumeration, memo?: BlobSizeMemo)
569
591
  for (const [oid, n] of learned) sizes.set(oid, n)
570
592
  memo?.rememberBlobSizes(learned)
571
593
  }
572
- for (const rel of trusted.keys()) {
573
- const entry = indexed.get(rel)
574
- if (entry === undefined) continue // a symlink: its blob is its target string
594
+ for (let i = 0; i < rels.length; i++) {
595
+ const entry = entries[i]!
575
596
  const size = sizes.get(entry.oid)
576
597
  // The index keeps the low 32 bits of a size.
577
- if (size === undefined || size % 2 ** 32 !== entry.size) trusted.delete(rel)
598
+ if (size === undefined || size % 2 ** 32 !== entry.size) trusted.delete(rels[i]!)
578
599
  }
579
600
  }
580
601
 
@@ -1064,7 +1085,7 @@ export async function startGitEnumeration(
1064
1085
  // (`dropResizedOids`, A-60).
1065
1086
  const listing = spawnGit(['ls-files', '-s', '-v', '-z', '--debug', '--', ...pathspecs]).then(
1066
1087
  (run) =>
1067
- run === null || run.exitCode !== 0 ? { run, debug: null } : { run, debug: stripLsDebug(run) },
1088
+ run === null || run.exitCode !== 0 ? { run, debug: null } : { run, debug: splitLsDebug(run) },
1068
1089
  )
1069
1090
  const running = Promise.all([
1070
1091
  listing,
@@ -1118,13 +1139,14 @@ export async function startGitEnumeration(
1118
1139
  `Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
1119
1140
  )
1120
1141
  }
1142
+ const indexed = new Map<string, { oid: string; size: number }>()
1121
1143
  const {
1122
1144
  files: tracked,
1123
1145
  oids,
1124
1146
  flagged,
1125
1147
  gitlinks,
1126
1148
  undecodable,
1127
- } = parseLsFilesOutput(debug!.plain, debug!.undecodable)
1149
+ } = parseLsFilesOutput(debug!.records, debug!.undecodable, { sizes: debug!.sizes, indexed })
1128
1150
  // Normalize `status`'s repo-root-relative paths to workspace-relative (strip
1129
1151
  // the `--show-prefix`) so the dirty set is keyed identically to the trusted
1130
1152
  // OID map. Without this, when the workspace root is a git subdir, a modified
@@ -1202,7 +1224,7 @@ export async function startGitEnumeration(
1202
1224
  untracked: dirty === null ? null : untracked,
1203
1225
  undecodable,
1204
1226
  startedAtMs,
1205
- indexed: debug!.indexed,
1227
+ indexed,
1206
1228
  // A missing blob is an answer ("missing"), never a fetch from a partial
1207
1229
  // clone's promisor remote.
1208
1230
  catFile: (stdin) =>
@@ -1257,7 +1279,28 @@ export async function applyGitEnumeration(
1257
1279
  // ~9k files; ~5 ms this way). '/' sorts below most filename chars,
1258
1280
  // so the range [prefix, prefix+'\xff…') is contiguous in the sorted
1259
1281
  // array; lowerBound on `prefix` and on `prefix + '￿'` bracket it.
1260
- const sorted = [...all].sort()
1282
+ // Git lists in order; a list that already is skips the sort.
1283
+ let inOrder = true
1284
+ for (let i = 1; i < all.length; i++) {
1285
+ if (all[i - 1]! > all[i]!) {
1286
+ inOrder = false
1287
+ break
1288
+ }
1289
+ }
1290
+ const sorted = inOrder ? all : [...all].sort()
1291
+ // Git prints normalized relative paths, so under an absolute, normalized
1292
+ // root a join is a concatenation: `path.join` per tracked file and
1293
+ // `path.relative` per project were most of this pass (I-30's rule).
1294
+ const base =
1295
+ path.sep === '/' &&
1296
+ path.isAbsolute(workspaceRoot) &&
1297
+ path.normalize(workspaceRoot) === workspaceRoot
1298
+ ? workspaceRoot.endsWith('/')
1299
+ ? workspaceRoot
1300
+ : `${workspaceRoot}/`
1301
+ : undefined
1302
+ const abs = (rel: string): string =>
1303
+ base === undefined ? path.join(workspaceRoot, rel) : base + rel
1261
1304
  const lowerBound = (key: string): number => {
1262
1305
  let lo = 0
1263
1306
  let hi = sorted.length
@@ -1269,11 +1312,14 @@ export async function applyGitEnumeration(
1269
1312
  return lo
1270
1313
  }
1271
1314
  for (const projectDir of projectDirs) {
1272
- const relPrefix = path.relative(workspaceRoot, projectDir).split(path.sep).join('/')
1315
+ const relPrefix =
1316
+ base !== undefined && projectDir.startsWith(base) && path.normalize(projectDir) === projectDir
1317
+ ? projectDir.slice(base.length).replace(/\/$/, '')
1318
+ : path.relative(workspaceRoot, projectDir).split(path.sep).join('/')
1273
1319
  if (relPrefix === '' || relPrefix === '.') {
1274
1320
  cache.set(projectDir, all)
1275
1321
  const rootOids = new Map<string, string>()
1276
- for (const [rel, oid] of trusted) rootOids.set(path.join(workspaceRoot, rel), oid)
1322
+ for (const [rel, oid] of trusted) rootOids.set(abs(rel), oid)
1277
1323
  cache.setOids(projectDir, rootOids)
1278
1324
  continue
1279
1325
  }
@@ -1286,7 +1332,7 @@ export async function applyGitEnumeration(
1286
1332
  const rel = sorted[i]!
1287
1333
  matches.push(rel.slice(prefix.length))
1288
1334
  const oid = trusted.get(rel)
1289
- if (oid !== undefined) projOids.set(path.join(workspaceRoot, rel), oid)
1335
+ if (oid !== undefined) projOids.set(abs(rel), oid)
1290
1336
  }
1291
1337
  // An empty slice is a directory git did not see, not an empty project:
1292
1338
  // a project has at least its package.json, tracked or untracked. A
@@ -1308,7 +1354,7 @@ export async function applyGitEnumeration(
1308
1354
  }
1309
1355
  if (workspaceWide) {
1310
1356
  const rootOids = new Map<string, string>()
1311
- for (const [rel, oid] of trusted) rootOids.set(path.join(workspaceRoot, rel), oid)
1357
+ for (const [rel, oid] of trusted) rootOids.set(abs(rel), oid)
1312
1358
  cache.set(workspaceRoot, all)
1313
1359
  cache.setOids(workspaceRoot, rootOids)
1314
1360
  cache.setWorkspaceRoot(workspaceRoot)
@@ -385,6 +385,7 @@ async function runRuntimeCommand(
385
385
  command: string,
386
386
  cwd: string,
387
387
  binDirs: readonly string[],
388
+ owner: RuntimeMemo | undefined,
388
389
  ): Promise<string> {
389
390
  const ambient = process.env['PATH']
390
391
  const prefix = binDirs.join(path.delimiter)
@@ -416,6 +417,12 @@ async function runRuntimeCommand(
416
417
  throw new UserError(`cache.inputs runtime command failed to spawn: ${command} (cwd: ${cwd})`)
417
418
  }
418
419
  liveProbes.add(proc)
420
+ let mine: Set<ReturnType<typeof Bun.spawn>> | undefined
421
+ if (owner !== undefined) {
422
+ mine = probesOf.get(owner) ?? new Set()
423
+ probesOf.set(owner, mine)
424
+ mine.add(proc)
425
+ }
419
426
  killProbesOnExit()
420
427
  let stdout, stderr, exitCode
421
428
  try {
@@ -426,6 +433,7 @@ async function runRuntimeCommand(
426
433
  ])
427
434
  } finally {
428
435
  liveProbes.delete(proc)
436
+ mine?.delete(proc)
429
437
  }
430
438
  const output = `${stdout}${stderr}`.trim()
431
439
  if (exitCode !== 0) {
@@ -449,18 +457,26 @@ async function runRuntimeCommand(
449
457
  const liveProbes = new Set<ReturnType<typeof Bun.spawn>>()
450
458
  let probeExitHooked = false
451
459
 
460
+ type RuntimeMemo = Map<string, Promise<string>>
461
+ /** The probes each run started, by the run's memo: one run's stop is not another's. */
462
+ const probesOf = new WeakMap<RuntimeMemo, Set<ReturnType<typeof Bun.spawn>>>()
463
+
452
464
  /**
453
- * Kill every runtime probe still running, with its tree. A run's stop asks
454
- * this: a Ctrl-C while a probe ran waited for the probe, or for the signal
455
- * handler's bound, about 7 s, before vx exited (C-65). Its answer is no
456
- * longer needed; its caller sees it fail and the run reads it aborted.
465
+ * Kill every runtime probe still running that these memos (one run's
466
+ * `runtimeCache` and `workspaceRuntimeCache`) started, with its tree. A
467
+ * run's stop asks this: a Ctrl-C while a probe ran waited for the probe,
468
+ * or for the signal handler's bound, about 7 s, before vx exited (C-65).
469
+ * Its answer is no longer needed; its caller sees it fail and the run
470
+ * reads it aborted. Another run in the process keeps its own.
457
471
  */
458
- export function stopRuntimeProbes(): void {
459
- for (const p of liveProbes) {
460
- try {
461
- process.kill(-p.pid, 'SIGKILL')
462
- } catch {
463
- // the group is gone
472
+ export function stopRuntimeProbes(...memos: readonly RuntimeMemo[]): void {
473
+ for (const memo of memos) {
474
+ for (const p of probesOf.get(memo) ?? []) {
475
+ try {
476
+ process.kill(-p.pid, 'SIGKILL')
477
+ } catch {
478
+ // the group is gone
479
+ }
464
480
  }
465
481
  }
466
482
  }
@@ -501,7 +517,7 @@ async function resolveRuntimeValues(
501
517
  const key = `${memoKeyPrefix}${cmd}`
502
518
  let p = memo?.get(key)
503
519
  if (p === undefined) {
504
- p = runRuntimeCommand(cmd, cwd, binDirs)
520
+ p = runRuntimeCommand(cmd, cwd, binDirs, memo)
505
521
  memo?.set(key, p)
506
522
  }
507
523
  return [cmd, await p] as [string, string]
package/src/cli/cache.ts CHANGED
@@ -45,7 +45,10 @@ export async function cacheCmd(args: readonly string[]): Promise<number> {
45
45
  case 'prune':
46
46
  return await pruneCmd(rest)
47
47
  case undefined:
48
- process.stderr.write('vx cache: missing subcommand. Try `vx cache prune`.\n')
48
+ // A bare `vx cache prune` refuses too: the hint names a whole command.
49
+ process.stderr.write(
50
+ `vx cache: missing subcommand. Try \`vx cache prune --older-than 30d\` or \`vx cache prune --max-size 1G\`${seeHelp('cache')}\n`,
51
+ )
49
52
  return 1
50
53
  default:
51
54
  process.stderr.write(
package/src/cli/show.ts CHANGED
@@ -66,10 +66,17 @@ export async function showCmd(args: readonly string[]): Promise<number> {
66
66
  const metas = await discoverCliProjects(await loadWorkspace(root, reads))
67
67
  const byName = new Map(metas.map((m) => [m.name, m]))
68
68
 
69
- // `//#task` is Turbo's root package's task: the root project here (D-39).
69
+ // `//#task` is Turbo's root package's task: the root project here (D-39),
70
+ // and `//` alone is that project, as `--filter //` reads it (D-46).
70
71
  const rootMeta = metas.find((m) => path.resolve(m.dir) === path.resolve(root))
71
- if (parsed.target?.startsWith('//#') && rootMeta !== undefined)
72
- parsed.target = `${rootMeta.name}#${parsed.target.slice(3)}`
72
+ if (parsed.target === '//' || parsed.target?.startsWith('//#')) {
73
+ if (rootMeta === undefined) {
74
+ throw new UserError(
75
+ `vx show: "${parsed.target}" names the workspace root's project, and the root is no project here`,
76
+ )
77
+ }
78
+ parsed.target = `${rootMeta.name}${parsed.target.slice(2)}`
79
+ }
73
80
  const hashAt = parsed.target?.indexOf('#') ?? -1
74
81
  const projectName =
75
82
  parsed.target === undefined
@@ -87,7 +94,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
87
94
  if (projectName !== undefined && (taskName !== undefined || byName.has(projectName))) {
88
95
  if (!byName.has(projectName)) {
89
96
  throw new UserError(
90
- `unknown project: "${projectName}"${suggest(projectName, [...byName.keys()])}`,
97
+ `vx show: unknown project: "${projectName}"${suggest(projectName, [...byName.keys()])}`,
91
98
  )
92
99
  }
93
100
  }
@@ -114,7 +121,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
114
121
  // `nx show projects` lists them; here a bare `vx show` does.
115
122
  const nx = projectName === 'projects' ? ' (`nx show projects` is `vx show` here)' : ''
116
123
  throw new UserError(
117
- `unknown project or task: "${projectName}"${nx}${suggest(projectName!, [...byName.keys(), ...names])}`,
124
+ `vx show: unknown project or task: "${projectName}"${nx}${suggest(projectName!, [...byName.keys(), ...names])}`,
118
125
  )
119
126
  }
120
127
  process.stdout.write(renderTaskAcross(root, declaring, projectName!, parsed.format))
@@ -136,7 +143,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
136
143
  const task = declaredTask(config, taskName)
137
144
  if (task === undefined) {
138
145
  throw new UserError(
139
- `unknown task: "${meta.name}#${taskName}"${suggest(taskName, Object.keys(config?.tasks ?? {}), `${meta.name}#`)}`,
146
+ `vx show: unknown task: "${meta.name}#${taskName}"${suggest(taskName, Object.keys(config?.tasks ?? {}), `${meta.name}#`)}`,
140
147
  )
141
148
  }
142
149
  process.stdout.write(renderTask(meta.name, dir, taskName, task, parsed.format))
package/src/cli/why.ts CHANGED
@@ -44,7 +44,8 @@ export const WHAT_TO_DO: Readonly<Record<string, string>> = {
44
44
  package: "the project's package.json changed (a dependency, version or script)",
45
45
  workspace:
46
46
  'a lockfile or root manifest changed, which re-keys every task; a lockfile plugin keys each project on its own dependencies',
47
- config: "the task's evaluated config changed (its vx.config or a file that imports)",
47
+ config:
48
+ "the task's evaluated config changed (its vx.config or a file it imports); `vx show <that task>` prints it as it is now",
48
49
  upstream: "a dependency's key moved; `vx why <that task>` says why",
49
50
  plugin: "a key plugin's material changed; that plugin decides what it folds",
50
51
  }
@@ -267,7 +268,9 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
267
268
  '`vx why <that task>`',
268
269
  upstream.map((n) => `\`vx why ${n}\``).join(', '),
269
270
  )
270
- : WHAT_TO_DO[k]
271
+ : k === 'config'
272
+ ? WHAT_TO_DO[k]!.replace('`vx show <that task>`', `\`vx show ${taskId}\``)
273
+ : WHAT_TO_DO[k]
271
274
  lines.push(` ${k.padEnd(kindW)} ${todo}`)
272
275
  }
273
276
  }
package/src/exec/env.ts CHANGED
@@ -71,6 +71,7 @@ export interface BuildEnvOptions {
71
71
  * (`oxlint`, `tsc`, `vitest`, …) resolve without a PM wrapper. Matches
72
72
  * vite-task's behavior — explicit per-project bin, no tree walk, so
73
73
  * sibling projects' bins stay invisible per the project-isolation rule.
74
+ * One holding `path.delimiter` is left out: PATH cannot name it.
74
75
  */
75
76
  binPaths?: readonly string[]
76
77
  }
@@ -90,8 +91,12 @@ export function buildIsolatedEnv(opts: BuildEnvOptions): NodeJS.ProcessEnv {
90
91
  out[name] = value
91
92
  }
92
93
 
93
- if (opts.binPaths && opts.binPaths.length > 0) {
94
- const prefix = opts.binPaths.join(path.delimiter)
94
+ // A directory holding the delimiter cannot be named in PATH: split, it
95
+ // became two entries naming nothing, the second RELATIVE, so resolved
96
+ // against the task's cwd. The 127 verdict says why it is missing.
97
+ const bins = opts.binPaths?.filter((dir) => !dir.includes(path.delimiter)) ?? []
98
+ if (bins.length > 0) {
99
+ const prefix = bins.join(path.delimiter)
95
100
  out['PATH'] = out['PATH'] ? `${prefix}${path.delimiter}${out['PATH']}` : prefix
96
101
  }
97
102
 
@@ -36,7 +36,7 @@ export function bindableWrites(paths: readonly string[]): string[] {
36
36
  if (process.platform !== 'linux') return [...paths]
37
37
  return unique(
38
38
  paths
39
- .filter((p) => !holdsBracket(p))
39
+ .filter((p) => !holdsGlobChar(p))
40
40
  .map((p) => {
41
41
  if (!isMountableLiteral(p)) return p
42
42
  try {
@@ -191,31 +191,66 @@ export function punchWritePaths(readPath: string, writePaths: readonly string[])
191
191
  }
192
192
 
193
193
  /** Grants already reported — once per process, not per spawn. */
194
- const warnedBracket = new Set<string>()
194
+ const warnedGlob = new Set<string>()
195
195
 
196
196
  /**
197
- * SRT drops every Linux write path holding a bracket (it reads one as a
198
- * glob, and a write path must be a path), and no spelling keeps it. Left
199
- * in, the read grants were punched around a bind that never came, the
200
- * directory vanished from the task's view, its write read "Directory
197
+ * SRT drops every Linux write path holding `[`, `]`, `*` or `?` (it reads
198
+ * one as a glob, and a write path must be a path) or a backslash (Bun's
199
+ * realpath refuses it; `bindableReads`), and no spelling keeps
200
+ * it. Left in, the read grants were punched around a bind that never came,
201
+ * the directory vanished from the task's view, its write read "Directory
201
202
  * nonexistent" with no word of the grant, and the refused write went
202
203
  * unreported, judged against it. Dropped from the binds instead, and said
203
204
  * once: the remedy is the deepest directory above it whose name holds none.
204
205
  */
205
- function holdsBracket(grant: string): boolean {
206
- const at = grant.search(/[[\]]/)
206
+ function holdsGlobChar(grant: string): boolean {
207
+ const at = srtStripped(grant).search(/[[\]*?\\]/)
207
208
  if (at === -1) return false
208
- if (!warnedBracket.has(grant)) {
209
- warnedBracket.add(grant)
209
+ if (!warnedGlob.has(grant)) {
210
+ warnedGlob.add(grant)
210
211
  process.stderr.write(
211
- `[vx] sandbox: the write grant ${grant} holds a bracket, and the Linux sandbox mounts no ` +
212
- `write path that does, so a write under it is refused. Grant the directory above it ` +
213
- `instead: ${grant.slice(0, grant.lastIndexOf(path.sep, at) + 1)}\n`,
212
+ `[vx] sandbox: the write grant ${grant} holds ${globChar(grant[at]!)}, and the Linux ` +
213
+ `sandbox mounts no write path that does, so a write under it is refused. Grant the ` +
214
+ `directory above it instead: ${grant.slice(0, grant.lastIndexOf(path.sep, at) + 1)}\n`,
214
215
  )
215
216
  }
216
217
  return true
217
218
  }
218
219
 
220
+ /** A trailing `/**` SRT strips before it asks whether a path is a glob. */
221
+ const srtStripped = (grant: string): string => grant.replace(/\/\*\*$/, '')
222
+
223
+ const globChar = (c: string): string =>
224
+ c === '[' || c === ']' ? 'a bracket' : c === '\\' ? 'a backslash' : `a ${c}`
225
+
226
+ /**
227
+ * Linux: the read grants SRT can mount as the paths they are. It reads
228
+ * one holding `*` or `?` as a glob and mounts every match, and no spelling
229
+ * makes either literal (its rewrite of each runs inside a class too), so a
230
+ * grant of `a*b.txt` granted `aXb.txt`. One holding a backslash it skips:
231
+ * Bun's `realpathSync` throws ENOENT on such a path (Node's does not), and
232
+ * SRT mounts no path it cannot resolve. Left out, so a read of it is
233
+ * refused and reported, and said once; a bracket has a spelling
234
+ * (`literalReadPaths`).
235
+ */
236
+ export function bindableReads(paths: readonly string[]): string[] {
237
+ if (process.platform !== 'linux') return [...paths]
238
+ return paths.filter((grant) => {
239
+ const at = srtStripped(grant).search(/[*?\\]/)
240
+ if (at === -1) return true
241
+ if (!warnedGlob.has(grant)) {
242
+ warnedGlob.add(grant)
243
+ process.stderr.write(
244
+ `[vx] sandbox: the read grant ${grant} holds ${globChar(grant[at]!)}, which the Linux ` +
245
+ `sandbox ${grant[at] === '\\' ? 'cannot resolve' : 'reads as a pattern that also matches its siblings'}, so it is not granted. ` +
246
+ `Rename it, or grant the directory above it: ` +
247
+ `${grant.slice(0, grant.lastIndexOf(path.sep, at) + 1)}\n`,
248
+ )
249
+ }
250
+ return false
251
+ })
252
+ }
253
+
219
254
  /**
220
255
  * Merge the orchestrator-provided baseline (declared inputs / outputs /
221
256
  * workspace-root anchor) with the user's resolved sandbox block to
@@ -63,7 +63,7 @@ import {
63
63
  UserError,
64
64
  xxh3hex,
65
65
  } from '../util/index.js'
66
- import { bindableWrites, buildCustomConfig, scratchWrites } from './sandbox-binds.js'
66
+ import { bindableReads, bindableWrites, buildCustomConfig, scratchWrites } from './sandbox-binds.js'
67
67
  import {
68
68
  atOrUnder,
69
69
  isMountableLiteral,
@@ -1167,7 +1167,8 @@ function asksUnixSockets(c: Pick<ResolvedSandboxConfig, 'unixSockets'>): boolean
1167
1167
  * but SRT reads any holding `[` as a glob, where a bracket opens a class:
1168
1168
  * a route granted as `pages/\[id\].tsx` was never mounted (its denial
1169
1169
  * unreported, a listed grant), and a workspace under `[ws]/` was never
1170
- * walled. `[[]` is a class of one `[`; a lone `]` is plain text to it.
1170
+ * walled. `[[]` is a class of one `[`; a lone `]` is plain text to it. A
1171
+ * `*` or `?` has no such spelling: `bindableReads` leaves its grant out.
1171
1172
  */
1172
1173
  function literalReadPaths(
1173
1174
  config: Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2],
@@ -1179,8 +1180,9 @@ function literalReadPaths(
1179
1180
  ...config,
1180
1181
  filesystem: {
1181
1182
  ...fs,
1183
+ // A `*` or `?` in a deny path matches its siblings too: a wider wall.
1182
1184
  denyRead: escape(fs.denyRead),
1183
- ...(fs.allowRead !== undefined ? { allowRead: escape(fs.allowRead) } : {}),
1185
+ ...(fs.allowRead !== undefined ? { allowRead: escape(bindableReads(fs.allowRead)) } : {}),
1184
1186
  },
1185
1187
  }
1186
1188
  }
@@ -6,6 +6,7 @@
6
6
 
7
7
  import path from 'node:path'
8
8
  import { absolutize, atOrUnder, isUnderAny, localBindingOn, toRealPath } from './sandbox-paths.js'
9
+ import { bindableReads } from './sandbox-binds.js'
9
10
  import type {
10
11
  ResolvedSandboxConfig,
11
12
  SandboxedRunArgs,
@@ -274,7 +275,10 @@ export async function parseStraceViolations(
274
275
  // real paths (see `canonicalBaselines`), so comparing a link-path here
275
276
  // would report an explicitly-allowed read as a violation.
276
277
  const allowAbs = new Set<string>(
277
- [...baselines.allowRead, ...args.config.allowRead].map((p) => toRealPath(absolutize(p))),
278
+ // A grant SRT could not mount (`bindableReads`) permits nothing.
279
+ [...baselines.allowRead, ...bindableReads(args.config.allowRead)].map((p) =>
280
+ toRealPath(absolutize(p)),
281
+ ),
278
282
  )
279
283
  const denyAnchors = baselines.denyRead.map((p) => toRealPath(absolutize(p)))
280
284
 
@@ -347,17 +347,15 @@ async function countLoadableTasks(
347
347
  } catch (err) {
348
348
  // Counted as zero, and named: the loader's message opens with the
349
349
  // absolute path — bare for a schema refusal, behind `Project config`
350
- // for an import that failed — which the row carries
351
- // workspace-relative instead, once (the second form named the file
352
- // twice, 2026-09-16).
350
+ // for an import that failed, and with `:line:col` after it for a
351
+ // syntax error — which the row carries workspace-relative instead,
352
+ // once (the second form named the file twice, 2026-09-16; the third
353
+ // still did).
353
354
  const raw = err instanceof Error ? err.message : String(err)
354
- let message = raw
355
- for (const prefix of [`${meta.configPath}: `, `Project config ${meta.configPath}: `]) {
356
- if (raw.startsWith(prefix)) {
357
- message = raw.slice(prefix.length)
358
- break
359
- }
360
- }
355
+ const bare = raw.startsWith('Project config ') ? raw.slice('Project config '.length) : raw
356
+ const message = bare.startsWith(`${meta.configPath}:`)
357
+ ? bare.slice(meta.configPath.length + 1).trimStart()
358
+ : raw
361
359
  errors.push({
362
360
  path: path.relative(root, meta.configPath).split(path.sep).join('/'),
363
361
  message,
@@ -449,8 +449,12 @@ const executorPlugin = new WeakMap<TaskExecutor, string>()
449
449
  */
450
450
  export function nameExecutorFailure(executor: TaskExecutor, err: unknown): unknown {
451
451
  if (!(err instanceof Error) || !executorPlugin.has(executor)) return err
452
+ // Once: one error an executor rejects several tasks with (a failed
453
+ // connection it memoized) reaches here once per task (C-74).
454
+ const prefix = `${executorLabel(executor)} failed in execute: `
455
+ if (err.message.startsWith(prefix)) return err
452
456
  try {
453
- err.message = `${executorLabel(executor)} failed in execute: ${err.message}`
457
+ err.message = `${prefix}${err.message}`
454
458
  } catch {
455
459
  // A frozen error keeps its own words.
456
460
  }
@@ -538,7 +538,7 @@ async function runOnBus(
538
538
  // ends after it is the stop's own kill, and was named a crash (item 1061).
539
539
  let endedBeforeStop: ReadonlySet<ReturnType<typeof Bun.spawn>> | undefined
540
540
  const onAbort = (): void => {
541
- stopRuntimeProbes()
541
+ stopRuntimeProbes(hashCache.runtime, hashCache.workspaceRuntime)
542
542
  endedBeforeStop = new Set([...persistentRegistry.values()].filter(hasEnded))
543
543
  aborting = terminateChildren(
544
544
  () => [...liveChildren, ...persistentRegistry.values()],
@@ -670,6 +670,25 @@ async function runOnBus(
670
670
  }
671
671
 
672
672
  const sandboxArmer = prepareSandbox(nodes.values())
673
+ // The probe is ~220 ms of spawns, paid by the first sandboxed task to
674
+ // execute. A task no cache can answer (no `cache`, reads off, or
675
+ // persistent) is sure to: start it now, under the classify and the
676
+ // upstream work. The task's own `arm()` rethrows a refusal, and the
677
+ // end of the run waits for it: one that landed after the reset left
678
+ // SRT's proxies holding the process open.
679
+ const surelyRuns = (n: TaskNode): boolean =>
680
+ n.config.exec?.persistent !== undefined ||
681
+ n.config.cache === undefined ||
682
+ !(policy.localRead || policy.remoteRead)
683
+ const sandboxed =
684
+ sandboxArmer === null
685
+ ? []
686
+ : [...nodes.values()].filter((n) => n.config.exec?.sandbox !== undefined)
687
+ let prewarming: Promise<void> | undefined
688
+ const prewarm = (): void => {
689
+ prewarming ??= sandboxArmer?.arm().catch(() => {})
690
+ }
691
+ if (sandboxed.some(surelyRuns)) prewarm()
673
692
  const keyed = keyedProjects(nodes, prepared.keyOnly)
674
693
  const outputDirSnapshots: OutputDirSnapshot[] = []
675
694
  // Saves run off the execution slot, twice the cap at once (memory:
@@ -771,6 +790,8 @@ async function runOnBus(
771
790
  hashCache,
772
791
  concurrency,
773
792
  })
793
+ // A confirmed miss runs too.
794
+ if (sandboxed.some((n) => shortCircuit.preProbed.get(n.id)?.hit === null)) prewarm()
774
795
  }
775
796
 
776
797
  // Whether this task runs behind a failure (`continueMode: 'always'`
@@ -1127,6 +1148,7 @@ async function runOnBus(
1127
1148
  // Tear down SRT's network bridge + (on macOS) log monitor. No-op if
1128
1149
  // no task was sandboxed; otherwise SRT keeps proxy servers alive and
1129
1150
  // the next vx run would init on top of stale state.
1151
+ await prewarming
1130
1152
  if (sandboxArmer?.armed) {
1131
1153
  try {
1132
1154
  await resetSandbox()
@@ -104,7 +104,8 @@ export function sandboxRunUnion(nodes: Iterable<TaskNode>): SandboxRunUnion | nu
104
104
  * Linux, the runtime module's own load included) even when every task was
105
105
  * a cache hit and nothing executed; measured 2026-09-10 on this repo's
106
106
  * own warm gate: `classify + probe` 288 ms of a 798 ms run. A hit needs
107
- * no sandbox, so a hit pays nothing.
107
+ * no sandbox, so a hit pays nothing. `run()` arms early only when a
108
+ * sandboxed task is sure to execute (C-76).
108
109
  *
109
110
  * The domain union is computed here from every sandboxed node, because
110
111
  * SRT runs ONE filtering proxy per run and checks every request against
@@ -190,13 +191,23 @@ export async function sandboxRequestFor(
190
191
  // The runtime reads a path holding a bracket as a pattern: on Linux it
191
192
  // mounts no such write path (B-60); seatbelt's rules compile it as a
192
193
  // character class, so vx's own workspace wall matched nothing (B-65).
194
+ // A `*` or `?` is one too, and its grants matched the project's siblings.
193
195
  const home = toRealPath(node.projectDir)
194
- if (process.platform !== 'win32' && /[[\]]/.test(home)) {
196
+ if (process.platform !== 'win32' && /[[\]*?]/.test(home)) {
195
197
  throw new UserError(
196
- `exec.sandbox: ${home} holds a bracket ([ or ]), which the sandbox runtime reads as a ` +
198
+ `exec.sandbox: ${home} holds [, ], * or ?, which the sandbox runtime reads as a ` +
197
199
  `pattern, not a name — rename the directory, or run the task without exec.sandbox`,
198
200
  )
199
201
  }
202
+ // Bun's realpath throws ENOENT on a path holding a backslash, and SRT
203
+ // mounts no path it cannot resolve: the task saw no project and ran in
204
+ // $HOME.
205
+ if (process.platform === 'linux' && home.includes('\\')) {
206
+ throw new UserError(
207
+ `exec.sandbox: ${home} holds a backslash, which the Linux sandbox cannot resolve, so ` +
208
+ `it mounts none of the project — rename the directory, or run the task without exec.sandbox`,
209
+ )
210
+ }
200
211
  const depDirs = [
201
212
  path.join(node.projectDir, 'node_modules'),
202
213
  path.join(workspaceRoot, 'node_modules'),
@@ -43,6 +43,10 @@ export function shellVerdict(args: {
43
43
  if (args.code === 126) {
44
44
  return `[vx] exit 126 is the shell's "found but cannot execute": ${what} is not executable or is a directory — chmod +x it`
45
45
  }
46
+ const unnamed = args.bins.find((dir) => dir.includes(path.delimiter))
47
+ if (unnamed !== undefined) {
48
+ return `[vx] exit 127 is the shell's "command not found": ${what} is not on this task's PATH — ${unnamed} holds "${path.delimiter}", which PATH reads as a separator, so vx cannot put it there; move the workspace to a path without "${path.delimiter}"`
49
+ }
46
50
  return `[vx] exit 127 is the shell's "command not found": ${what} is not on this task's PATH — vx puts ${args.bins.join(' and ')} first and never a sibling project's bin; install it in this package or at the workspace root`
47
51
  }
48
52
 
@@ -272,7 +272,8 @@ export function formatSummarySection(
272
272
  /**
273
273
  * `42 tasks · 38 cached (90%) · 3.2s`, `2 tasks · all cached · 23ms`,
274
274
  * with the failures first when there are any. Cached is every hit (up to
275
- * date, restored locally or remotely) over every task that has a cache.
275
+ * date, restored locally or remotely) over every task that consulted a
276
+ * cache: a skipped or not-yet-run task never did.
276
277
  */
277
278
  function resultLine(
278
279
  stats: SummaryStats,
@@ -283,9 +284,11 @@ function resultLine(
283
284
  const parts = [`${stats.total} task${stats.total === 1 ? '' : 's'}`]
284
285
  if (stats.failed > 0) parts.push(paint(ERROR, `${stats.failed} failed`, colors, { bold: true }))
285
286
  // A task with no `cache` block could never hit: it is counted apart, not
286
- // as a miss, or `vx run dev` would read "0 cached (0%)".
287
+ // as a miss, or `vx run dev` would read "0 cached (0%)". A skipped task
288
+ // never asked either, and counted as a miss it put "0 cached (0%)" on a
289
+ // run in which no task has a cache.
287
290
  const noCache = stats.noCache ?? 0
288
- const cacheable = stats.total - noCache
291
+ const cacheable = stats.total - noCache - stats.skipped - (stats.left ?? 0)
289
292
  if (cacheable > 0)
290
293
  parts.push(
291
294
  hits === cacheable
@@ -754,7 +754,22 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
754
754
  'one task per configuration (`build:production`, with its own `exec.command`)',
755
755
  },
756
756
  ],
757
- [EXEC_FIELDS, { cmd: '`command`', script: '`command`' }],
757
+ [
758
+ EXEC_FIELDS,
759
+ {
760
+ cmd: '`command`',
761
+ script: '`command`',
762
+ // Nx run-commands' options and shell-runner habits, refused with no
763
+ // word on where they go (D-100).
764
+ cwd: '`cd <dir> && …` in `command`: a task runs in its project directory',
765
+ args: 'the arguments written into `command`, or after `--` on `vx run`',
766
+ commands: 'one `command` (`a && b`, or `a & b; wait` to run them at once), or one task each',
767
+ parallel: 'one task each, which vx runs at once, or `a & b; wait` in `command`',
768
+ shell: 'no field: `command` always runs in a shell',
769
+ interactive:
770
+ 'a command run outside vx: a task never reads the terminal (its stdin is EOF, or a pipe vx holds under `persistent`)',
771
+ },
772
+ ],
758
773
  // A package's turbo.json and Nx's project.json keys, written into a
759
774
  // project's vx.config (D-56). `tags` had read as a typo of `tasks`.
760
775
  [
@@ -806,6 +821,24 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
806
821
  defaultBase: '`affectedBase`',
807
822
  tasksRunnerOptions: 'a cache plugin in `plugins` (`nxCache()` from `@vzn/vx-migrate`)',
808
823
  globalPassThroughEnv: '`exec.env.passThrough` on the tasks it passes to',
824
+ // pnpm-workspace.yaml's, Lerna's and the package.json's own keys
825
+ // pasted into vx.workspace: refused with no word on where vx reads
826
+ // them (D-98).
827
+ packages:
828
+ "`workspaces` in package.json or pnpm-workspace.yaml's `packages`, where vx reads the members",
829
+ workspaces:
830
+ "`workspaces` in package.json or pnpm-workspace.yaml's `packages`, where vx reads the members",
831
+ projects:
832
+ "`workspaces` in package.json or pnpm-workspace.yaml's `packages`, where vx reads the members",
833
+ ignore:
834
+ 'a `!` pattern in the workspace globs (package.json `workspaces` or pnpm-workspace.yaml)',
835
+ catalog:
836
+ "pnpm-workspace.yaml's or package.json's `catalog`, where the package manager reads it",
837
+ npmClient: "no field: a task's command runs as written, with no package manager in between",
838
+ cache:
839
+ "each task's `cache` in its project's vx.config (a task caches only when it declares one)",
840
+ remote: 'a cache plugin in `plugins`',
841
+ env: '`cache.inputs.env` on the tasks it keys, or `exec.env` on the tasks it passes to',
809
842
  },
810
843
  ],
811
844
  ])
@@ -343,6 +343,48 @@ function cdsToMembers(script: string, rootDir: string, memberDirs: readonly stri
343
343
  return false
344
344
  }
345
345
 
346
+ /** The workspace flags that move a package manager off the package it runs in. */
347
+ const ELSEWHERE_FLAG = /^(-r|--recursive|--filter|-F|--workspaces|-ws|--workspace)(=|$)/
348
+ const DIR_FLAG = /^(-C|--dir|--cwd|--prefix)(?:=(.*))?$/
349
+
350
+ /**
351
+ * The part of a member's script that runs another member's work: pinia's
352
+ * online-playground `build` is `pnpm -C ../pinia run build && vite build`,
353
+ * and mapped verbatim it built pinia again beside pinia's own task, writing
354
+ * the dist its siblings read. `yarn workspace <name>`, a runner, `pnpm -r`
355
+ * / `--filter`, and a `cd` / `-C` / `--cwd` / `--prefix` whose innermost
356
+ * member is another one count; a fixture inside the member, or the root,
357
+ * does not.
358
+ */
359
+ function siblingRun(script: string, dir: string, others: readonly string[]): string | undefined {
360
+ const inside = (a: string, b: string): boolean => a === b || a.startsWith(b + path.sep)
361
+ const intoOther = (target: string): boolean => {
362
+ const t = path.resolve(dir, target.replace(/^(["'])(.*)\1$/, '$2'))
363
+ if (/[$`~*?]/.test(target)) return false
364
+ const owner = [dir, ...others]
365
+ .filter((d) => inside(t, d))
366
+ .sort((x, y) => y.length - x.length)[0]
367
+ return owner !== undefined && owner !== dir
368
+ }
369
+ for (const segment of script.split(/&&|\|\||[;|()]/)) {
370
+ const s = segment.trim()
371
+ if (RUNS_MEMBERS.test(` ${s}`)) return s
372
+ for (const m of s.matchAll(CD)) if (intoOther(m[1]!)) return s
373
+ const words = s.split(/\s+/)
374
+ const at = words.findIndex((w) => /^(pnpm|pn|npm|yarn|bun)$/.test(w))
375
+ if (at < 0) continue
376
+ for (let i = at + 1; i < words.length; i++) {
377
+ const w = words[i]!
378
+ if (ELSEWHERE_FLAG.test(w)) return s
379
+ const flag = DIR_FLAG.exec(w)
380
+ if (flag === null) continue
381
+ const target = flag[2] ?? words[++i]
382
+ if (target !== undefined && intoOther(target)) return s
383
+ }
384
+ }
385
+ return undefined
386
+ }
387
+
346
388
  /** A package manager running a script by name: `pnpm build`, `npm run x`, `bun run x`. */
347
389
  const RUNS_SCRIPT =
348
390
  /(?:^|[\s;&|(])(?:pnpm|pn|npm|yarn|bun)\s+(?:run(?:-script)?\s+)?([^\s;&|()'"-][^\s;&|()'"]*)/g
@@ -589,6 +631,19 @@ export function migrateScripts(
589
631
  `${hooksBy} ran ${hooks.map((h) => `\`${h}\``).join(' and ')} around this script without being asked; folded into the command in that order`,
590
632
  )
591
633
  }
634
+ const sibling =
635
+ meta === rootMeta
636
+ ? undefined
637
+ : siblingRun(
638
+ own,
639
+ meta.dir,
640
+ metas.filter((m) => m !== root && m !== meta).map((m) => m.dir),
641
+ )
642
+ if (sibling !== undefined) {
643
+ todos.push(
644
+ `\`${sibling}\` runs another member's work outside the graph, again beside that member's own task: name that task under dependsOn (\`<member>#<task>\`) and drop it from the command`,
645
+ )
646
+ }
592
647
  const exec: Record<string, unknown> = { command }
593
648
  const npm = npmEnv(command, name, hooks.length > 0)
594
649
  if (Object.keys(npm.define).length > 0) exec['env'] = { define: npm.define }
@@ -541,13 +541,38 @@ const WATCHED_BUILTINS: ReadonlyArray<readonly [string, object]> = [
541
541
  ['Promise.prototype', Promise.prototype],
542
542
  ]
543
543
 
544
- type BuiltinSnapshot = ReadonlyArray<ReadonlyMap<PropertyKey, PropertyDescriptor>>
544
+ interface OwnProperties {
545
+ keys: PropertyKey[]
546
+ descriptors: PropertyDescriptor[]
547
+ byKey: ReadonlyMap<PropertyKey, PropertyDescriptor>
548
+ }
549
+
550
+ type BuiltinSnapshot = readonly OwnProperties[]
545
551
 
546
552
  function builtinSnapshot(): BuiltinSnapshot {
547
- return WATCHED_BUILTINS.map(
548
- ([, proto]) =>
549
- new Map(Reflect.ownKeys(proto).map((k) => [k, Object.getOwnPropertyDescriptor(proto, k)!])),
550
- )
553
+ return WATCHED_BUILTINS.map(([, proto]) => {
554
+ const keys = Reflect.ownKeys(proto)
555
+ const descriptors = keys.map((k) => Object.getOwnPropertyDescriptor(proto, k)!)
556
+ return { keys, descriptors, byKey: new Map(keys.map((k, j) => [k, descriptors[j]!])) }
557
+ })
558
+ }
559
+
560
+ /**
561
+ * The same keys in the same order with the same descriptors: nothing to
562
+ * put back. Read by position, without the by-key lookups and the second
563
+ * pass for deleted keys, it is the cold path's common case once per
564
+ * evaluated config: the full check was ~0.1 ms a config, ~100 ms of a
565
+ * 1,000-config cold load.
566
+ */
567
+ function unchanged(proto: object, keys: readonly PropertyKey[], was: OwnProperties): boolean {
568
+ if (keys.length !== was.keys.length) return false
569
+ for (let j = 0; j < keys.length; j++) {
570
+ const key = keys[j]!
571
+ if (key !== was.keys[j]) return false
572
+ if (!sameDescriptor(was.descriptors[j]!, Object.getOwnPropertyDescriptor(proto, key)!))
573
+ return false
574
+ }
575
+ return true
551
576
  }
552
577
 
553
578
  /** Loads run together, so the config named is the one whose load saw the change. */
@@ -565,8 +590,10 @@ function builtinsChanged(changed: readonly string[], configPath?: string): UserE
565
590
  function restoreBuiltins(before: BuiltinSnapshot): string[] {
566
591
  const changed: string[] = []
567
592
  WATCHED_BUILTINS.forEach(([name, proto], i) => {
568
- const was = before[i]!
569
- for (const key of Reflect.ownKeys(proto)) {
593
+ const keys = Reflect.ownKeys(proto)
594
+ if (unchanged(proto, keys, before[i]!)) return
595
+ const was = before[i]!.byKey
596
+ for (const key of keys) {
570
597
  const prior = was.get(key)
571
598
  const now = Object.getOwnPropertyDescriptor(proto, key)!
572
599
  if (prior !== undefined && sameDescriptor(prior, now)) continue