@vzn/vx 0.0.355 → 0.0.357

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.355",
3
+ "version": "0.0.357",
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.355",
25
- "@vzn/vx-linux-arm64": "0.0.355",
26
- "@vzn/vx-darwin-x64": "0.0.355",
27
- "@vzn/vx-darwin-arm64": "0.0.355"
24
+ "@vzn/vx-linux-x64": "0.0.357",
25
+ "@vzn/vx-linux-arm64": "0.0.357",
26
+ "@vzn/vx-darwin-x64": "0.0.357",
27
+ "@vzn/vx-darwin-arm64": "0.0.357"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -122,10 +122,6 @@ const drain = async (body: AsyncIterable<Uint8Array>): Promise<void> => {
122
122
  }
123
123
  }
124
124
 
125
- const WIN32 = process.platform === 'win32'
126
- // A Windows link target may be spelled with either separator.
127
- const LINK_SEP = WIN32 ? /[\\/]/ : '/'
128
-
129
125
  /**
130
126
  * Sidecar shape. `files` maps an entry name to `[mode, mtimeMs]` — a
131
127
  * pair, not an object, because the sidecar is real entropy inside an
@@ -271,9 +267,7 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
271
267
  `output ${shown} is not a regular file (a symlink to a directory?): vx stores regular files only — emit a file there, or narrow cache.outputs.files to the files the task produces, or take it back with a '!' entry`,
272
268
  )
273
269
  }
274
- // Windows reports 0o666 (0o444 read-only) and never an exec bit: a
275
- // Linux restore of that would be world-writable.
276
- const mode = WIN32 ? 0o644 : st.mode & 0o777
270
+ const mode = st.mode & 0o777
277
271
  meta.files[name] = [mode, Math.floor(st.mtimeMs)]
278
272
  return {
279
273
  name,
@@ -788,9 +782,7 @@ class Extractor {
788
782
  let n = 0
789
783
  for (const s of this.staged) {
790
784
  const [mode, mtimeMs] = metaFor(s.name)
791
- // Windows has no mode bits: chmod there only sets the read-only
792
- // attribute, and a read-only restore refuses the next rename over it.
793
- if ((mode & 0o777) !== createdMode && !WIN32) chmodSync(s.tmp, mode & 0o777)
785
+ if ((mode & 0o777) !== createdMode) chmodSync(s.tmp, mode & 0o777)
794
786
  if (mtimeMs !== undefined) {
795
787
  // A Date, not seconds: Bun reads a negative number of seconds as
796
788
  // "now", and a Date before 1970 as itself (Bun 1.4.2).
@@ -904,7 +896,7 @@ async function resolveThrough(p: string): Promise<string> {
904
896
  at = path.parse(link).root
905
897
  rest = link.slice(at.length)
906
898
  }
907
- pending.push(...rest.split(LINK_SEP).filter(Boolean).reverse())
899
+ pending.push(...rest.split('/').filter(Boolean).reverse())
908
900
  }
909
901
  return at
910
902
  }
@@ -2026,8 +2026,8 @@ const SQLITE_FCNTL_PERSIST_WAL = 10
2026
2026
  * Close for real. A plain `close()` leaves the connection open while any
2027
2027
  * statement from `db.prepare()` lives (bun:sqlite 1.4.2 defers it, as
2028
2028
  * `sqlite3_close_v2` does), so `cache.db` and its `-wal` and `-shm` stayed
2029
- * open after `Cache.close()`: a leaked descriptor per run for an embedder,
2030
- * and on Windows a cache directory nothing could delete (O-10).
2029
+ * open after `Cache.close()`: a leaked descriptor per run for an embedder
2030
+ * (O-10).
2031
2031
  * `close(true)` finalizes them and closes.
2032
2032
  */
2033
2033
  function closeDb(db: Database): void {
@@ -22,7 +22,6 @@ import { rm, rmdir } from 'node:fs/promises'
22
22
  import type { CacheInputs } from '../config.js'
23
23
  import {
24
24
  asTrees,
25
- executablePath,
26
25
  isExecutableMissing,
27
26
  isLiteralPattern,
28
27
  normalizeGlob,
@@ -394,17 +393,15 @@ async function runRuntimeCommand(
394
393
  // vx's own `sh`, resolved on its PATH before the probe's: Bun.spawn looks
395
394
  // a bare name up on the child's PATH, which leads with the project's
396
395
  // `node_modules/.bin`, so a dependency's `sh` bin ran every probe (J-69).
397
- const argv =
398
- process.platform === 'win32' ? shellArgv(command) : [executablePath('sh'), '-c', command]
399
- proc = Bun.spawn(argv, {
396
+ proc = Bun.spawn(shellArgv(command), {
400
397
  cwd,
401
398
  env: { ...process.env, PATH: ambient ? `${prefix}${path.delimiter}${ambient}` : prefix },
402
399
  stdin: 'ignore',
403
400
  stdout: 'pipe',
404
401
  stderr: 'pipe',
405
402
  // Its own group, so the probe's whole tree can be taken down with vx
406
- // (`killProbesOnExit`). Windows has no groups.
407
- detached: process.platform !== 'win32',
403
+ // (`killProbesOnExit`).
404
+ detached: true,
408
405
  })
409
406
  } catch (err) {
410
407
  // The probe runs through `sh -c` like a task: a box without sh names
package/src/cli/help.ts CHANGED
@@ -61,7 +61,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
61
61
  ' foo, @scope/*, ./packages/foo, foo..., ...foo,',
62
62
  ' foo^..., !foo, [<since>]',
63
63
  ' --affected[=<base>] Sugar for --filter "...[<base>]". Default base =',
64
- ' affectedBase, else origin/HEAD, else HEAD~1.',
64
+ ' affectedBase, else origin/HEAD, else main or',
65
+ ' master, else HEAD~1.',
65
66
  " pkg#task Run a specific project's task directly.",
66
67
  '',
67
68
  'Execution (for run):',
package/src/cli/init.ts CHANGED
@@ -193,7 +193,7 @@ async function adopt(
193
193
  ? ''
194
194
  : `${cache}(): ${remote}, so vx shares that remote cache (inert where the variable is unset).\n`
195
195
  process.stdout.write(
196
- `vx init: ${source} found — ${runner}() from @vzn/vx-migrate runs this repo as it is; nothing else written.\n` +
196
+ `vx init: ${source} found — ${runner}() from @vzn/vx-migrate, a temporary start until bunx @vzn/vx-migrate writes native config; nothing else written.\n` +
197
197
  `${wrote}\n${cacheLine}\nnext: ${adoptionNext(root, runner, source)}\n`,
198
198
  )
199
199
  return 0
@@ -21,11 +21,10 @@ const REPO = 'vznjs/vx'
21
21
 
22
22
  /**
23
23
  * A Bun standalone-binary path lives under the bunfs virtual root.
24
- * Marker differs by platform / Bun version: `/$bunfs/...` (posix) and
25
- * `B:\~BUN\...` or `B:/~BUN/...` (windows).
24
+ * Marker: `/$bunfs/...`.
26
25
  */
27
26
  export function isBunfsPath(p: string): boolean {
28
- return p.startsWith('/$bunfs') || p.startsWith('B:\\~BUN') || p.startsWith('B:/~BUN')
27
+ return p.startsWith('/$bunfs')
29
28
  }
30
29
 
31
30
  /**
package/src/exec/env.ts CHANGED
@@ -32,22 +32,6 @@ export const ESSENTIAL_ENV: readonly string[] = [
32
32
  'NO_COLOR',
33
33
  'CI',
34
34
  'NODE_OPTIONS',
35
- 'SYSTEMROOT',
36
- 'APPDATA',
37
- 'LOCALAPPDATA',
38
- 'PROGRAMDATA',
39
- 'PROGRAMFILES',
40
- 'PROGRAMFILES(X86)',
41
- 'COMSPEC',
42
- 'PATHEXT',
43
- // Read on Windows by os.homedir(), npm and git, which break without them.
44
- 'SYSTEMDRIVE',
45
- 'WINDIR',
46
- 'USERPROFILE',
47
- 'HOMEDRIVE',
48
- 'HOMEPATH',
49
- 'NUMBER_OF_PROCESSORS',
50
- 'PROCESSOR_ARCHITECTURE',
51
35
  ]
52
36
 
53
37
  /**
@@ -15,25 +15,6 @@ import { executablePath, procfsIsOwn } from '../util/index.js'
15
15
 
16
16
  export type Child = ReturnType<typeof Bun.spawn>
17
17
 
18
- const WIN32 = process.platform === 'win32'
19
-
20
- /**
21
- * Windows has no process groups (`kill(-pid)` is ESRCH, which read as
22
- * "gone" and killed nothing) and no polite signal a console task hears
23
- * from another process, so every stop is `taskkill /T /F`: the child and
24
- * every descendant it still parents. A grandchild whose parent already
25
- * exited is out of the tree's reach (docs/design/windows-2026-09.md).
26
- */
27
- function killTreeWin32(child: Child): void {
28
- try {
29
- Bun.spawn(['taskkill', '/T', '/F', '/PID', String(child.pid)], {
30
- stdio: ['ignore', 'ignore', 'ignore'],
31
- })
32
- } catch {
33
- child.kill()
34
- }
35
- }
36
-
37
18
  /**
38
19
  * A child whose polite signals go down a pipe instead of to its group: a
39
20
  * Linux sandboxed task. The group there is bwrap's, whose monitor dies of
@@ -97,8 +78,6 @@ const GUARD_SCRIPT = [
97
78
  function startGuard(): void {
98
79
  if (guardFd !== undefined) return
99
80
  guardFd = null
100
- // The guard is an `sh` script over process groups; Windows has neither.
101
- if (process.platform === 'win32') return
102
81
  try {
103
82
  const guard = Bun.spawn([executablePath('sh'), '-c', GUARD_SCRIPT], {
104
83
  argv0: 'vx-group-guard',
@@ -227,7 +206,6 @@ export function killTree(child: Child, signal: 'SIGINT' | 'SIGTERM' | 'SIGKILL')
227
206
  // A pid of 0 would name OUR group (kill(0)): a child that never
228
207
  // spawned has nothing to kill.
229
208
  if (!(child.pid > 0) || goneGroups.has(child)) return
230
- if (WIN32) return killTreeWin32(child)
231
209
  const fd = signal === 'SIGKILL' ? undefined : channels.get(child)
232
210
  if (fd !== undefined) {
233
211
  try {
@@ -297,8 +275,6 @@ export async function untilGroupsGone(
297
275
  */
298
276
  function groupAlive(child: Child): boolean {
299
277
  if (!(child.pid > 0) || goneGroups.has(child)) return false
300
- // No groups: the tree kill is `taskkill /T`, and the leader is what is left to ask.
301
- if (WIN32) return child.exitCode === null && child.signalCode === null
302
278
  try {
303
279
  process.kill(-child.pid, 0)
304
280
  } catch (err) {
@@ -4,7 +4,7 @@
4
4
  // plugin claims the task, so it sits at the TAIL of every executor list
5
5
  // (owner, 2026-09-05). An executor plugin places work ELSEWHERE; its
6
6
  // absence is "here".
7
- import { runCommand, signalExitCode } from './runner.js'
7
+ import { runCommand, signalExitCode, stopSignal } from './runner.js'
8
8
  import { runSandboxed } from './sandbox-runtime.js'
9
9
  import type { TaskExecutor } from './executor.js'
10
10
 
@@ -29,8 +29,7 @@ export function localExecutor(): TaskExecutor {
29
29
  // runs after the teardown swept the run's children, and held the run
30
30
  // to the signal's bound.
31
31
  if (req.signal?.aborted === true) {
32
- const reason: unknown = req.signal.reason
33
- const signal = reason === 'SIGINT' || reason === 'SIGHUP' ? reason : 'SIGTERM'
32
+ const signal = stopSignal(req.signal.reason)
34
33
  const exitCode = signalExitCode(signal)
35
34
  return { exitCode, durationMs: 0, stdout: '', stderr: '', signal, violations: [] }
36
35
  }
@@ -4,7 +4,7 @@
4
4
  // process exits. cpuMs / peakRssBytes are then surfaced on RunResult and
5
5
  // folded into the v11 `runs` table by the orchestrator.
6
6
 
7
- import { existsSync, readFileSync } from 'node:fs'
7
+ import { existsSync } from 'node:fs'
8
8
  import { constants as osConstants } from 'node:os'
9
9
  import {
10
10
  shellArgv,
@@ -25,9 +25,8 @@ import {
25
25
  untilGroupsGone,
26
26
  } from './kill-tree.js'
27
27
 
28
- const WIN32 = process.platform === 'win32'
29
- /** `sh` names itself in its errors; `bun exec` on Windows keeps its own. */
30
- const SH_ARGV0 = WIN32 ? {} : { argv0: 'sh' }
28
+ /** `sh` names itself in its errors. */
29
+ const SH_ARGV0 = { argv0: 'sh' }
31
30
 
32
31
  export interface RunResult {
33
32
  exitCode: number
@@ -224,8 +223,6 @@ const SHELL_BUILTINS = new Set([
224
223
  * the shell.
225
224
  */
226
225
  export function execWrap(command: string): string {
227
- // Bun's shell, the task shell on Windows, has no `exec`.
228
- if (WIN32) return command
229
226
  const first = execWord(command)
230
227
  return first === undefined ? command : `exec ${command}`
231
228
  }
@@ -238,9 +235,23 @@ export function execWrap(command: string): string {
238
235
  */
239
236
  export function execWord(command: string): string | undefined {
240
237
  if (SHELL_CONTROL.test(command)) return undefined
241
- const first = command.trimStart().split(/\s+/)[0] ?? ''
242
- if (first === '' || first.includes('=') || SHELL_BUILTINS.has(first)) return undefined
243
- return first
238
+ // The word as the shell reads it: quotes group and are removed, so
239
+ // `"./my tool.sh"` names one file (the verdict looked for `"./my`).
240
+ const raw = command.trimStart()
241
+ let first = ''
242
+ let quote = ''
243
+ let i = 0
244
+ for (; i < raw.length; i++) {
245
+ const c = raw[i]!
246
+ if (quote !== '') {
247
+ if (c === quote) quote = ''
248
+ else first += c
249
+ } else if (c === "'" || c === '"') quote = c
250
+ else if (/\s/.test(c)) break
251
+ else first += c
252
+ }
253
+ if (quote !== '' || first === '' || raw.slice(0, i).includes('=')) return undefined
254
+ return SHELL_BUILTINS.has(first) ? undefined : first
244
255
  }
245
256
 
246
257
  /**
@@ -254,6 +265,16 @@ export function signalExitCode(signal: string): number {
254
265
  return num === undefined ? 130 : 128 + num
255
266
  }
256
267
 
268
+ /**
269
+ * The signal a run's stop sends a task, from its abort reason: a task
270
+ * stopped before its spawn exits as if that signal had killed it. As
271
+ * `forwardedSignal` (orchestrator/signals.ts): a hang-up forwards SIGTERM
272
+ * (to many servers SIGHUP means "reload"), so it reads as SIGTERM here.
273
+ */
274
+ export function stopSignal(reason: unknown): 'SIGINT' | 'SIGTERM' {
275
+ return reason === 'SIGINT' ? 'SIGINT' : 'SIGTERM'
276
+ }
277
+
257
278
  /**
258
279
  * The reverse: the signal an exit above 128 stands for (137 → SIGKILL),
259
280
  * by the platform's numbering; undefined for a plain exit. The shell
@@ -484,9 +505,8 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
484
505
  ],
485
506
  // Its own session and process group, so a kill reaches what it
486
507
  // forked (kill-tree.ts). stdin is a pipe, so a background group
487
- // never stops on a terminal read. Windows has no groups, and a
488
- // detached child there has no console.
489
- detached: !WIN32,
508
+ // never stops on a terminal read.
509
+ detached: true,
490
510
  },
491
511
  ),
492
512
  )
@@ -695,9 +715,8 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
695
715
  stdio: ['ignore', 'pipe', 'pipe', ...(guard === undefined ? [] : [guard])],
696
716
  // Its own session and process group, so a kill reaches what it
697
717
  // forked (kill-tree.ts). stdin is ignored, so a background group
698
- // never stops on a terminal read. Windows has no groups, and a
699
- // detached child there has no console.
700
- detached: !WIN32,
718
+ // never stops on a terminal read.
719
+ detached: true,
701
720
  }),
702
721
  )
703
722
  } catch (err) {
@@ -750,18 +769,15 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
750
769
  * `true` read 44 MB through vx while its shell's `VmHWM` was 1.9 MB, and
751
770
  * 300 MB allocated in the parent made `true` read 328 MB (2026-09-12).
752
771
  * Read after the child exits so it covers the whole task's span (the mark
753
- * is monotonic). Linux reads `VmHWM`; elsewhere the current RSS is the
754
- * bound in hand.
772
+ * is monotonic). Linux reads its own peak (`getrusage`); elsewhere the
773
+ * current RSS is the bound in hand.
755
774
  */
756
775
  export function ownRssHighWater(): number {
757
- if (process.platform === 'linux') {
758
- try {
759
- const m = /VmHWM:\s+(\d+) kB/.exec(readFileSync('/proc/self/status', 'utf8'))
760
- if (m !== null) return Number(m[1]) * 1024
761
- } catch {
762
- // /proc unreadable: fall through to the current RSS.
763
- }
764
- }
776
+ // getrusage's own peak, not `/proc/self/status`'s `VmHWM`: one syscall
777
+ // where generating and parsing the status file was ~20 µs a task alone
778
+ // and ~70 ms of a 1,000-task cold run. It is the same mark, raised only
779
+ // by the image this one exec'd from (a shell), within the floor's slack.
780
+ if (process.platform === 'linux') return peakRssBytes(process.resourceUsage().maxRSS)
765
781
  return process.memoryUsage.rss()
766
782
  }
767
783
 
@@ -10,6 +10,7 @@ import {
10
10
  isMountableLiteral,
11
11
  localBindingOn,
12
12
  MOUNT_WILDCARDS,
13
+ toRealPath,
13
14
  unique,
14
15
  } from './sandbox-paths.js'
15
16
  import type { SandboxedRunArgs } from './sandbox-runtime.js'
@@ -27,27 +28,50 @@ type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
27
28
  * is "Device or resource busy"; binding `/w/dist` instead succeeds.
28
29
  *
29
30
  * So on Linux a file-shaped grant is widened to its directory. That IS a
30
- * widening — the task may write its siblings — and it is the narrowest
31
+ * widening — the task may write its siblings, and read them unrefused
32
+ * (`widenedEntries` has those reads reported) — and it is the narrowest
31
33
  * grant the mechanism can express: the alternative is a declared output
32
34
  * the task cannot produce. macOS needs none of this (seatbelt matches
33
35
  * paths, it does not mount), so the grant stays exact there.
34
36
  */
35
37
  export function bindableWrites(paths: readonly string[]): string[] {
36
38
  if (process.platform !== 'linux') return [...paths]
37
- return unique(
38
- paths
39
- .filter((p) => !holdsGlobChar(p))
40
- .map((p) => {
41
- if (!isMountableLiteral(p)) return p
42
- try {
43
- if (statSync(p).isDirectory()) return p
44
- } catch {
45
- // Does not exist yet: `prepareOutputsForBind` creates a file for a
46
- // file-shaped grant, so treat it as one.
47
- }
48
- return path.dirname(p)
49
- }),
50
- )
39
+ return unique(paths.filter((p) => !holdsGlobChar(p)).map(bindOf))
40
+ }
41
+
42
+ function bindOf(p: string): string {
43
+ if (!isMountableLiteral(p)) return p
44
+ try {
45
+ if (statSync(p).isDirectory()) return p
46
+ } catch {
47
+ // Does not exist yet: `prepareOutputsForBind` creates a file for a
48
+ // file-shaped grant, so treat it as one.
49
+ }
50
+ return path.dirname(p)
51
+ }
52
+
53
+ /**
54
+ * What each directory `bindableWrites` widened a file grant to held when
55
+ * the task started, by real path. A read there is never refused, so the
56
+ * strace pass reports one of these no grant covers: a task granted
57
+ * `write: ['out.txt']` read the whole project unreported, and a cached run
58
+ * replayed an undeclared file's old bytes. What the task creates there is
59
+ * its own, not an input, so only these entries count. Empty off Linux.
60
+ */
61
+ export function widenedEntries(paths: readonly string[]): Map<string, Set<string>> {
62
+ const out = new Map<string, Set<string>>()
63
+ if (process.platform !== 'linux') return out
64
+ for (const p of paths) {
65
+ if (holdsGlobChar(p) || bindOf(p) === p) continue
66
+ const dir = toRealPath(path.dirname(p))
67
+ if (out.has(dir)) continue
68
+ try {
69
+ out.set(dir, new Set(readdirSync(dir).map((e) => path.join(dir, e))))
70
+ } catch {
71
+ // Gone or unreadable: nothing there to read.
72
+ }
73
+ }
74
+ return out
51
75
  }
52
76
 
53
77
  /**
@@ -48,6 +48,7 @@ import {
48
48
  shellQuote,
49
49
  withForwardArgs,
50
50
  signalExitCode,
51
+ stopSignal,
51
52
  spawnFailureText,
52
53
  streamToString,
53
54
  resourceUsageToCpuRss,
@@ -63,7 +64,13 @@ import {
63
64
  UserError,
64
65
  xxh3hex,
65
66
  } from '../util/index.js'
66
- import { bindableReads, bindableWrites, buildCustomConfig, scratchWrites } from './sandbox-binds.js'
67
+ import {
68
+ bindableReads,
69
+ bindableWrites,
70
+ buildCustomConfig,
71
+ scratchWrites,
72
+ widenedEntries,
73
+ } from './sandbox-binds.js'
67
74
  import {
68
75
  atOrUnder,
69
76
  isMountableLiteral,
@@ -550,6 +557,9 @@ export async function initSandbox(opts?: {
550
557
  /** Whether any task of the run grants `gitConfig`: SRT reads it run-wide, so it is set per wrap (B-41). */
551
558
  gitConfig?: boolean
552
559
  }): Promise<void> {
560
+ // This run owns the session now; a reset an earlier run deferred to its
561
+ // last server would tear it down under this run's tasks (M-25).
562
+ resetDeferred = false
553
563
  // A reset a server's exit started unawaited: a watch cycle stops its
554
564
  // server and starts its run at once, and an init under that reset
555
565
  // found SRT up, hot-reloaded it, and had it torn down after (item 884).
@@ -947,6 +957,8 @@ export async function wrapSandboxedCommand(
947
957
  server?: boolean
948
958
  /** Trace the command's `openat` calls to descriptor TRACE_FD (Linux; `wantsStraceDetection`). */
949
959
  trace?: 'plain' | 'seccomp'
960
+ /** Trace with `-y`, each descriptor's path printed: a read under a widened grant is judged. */
961
+ tracePaths?: boolean
950
962
  },
951
963
  ): Promise<{
952
964
  wrapped: string
@@ -1004,7 +1016,7 @@ export async function wrapSandboxedCommand(
1004
1016
  const ports = process.platform === 'linux' ? bridgedPorts(args.config) : []
1005
1017
  const grouped =
1006
1018
  process.platform === 'linux'
1007
- ? ownGroupCommand(tag, inTmp, args.trace)
1019
+ ? ownGroupCommand(tag, inTmp, args.trace, args.tracePaths === true)
1008
1020
  : { command: taggedCommand, forwards: false, traced: false }
1009
1021
  const inner = [
1010
1022
  ports.length > 0 ? portBridgeInner(ports, tag) : '',
@@ -1082,6 +1094,7 @@ function ownGroupCommand(
1082
1094
  tag: string,
1083
1095
  userCommand: string,
1084
1096
  trace?: 'plain' | 'seccomp',
1097
+ tracePaths = false,
1085
1098
  ): { command: string; forwards: boolean; traced: boolean } {
1086
1099
  // `sh`, as an unsandboxed task runs (`runner.ts`): the command ran under
1087
1100
  // bash here, so `[[ … ]]`, brace expansion and `echo 'a\tb'` read one
@@ -1131,6 +1144,10 @@ function ownGroupCommand(
1131
1144
  '-DD',
1132
1145
  '-f',
1133
1146
  ...(trace === 'seccomp' ? ['--seccomp-bpf'] : []),
1147
+ // A read through a directory's descriptor (`find`, `grep -r`) names
1148
+ // only the entry; `-y` prints the path it opened. 40% slower on 2,000
1149
+ // opens, so only where a widened grant's reads are judged.
1150
+ ...(tracePaths ? ['-y'] : []),
1134
1151
  '-qq',
1135
1152
  '-e',
1136
1153
  // A process's cwd moves on `chdir` and starts as its parent's at the
@@ -1477,9 +1494,31 @@ async function runSandboxedOnce(
1477
1494
  // kernel filter stops only on `openat`. strace ≥ 5.3 (2019); an older
1478
1495
  // one gets the slow form rather than no detection.
1479
1496
  const useStrace = await wantsStraceDetection()
1497
+ // Before the spawn: what the task creates under a widened grant is its own.
1498
+ const widened = useStrace ? widenedEntries(args.config.allowWrite) : undefined
1480
1499
  const { wrapped, tag, srtCommand, baselines, scratch, forwardsSignals, traced } =
1481
- await wrapSandboxedCommand({ ...args, ...(useStrace ? { trace: useStrace } : {}) })
1500
+ await wrapSandboxedCommand({
1501
+ ...args,
1502
+ ...(useStrace ? { trace: useStrace } : {}),
1503
+ ...(widened !== undefined && widened.size > 0 ? { tracePaths: true } : {}),
1504
+ })
1482
1505
  const takeRecords = collectRecords(SandboxManager.getSandboxViolationStore(), srtCommand)
1506
+ // A stop that landed during the awaits above leaves nothing to kill yet:
1507
+ // spawned now, the task ran after the teardown swept the run's children.
1508
+ if (args.signal?.aborted === true) {
1509
+ releaseBridges(tag)
1510
+ takeRecords()
1511
+ const signal = stopSignal(args.signal.reason)
1512
+ return {
1513
+ exitCode: signalExitCode(signal),
1514
+ durationMs: Date.now() - start,
1515
+ stdout: '',
1516
+ stderr: '',
1517
+ signal,
1518
+ violations: [],
1519
+ tracerFailed: false,
1520
+ }
1521
+ }
1483
1522
  // Beside the task directories, which every sandbox replaces with its own:
1484
1523
  // in the shared temp dir a concurrent task read this log, every path
1485
1524
  // this task opened (L-25).
@@ -1628,7 +1667,7 @@ async function runSandboxedOnce(
1628
1667
  process.platform === 'linux'
1629
1668
  ? [
1630
1669
  ...(straceLog
1631
- ? await parseStraceViolations(straceLog, args, baselines).catch(() => [])
1670
+ ? await parseStraceViolations(straceLog, args, baselines, widened).catch(() => [])
1632
1671
  : []),
1633
1672
  ...refusedWrites(
1634
1673
  // Keyed by what SRT wrapped, the in-sandbox group wrapper
@@ -50,6 +50,25 @@ const STRACE_RESUMED_RE = new RegExp(
50
50
  )
51
51
  /** A resumed call that SUCCEEDED — clears the pending entry, emits nothing. */
52
52
  const STRACE_RESUMED_OK_RE = new RegExp(`^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) resumed>`)
53
+ /**
54
+ * An `openat` that succeeded, and not for writing alone: a read. Placed by
55
+ * the path `-y` prints for the descriptor it returned; without one, only
56
+ * from the cwd (`AT_FDCWD`) or by an absolute path.
57
+ */
58
+ const READ_FLAGS = '(?![A-Z_|]*O_WRONLY)[A-Z_|]+'
59
+ const OPEN_READ_RE = new RegExp(
60
+ `^(\\d+)\\s+openat\\((AT_FDCWD|\\d+)(?:<[^"]*>)?, ${QUOTED}, ${READ_FLAGS}[^)]*\\)\\s*=\\s*\\d+(?:<(.*)>)?$`,
61
+ )
62
+ const OPEN_READ_UNFINISHED_RE = new RegExp(
63
+ `^(\\d+)\\s+openat\\((AT_FDCWD|\\d+)(?:<[^"]*>)?, ${QUOTED}, ${READ_FLAGS}.*<unfinished`,
64
+ )
65
+ const RESUMED_FD_RE = /resumed>.*\)\s*=\s*\d+(?:<(.*)>)?$/
66
+
67
+ /** Where a successful read opened: `-y`'s path, else the call's own when placeable. */
68
+ function readPath(dirfd: string, rawPath: string, opened: string | undefined): string | undefined {
69
+ if (opened !== undefined) return cStringPath(opened)
70
+ return dirfd === 'AT_FDCWD' || rawPath.startsWith('/') ? rawPath : undefined
71
+ }
53
72
 
54
73
  const C_ESCAPES: Record<string, number> = { n: 10, t: 9, r: 13, v: 11, f: 12, a: 7, b: 8 }
55
74
 
@@ -89,6 +108,8 @@ export interface DeniedCall {
89
108
  syscall: string
90
109
  rawPath: string
91
110
  errno: string
111
+ /** A read that SUCCEEDED (`errno` empty): asked for by `deniedCalls`' `reads`. */
112
+ read?: true
92
113
  /**
93
114
  * The directory a relative `rawPath` was opened from, when the trace
94
115
  * shows the process (or one it was forked from) changed into one; absent
@@ -132,8 +153,11 @@ type Op = { chdir: string } | { lost: true } | { call: number }
132
153
  * detector, and a synthetic trace pins the split-line shapes deterministically
133
154
  * where an end-to-end run only produces them when strace happens to interleave.
134
155
  */
135
- export function deniedCalls(text: string, cwd?: string): DeniedCall[] {
136
- const pending = new Map<string, { syscall: string; rawPath: string }>()
156
+ export function deniedCalls(text: string, cwd?: string, reads = false): DeniedCall[] {
157
+ const pending = new Map<
158
+ string,
159
+ { syscall: string; rawPath: string; read?: true; dirfd?: string }
160
+ >()
137
161
  const out: DeniedCall[] = []
138
162
  const ops = new Map<string, Op[]>()
139
163
  // A clone with CLONE_FS (every thread) shares its creator's cwd, so a
@@ -162,6 +186,15 @@ export function deniedCalls(text: string, cwd?: string): DeniedCall[] {
162
186
  const opener =
163
187
  line.includes('= -1') || line.includes('<unfinished') || line.includes('resumed>')
164
188
  if (!opener) {
189
+ if (reads && line.includes('openat(')) {
190
+ const m = OPEN_READ_RE.exec(line)
191
+ if (m !== null) {
192
+ const at = readPath(m[2]!, cStringPath(m[3]!), m[4])
193
+ if (at !== undefined)
194
+ denied(m[1]!, { syscall: 'openat', rawPath: at, errno: '', read: true })
195
+ continue
196
+ }
197
+ }
165
198
  if (
166
199
  cwd !== undefined &&
167
200
  (line.includes('chdir(') || line.includes('fork(') || line.includes('clone'))
@@ -180,7 +213,12 @@ export function deniedCalls(text: string, cwd?: string): DeniedCall[] {
180
213
  unfinished[2] !== undefined &&
181
214
  unfinished[3] !== undefined
182
215
  ) {
183
- pending.set(unfinished[1], { syscall: unfinished[2], rawPath: cStringPath(unfinished[3]) })
216
+ pending.set(unfinished[1], {
217
+ syscall: unfinished[2],
218
+ rawPath: cStringPath(unfinished[3]),
219
+ ...(reads && OPEN_READ_UNFINISHED_RE.test(line) ? { read: true as const } : {}),
220
+ ...(reads ? { dirfd: OPEN_READ_UNFINISHED_RE.exec(line)?.[2] ?? 'AT_FDCWD' } : {}),
221
+ })
184
222
  continue
185
223
  }
186
224
  const resumedOk = STRACE_RESUMED_OK_RE.exec(line)
@@ -190,8 +228,14 @@ export function deniedCalls(text: string, cwd?: string): DeniedCall[] {
190
228
  const resumed = STRACE_RESUMED_RE.exec(line)
191
229
  // Only a resume that carries a DENIAL is a violation; a successful
192
230
  // resume just retires the pending entry.
231
+ const fd = held?.read === true ? RESUMED_FD_RE.exec(line) : null
193
232
  if (held !== undefined && resumed?.[3] !== undefined) {
194
- denied(resumedOk[1], { ...held, errno: resumed[3] })
233
+ denied(resumedOk[1], { syscall: held.syscall, rawPath: held.rawPath, errno: resumed[3] })
234
+ } else if (held !== undefined && fd !== null) {
235
+ const at = readPath(held.dirfd ?? 'AT_FDCWD', held.rawPath, fd[1])
236
+ if (at !== undefined) {
237
+ denied(resumedOk[1], { syscall: held.syscall, rawPath: at, errno: '', read: true })
238
+ }
195
239
  }
196
240
  continue
197
241
  }
@@ -264,6 +308,8 @@ export async function parseStraceViolations(
264
308
  logPath: string,
265
309
  args: SandboxedRunArgs,
266
310
  baselines: { allowRead: readonly string[]; denyRead: readonly string[]; cwd: string },
311
+ /** `widenedEntries` taken as the task started: no entry, no read parse. */
312
+ widened: ReadonlyMap<string, ReadonlySet<string>> = new Map(),
267
313
  ): Promise<SandboxViolation[]> {
268
314
  const text = await Bun.file(logPath).text()
269
315
  if (text.length === 0) return []
@@ -281,29 +327,60 @@ export async function parseStraceViolations(
281
327
  ),
282
328
  )
283
329
  const denyAnchors = baselines.denyRead.map((p) => toRealPath(absolutize(p)))
330
+ // A read under a widened write grant's directory is never refused, so it
331
+ // is reported when it succeeds: an entry that was there at the start and
332
+ // no grant covers is an input the key never saw, and so is the
333
+ // directory's listing while it holds one. The granted files and what the
334
+ // task made itself stay readable.
335
+ const written = new Set(args.config.allowWrite.map((p) => toRealPath(absolutize(p))))
336
+ const undeclared = (abs: string): boolean => {
337
+ for (const [dir, entries] of widened) {
338
+ if (abs === dir)
339
+ return [...entries].some((e) => !isUnderAny(e, written) && !isUnderAny(e, allowAbs))
340
+ if (!atOrUnder(abs, dir)) continue
341
+ const top = path.join(dir, path.relative(dir, abs).split(path.sep)[0]!)
342
+ return entries.has(top) && !isUnderAny(abs, written)
343
+ }
344
+ return false
345
+ }
284
346
 
285
347
  const seen = new Set<string>()
286
348
  const out: SandboxViolation[] = []
287
- for (const { syscall, rawPath, errno, dir } of deniedCalls(text, baselines.cwd)) {
349
+ for (const { syscall, rawPath, errno, dir, read } of deniedCalls(
350
+ text,
351
+ baselines.cwd,
352
+ widened.size > 0,
353
+ )) {
288
354
  const abs = toRealPath(absolutize(rawPath, dir ?? baselines.cwd))
289
355
  // Only report paths under the workspace-root deny anchor — system
290
356
  // libs / /proc / /sys / etc. probes are not interesting violations.
291
357
  if (!denyAnchors.some((root) => atOrUnder(abs, root))) continue
292
358
  // Skip paths the user explicitly allowed (and their descendants).
293
359
  if (isUnderAny(abs, allowAbs)) continue
360
+ if (read === true && !undeclared(abs)) continue
294
361
  const key = `${syscall}|${abs}`
295
362
  if (seen.has(key)) continue
296
363
  seen.add(key)
297
- out.push({
298
- line: `${syscall}(${rawPath}) = -1 ${errno} [${abs}]`,
299
- timestamp: new Date(),
300
- target: abs,
301
- path: abs,
302
- // The trace is `-e trace=openat`, and an openat is a read or a
303
- // write depending on flags the trace does not carry — so either
304
- // list can silence it.
305
- ignorable: ['read', 'write'],
306
- })
364
+ out.push(
365
+ read === true
366
+ ? {
367
+ line: `${syscall}(${rawPath}) read under a write grant's directory, granted no read [${abs}]`,
368
+ timestamp: new Date(),
369
+ target: abs,
370
+ path: abs,
371
+ ignorable: ['read'],
372
+ }
373
+ : {
374
+ line: `${syscall}(${rawPath}) = -1 ${errno} [${abs}]`,
375
+ timestamp: new Date(),
376
+ target: abs,
377
+ path: abs,
378
+ // The trace is `-e trace=openat`, and an openat is a read or a
379
+ // write depending on flags the trace does not carry — so either
380
+ // list can silence it.
381
+ ignorable: ['read', 'write'],
382
+ },
383
+ )
307
384
  }
308
385
  return out
309
386
  }
@@ -52,7 +52,12 @@ export interface EventBus {
52
52
  }
53
53
 
54
54
  export function createEventBus(): EventBus {
55
- const subscribers: RunEventSubscriber[] = []
55
+ // Replaced, never mutated: an emit walks the list it started with, so a
56
+ // subscriber that unsubscribes itself (an embedder's `off()` on
57
+ // `run:end`) no longer shifts the next one past the event — the terminal
58
+ // renderer after it missed `run:end` (C-77). A subscriber added during
59
+ // an emit hears the next event, not this one.
60
+ let subscribers: readonly RunEventSubscriber[] = []
56
61
  return {
57
62
  emit(event) {
58
63
  // Synchronous, in-subscription-order fan-out. Ordering is part of
@@ -79,10 +84,10 @@ export function createEventBus(): EventBus {
79
84
  }
80
85
  },
81
86
  subscribe(subscriber) {
82
- subscribers.push(subscriber)
87
+ subscribers = [...subscribers, subscriber]
83
88
  return () => {
84
89
  const i = subscribers.indexOf(subscriber)
85
- if (i >= 0) subscribers.splice(i, 1)
90
+ if (i >= 0) subscribers = subscribers.toSpliced(i, 1)
86
91
  }
87
92
  },
88
93
  }
@@ -37,7 +37,7 @@ import {
37
37
  } from '../exec/index.js'
38
38
  import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
39
39
  import { killGraceMs, maskedEmitter, printable, relPosix, secretMask, span } from '../util/index.js'
40
- import { SIGNAL_SHUTDOWN_GRACE_MS } from './signals.js'
40
+ import { forwardedSignal, SIGNAL_SHUTDOWN_GRACE_MS } from './signals.js'
41
41
  import { executorLabel, nameExecutorFailure } from './plugin-host.js'
42
42
  import {
43
43
  mayWriteFingerprint,
@@ -354,6 +354,22 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
354
354
  persistentOpts.timeoutMs = effectiveTimeout
355
355
  }
356
356
 
357
+ // A stop that landed during the awaits above (the key, the sandbox's
358
+ // arming, request and wrap) leaves nothing to kill yet: spawned now, the
359
+ // server came up after the teardown and held the run's exit 7 s.
360
+ if (args.stopSignal?.aborted === true) {
361
+ if (bridgeTag !== undefined) releaseBridges(bridgeTag)
362
+ await placeholderSweeper(placeholders)()
363
+ return {
364
+ node,
365
+ status: 'aborted',
366
+ // The signal a spawned server would have been sent (a hang-up forwards SIGTERM).
367
+ exitCode: signalExitCode(forwardedSignal(args.stopSignal.reason)),
368
+ durationMs: 0,
369
+ wallclockStartNs,
370
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
371
+ }
372
+ }
357
373
  const spawn = runPersistent(persistentOpts)
358
374
  // The host side of a port bridge lives exactly as long as the server:
359
375
  // released on the child's exit, whether the run tore it down or it died.
@@ -493,11 +509,22 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
493
509
  // The local executor keeps its own timer (it signals the process group).
494
510
  let timeoutTimer: ReturnType<typeof setTimeout> | undefined
495
511
  let timeoutFired = false
512
+ // The attempt's listener on the run's stop signal, taken off once the
513
+ // attempt settles. Left on, every task of the run added one: the list
514
+ // grew to the task count, and each add scans it for a duplicate, ~100 ms
515
+ // of a 1,000-task cold run.
516
+ let unlistenStop: (() => void) | undefined
496
517
  function requestSignal(): AbortSignal {
497
518
  const stop = new AbortController()
498
519
  const abort = (): void => stop.abort(args.stopSignal?.reason)
520
+ unlistenStop?.()
521
+ unlistenStop = undefined
499
522
  if (args.stopSignal?.aborted === true) abort()
500
- else args.stopSignal?.addEventListener('abort', abort, { once: true })
523
+ else if (args.stopSignal !== undefined) {
524
+ const run = args.stopSignal
525
+ run.addEventListener('abort', abort, { once: true })
526
+ unlistenStop = () => run.removeEventListener('abort', abort)
527
+ }
501
528
  if (effectiveTimeout !== undefined) {
502
529
  clearTimeout(timeoutTimer)
503
530
  timeoutFired = false
@@ -801,6 +828,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
801
828
  })
802
829
  .finally(() => {
803
830
  clearTimeout(timeoutTimer)
831
+ unlistenStop?.()
832
+ unlistenStop = undefined
804
833
  flushMasked()
805
834
  })
806
835
  endExec()
@@ -240,7 +240,7 @@ async function place(lockDir: string): Promise<string | null> {
240
240
  await mkdir(staging)
241
241
  try {
242
242
  await writeFile(path.join(staging, entry), '')
243
- await (WIN32 ? renameWin32(staging, lockDir) : rename(staging, lockDir))
243
+ await rename(staging, lockDir)
244
244
  return entry
245
245
  } catch (err) {
246
246
  await rm(staging, { recursive: true, force: true })
@@ -250,36 +250,6 @@ async function place(lockDir: string): Promise<string | null> {
250
250
  }
251
251
  }
252
252
 
253
- const WIN32 = process.platform === 'win32'
254
-
255
- /**
256
- * Windows renames no directory onto another, even an empty one: EPERM
257
- * where POSIX gives ENOTEMPTY, which read as a refusal and ran every
258
- * contender unlocked. A name that exists is taken; an empty one (a
259
- * holder's leave that lost its rmdir) is removed and the rename tried
260
- * once more, as POSIX's rename onto it would replace it.
261
- */
262
- async function renameWin32(staging: string, lockDir: string): Promise<void> {
263
- try {
264
- await rename(staging, lockDir)
265
- return
266
- } catch (err) {
267
- const code = (err as NodeJS.ErrnoException).code
268
- if (code !== 'EPERM' && code !== 'EACCES') throw err
269
- const empty = await rmdir(lockDir).then(
270
- () => true,
271
- () => false,
272
- )
273
- if (!empty) throw Object.assign(new Error('run lock taken'), { code: 'EEXIST' })
274
- }
275
- await rename(staging, lockDir).catch((err: NodeJS.ErrnoException) => {
276
- if (err.code === 'EPERM' || err.code === 'EACCES') {
277
- throw Object.assign(new Error('run lock taken'), { code: 'EEXIST' })
278
- }
279
- throw err
280
- })
281
- }
282
-
283
253
  /**
284
254
  * Unlink one taking's entry, then the directory if that left it empty.
285
255
  * Gone already is not an error (another waiter reclaimed it first); a
@@ -193,7 +193,7 @@ export async function sandboxRequestFor(
193
193
  // character class, so vx's own workspace wall matched nothing (B-65).
194
194
  // A `*` or `?` is one too, and its grants matched the project's siblings.
195
195
  const home = toRealPath(node.projectDir)
196
- if (process.platform !== 'win32' && /[[\]*?]/.test(home)) {
196
+ if (/[[\]*?]/.test(home)) {
197
197
  throw new UserError(
198
198
  `exec.sandbox: ${home} holds [, ], * or ?, which the sandbox runtime reads as a ` +
199
199
  `pattern, not a name — rename the directory, or run the task without exec.sandbox`,
@@ -1,11 +1,8 @@
1
1
  import { realpathSync } from 'node:fs'
2
2
 
3
3
  /**
4
- * A path's canonical spelling. On Windows `realpathSync` keeps an 8.3
5
- * short name (`C:\Users\RUNNER~1\…`) where git, `fs.promises.realpath`
6
- * and every other reader say `runneradmin`, so one file had two
7
- * spellings and a key or a comparison that folds the path split on
8
- * them (O-16). The native call asks the OS for the final path.
4
+ * A path's canonical spelling: the OS's own answer for the final path,
5
+ * so a key or a comparison that folds the path sees one spelling (O-16).
9
6
  */
10
7
  export function realPath(p: string): string {
11
8
  return realpathSync.native(p)
package/src/util/which.ts CHANGED
@@ -48,13 +48,8 @@ export function executablePath(name: string): string {
48
48
  }
49
49
 
50
50
  /**
51
- * The argv that runs `script` in the task shell: `sh -c` on Linux and
52
- * macOS, and Bun's own shell (`bun exec`) on Windows, which ships no `sh`
53
- * (docs/design/windows-2026-09.md § Decisions 1). Bun's shell reads the
54
- * same `&&`, `|`, `$(…)` and `VAR=x cmd` syntax that `bun run` uses there.
51
+ * The argv that runs `script` in the task shell: `sh -c`.
55
52
  */
56
53
  export function shellArgv(script: string): string[] {
57
- return process.platform === 'win32'
58
- ? [process.execPath, 'exec', script]
59
- : [executablePath('sh'), '-c', script]
54
+ return [executablePath('sh'), '-c', script]
60
55
  }
@@ -545,6 +545,21 @@ export async function configEvalKeyFromClosure(a: {
545
545
  return h.toString(16).padStart(16, '0')
546
546
  }
547
547
 
548
+ /** `configEvalKeyFromClosure` over identities already in hand; null when one is missing. */
549
+ export function configEvalKeyFromIdentities(a: {
550
+ closure: readonly string[]
551
+ identities: ReadonlyMap<string, string>
552
+ workspaceFingerprint: string
553
+ }): string | null {
554
+ let h = keySeed(a.workspaceFingerprint)
555
+ for (const file of a.closure) {
556
+ const id = a.identities.get(file)
557
+ if (id === undefined) return null
558
+ h = xxh3(`${file}\0${id}`, h)
559
+ }
560
+ return h.toString(16).padStart(16, '0')
561
+ }
562
+
548
563
  /**
549
564
  * Every file a config imports by relative specifier, transitively, outside
550
565
  * `node_modules`, the config itself excluded. `vx watch` watches these: a
@@ -343,6 +343,16 @@ function cdsToMembers(script: string, rootDir: string, memberDirs: readonly stri
343
343
  return false
344
344
  }
345
345
 
346
+ /**
347
+ * The hooks a package's build hides in when it has no `build` script:
348
+ * react-navigation's twelve packages build in `prepack: bob build`, their
349
+ * root's `build` (`lerna run prepack`) runs the members and is left out,
350
+ * and the repo mapped with no build at all.
351
+ */
352
+ const LIFECYCLE_BUILD_HOOKS = ['prepack', 'prepublishOnly', 'prepublish', 'prepare']
353
+ const BUILDER =
354
+ /(?:^|[\s;&|(])(?:bob build|tsc|tsup|tsdown|rollup|vite build|babel|unbuild|esbuild|webpack|microbundle|pkgroll|bunchee|preconstruct build)(?:\s|$)/
355
+
346
356
  /** The workspace flags that move a package manager off the package it runs in. */
347
357
  const ELSEWHERE_FLAG = /^(-r|--recursive|--filter|-F|--workspaces|-ws|--workspace)(=|$)/
348
358
  const DIR_FLAG = /^(-C|--dir|--cwd|--prefix)(?:=(.*))?$/
@@ -481,6 +491,7 @@ export function migrateScripts(
481
491
  // and maps.
482
492
  const root = metas.length > 1 ? workspaceRootOf(metas) : undefined
483
493
  const notes: string[] = []
494
+ const lifecycleBuilds: [string, string, string][] = []
484
495
  // The rest check the whole repo (`lint: oxlint .`, `test: vitest`):
485
496
  // `vx run lint` found no project in remix, wagmi or element-plus. Such a
486
497
  // script maps onto the root, when the root has a name (vx skips a
@@ -585,6 +596,12 @@ export function migrateScripts(
585
596
  return why === null ? [] : [[n, why] as const]
586
597
  })
587
598
  const names = runnable.filter((n) => taskNameProblem(n) === null)
599
+ if (meta !== rootMeta && !('build' in scripts)) {
600
+ const hook = LIFECYCLE_BUILD_HOOKS.find(
601
+ (h) => typeof scripts[h] === 'string' && BUILDER.test(scripts[h] as string),
602
+ )
603
+ if (hook !== undefined) lifecycleBuilds.push([meta.name, hook, scripts[hook] as string])
604
+ }
588
605
  if (runnable.length === 0) continue
589
606
  const hasBuild = names.includes('build')
590
607
  const has = (n: string): boolean => names.includes(n)
@@ -681,6 +698,13 @@ export function migrateScripts(
681
698
  projects.push({ name: meta.name, dir: meta.dir, importLines, tasks })
682
699
  }
683
700
  }
701
+ if (lifecycleBuilds.length > 0) {
702
+ const [name, hook, command] = lifecycleBuilds[0]!
703
+ const more = lifecycleBuilds.length - 1
704
+ notes.push(
705
+ `${lifecycleBuilds.length === 1 ? 'a package builds' : `${lifecycleBuilds.length} packages build`} only in a lifecycle script (\`${hook}: ${command}\` in ${name}${more > 0 ? ` and ${more} more` : ''}), which the package manager runs on pack or install and vx never runs: add a \`build\` script running it and run \`vx init\` again`,
706
+ )
707
+ }
684
708
  breakBuildCycles(projects, metas)
685
709
  pruneOrphanPersistentNotes(projects, PERSISTENT_TODO)
686
710
  return {
@@ -402,10 +402,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
402
402
  for (const f of replaced) report.push(` ${relPosix(root, f)}`)
403
403
  }
404
404
  }
405
- const firstTask =
406
- plan.projects.flatMap((p) => p.tasks.map((t) => t.name)).find((n) => n === 'build') ??
407
- plan.projects[0]?.tasks[0]?.name ??
408
- 'build'
405
+ const firstTask = nextTask(plan.projects.flatMap((p) => p.tasks))
409
406
  // A run of tasks none of which caches is never a hit, and a first try
410
407
  // that runs twice to see the cache work saw it run twice.
411
408
  if (!cached && [...todos.keys()].some(isCacheTodo)) {
@@ -442,6 +439,25 @@ function inGitWorkTree(root: string): boolean {
442
439
  }
443
440
  }
444
441
 
442
+ /** Names that change the repo or the world rather than check it. */
443
+ const NOT_A_TRY = /^(clean|reset|nuke|release|publish|deploy|version|format|fix)(?:$|[:\-_.])/
444
+
445
+ /**
446
+ * The task the `next:` line runs: `build`, else the first task that
447
+ * neither serves nor changes anything, else the first. react-navigation, whose
448
+ * packages build in `prepack`, was told `vx run clean --all` (D-105).
449
+ */
450
+ function nextTask(tasks: readonly GeneratedTask[]): string {
451
+ const names = tasks.filter((t) => t.task !== null).map((t) => t.name)
452
+ const fit = tasks.find(
453
+ (t) =>
454
+ t.task !== null &&
455
+ !NOT_A_TRY.test(t.name) &&
456
+ (t.task['exec'] as { persistent?: unknown } | undefined)?.persistent === undefined,
457
+ )
458
+ return names.includes('build') ? 'build' : (fit?.name ?? names[0] ?? 'build')
459
+ }
460
+
445
461
  /**
446
462
  * How the user runs vx, for the report's `next:` line. After `npx vx init`
447
463
  * or `bunx @vzn/vx init` a bare `vx` is on no PATH (the first-five-minutes
@@ -8,6 +8,7 @@ import { hasEsmExport, unprovidedBareImports } from './config-imports.js'
8
8
  import {
9
9
  configEvalKey,
10
10
  configEvalKeyFromClosure,
11
+ configEvalKeyFromIdentities,
11
12
  configImports,
12
13
  type ConfigEvalStore,
13
14
  } from './config-cache.js'
@@ -297,7 +298,7 @@ export interface LoadProjectConfigOptions {
297
298
  * order given, so a failure names the first broken file the way a
298
299
  * one-by-one load did). A single-path load is the one-element case.
299
300
  */
300
- const LOAD_WIDTH = 64
301
+ const LOAD_WIDTH = 128
301
302
 
302
303
  export async function loadProjectConfigs(
303
304
  configPaths: readonly string[],
@@ -323,49 +324,69 @@ export async function loadProjectConfigs(
323
324
  // the map; a file the batch could not stat has no identity, which makes
324
325
  // that config's fast key miss and sends it down the slow path exactly as
325
326
  // a throwing per-file `hashFile` did.
326
- let fastHashFile = hashFile
327
+ let identities: Map<string, string> | undefined
327
328
  if (closures.size > 0 && store?.hashFiles !== undefined) {
328
329
  const files = new Set<string>()
329
330
  for (const closure of closures.values()) for (const f of closure) files.add(f)
330
- const identities = await store.hashFiles([...files])
331
- fastHashFile = (file: string): Promise<string> => {
332
- const id = identities.get(file)
333
- return id === undefined
334
- ? Promise.reject(new Error(`no identity for ${file}`))
335
- : Promise.resolve(id)
336
- }
331
+ identities = await store.hashFiles([...files])
332
+ }
333
+ // With the batch in hand a fast key is synchronous: an async key per
334
+ // config, awaiting identities already in the map, was ~5 ms of a
335
+ // 1,000-config warm load. Only a config off the fast path is awaited.
336
+ const fastKeyOf = (configPath: string): string | null => {
337
+ const closure = closures.get(configPath)
338
+ if (closure === undefined || evalCache === undefined || identities === undefined) return null
339
+ return configEvalKeyFromIdentities({
340
+ closure,
341
+ identities,
342
+ workspaceFingerprint: evalCache.workspaceFingerprint,
343
+ })
344
+ }
345
+ const fastHashFile = identities === undefined ? hashFile : undefined
346
+ interface Prepared {
347
+ configPath: string
348
+ bytes: Uint8Array | null
349
+ cacheKey: string | null
350
+ indexed: boolean
351
+ closure?: string[] | undefined
337
352
  }
338
353
  const prepared = await Promise.all(
339
- configPaths.map(async (configPath) => {
340
- const closure = closures.get(configPath)
341
- if (closure !== undefined && evalCache !== undefined && fastHashFile !== undefined) {
342
- const fastKey = await configEvalKeyFromClosure({
343
- closure,
344
- hashFile: fastHashFile,
345
- workspaceFingerprint: evalCache.workspaceFingerprint,
346
- })
347
- if (fastKey !== null) return { configPath, bytes: null, cacheKey: fastKey, indexed: true }
348
- }
349
- const bytes = await Bun.file(configPath).bytes()
350
- const keyed =
351
- evalCache === undefined
352
- ? null
353
- : await configEvalKey({
354
- configPath,
355
- bytes,
356
- workspaceRoot: evalCache.workspaceRoot,
357
- workspaceFingerprint: evalCache.workspaceFingerprint,
358
- ...slowKeyHash,
359
- })
360
- return {
361
- configPath,
362
- bytes,
363
- cacheKey: keyed?.key ?? null,
364
- indexed: false,
365
- closure: keyed !== null && keyed.indexable ? keyed.closure : undefined,
366
- }
354
+ configPaths.map((configPath): Prepared | Promise<Prepared> => {
355
+ const fastKey = fastKeyOf(configPath)
356
+ return fastKey !== null
357
+ ? { configPath, bytes: null, cacheKey: fastKey, indexed: true }
358
+ : slowPrepared(configPath)
367
359
  }),
368
360
  )
361
+ async function slowPrepared(configPath: string): Promise<Prepared> {
362
+ const closure = closures.get(configPath)
363
+ if (closure !== undefined && evalCache !== undefined && fastHashFile !== undefined) {
364
+ const fastKey = await configEvalKeyFromClosure({
365
+ closure,
366
+ hashFile: fastHashFile,
367
+ workspaceFingerprint: evalCache.workspaceFingerprint,
368
+ })
369
+ if (fastKey !== null) return { configPath, bytes: null, cacheKey: fastKey, indexed: true }
370
+ }
371
+ const bytes = await Bun.file(configPath).bytes()
372
+ const keyed =
373
+ evalCache === undefined
374
+ ? null
375
+ : await configEvalKey({
376
+ configPath,
377
+ bytes,
378
+ workspaceRoot: evalCache.workspaceRoot,
379
+ workspaceFingerprint: evalCache.workspaceFingerprint,
380
+ ...slowKeyHash,
381
+ })
382
+ return {
383
+ configPath,
384
+ bytes,
385
+ cacheKey: keyed?.key ?? null,
386
+ indexed: false,
387
+ closure: keyed !== null && keyed.indexable ? keyed.closure : undefined,
388
+ }
389
+ }
369
390
  let hits = new Map<string, string>()
370
391
  if (evalCache !== undefined) {
371
392
  const keys = prepared.map((p) => p.cacheKey).filter((k): k is string => k !== null)
@@ -462,7 +483,9 @@ export async function loadProjectConfigs(
462
483
  try {
463
484
  // Loaded LOAD_WIDTH at a time: one import after another put 1,000 cold
464
485
  // configs at ~160 ms of imports where 64 at once take ~60 (D-68), and
465
- // the width bounds the files a module load may hold open. A failure
486
+ // 128 took `load configs` from 432 to 411 ms more; the width bounds the
487
+ // files a module load may hold open, so it stays under macOS's default
488
+ // 256-descriptor limit. A failure
466
489
  // stops nothing (the rest are evaluated and stored for the next
467
490
  // attempt); the error thrown is the first in order, as the serial
468
491
  // loop's was.