@vzn/vx 0.0.354 → 0.0.356

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.
@@ -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
  }
@@ -493,11 +493,22 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
493
493
  // The local executor keeps its own timer (it signals the process group).
494
494
  let timeoutTimer: ReturnType<typeof setTimeout> | undefined
495
495
  let timeoutFired = false
496
+ // The attempt's listener on the run's stop signal, taken off once the
497
+ // attempt settles. Left on, every task of the run added one: the list
498
+ // grew to the task count, and each add scans it for a duplicate, ~100 ms
499
+ // of a 1,000-task cold run.
500
+ let unlistenStop: (() => void) | undefined
496
501
  function requestSignal(): AbortSignal {
497
502
  const stop = new AbortController()
498
503
  const abort = (): void => stop.abort(args.stopSignal?.reason)
504
+ unlistenStop?.()
505
+ unlistenStop = undefined
499
506
  if (args.stopSignal?.aborted === true) abort()
500
- else args.stopSignal?.addEventListener('abort', abort, { once: true })
507
+ else if (args.stopSignal !== undefined) {
508
+ const run = args.stopSignal
509
+ run.addEventListener('abort', abort, { once: true })
510
+ unlistenStop = () => run.removeEventListener('abort', abort)
511
+ }
501
512
  if (effectiveTimeout !== undefined) {
502
513
  clearTimeout(timeoutTimer)
503
514
  timeoutFired = false
@@ -801,6 +812,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
801
812
  })
802
813
  .finally(() => {
803
814
  clearTimeout(timeoutTimer)
815
+ unlistenStop?.()
816
+ unlistenStop = undefined
804
817
  flushMasked()
805
818
  })
806
819
  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
@@ -282,7 +282,14 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
282
282
  const where = `${configPath}: tasks.${name}`
283
283
  assertTaskName(name, configPath)
284
284
  if (!task || typeof task !== 'object') {
285
- throw new UserError(`${where} must be an object`)
285
+ // package.json's shape, `name: 'command'`, was refused without the
286
+ // object that holds the command.
287
+ const raw: unknown = task
288
+ throw new UserError(
289
+ typeof raw === 'string' && raw.trim() !== ''
290
+ ? `${where} must be an object — a command is { exec: { command: ${JSON.stringify(raw)} } }`
291
+ : `${where} must be an object`,
292
+ )
286
293
  }
287
294
  assertKnownFields(task, TASK_FIELDS, where)
288
295
  const exec = (task as { exec?: unknown }).exec
@@ -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
@@ -60,7 +60,6 @@ export function buildPackageGraph(
60
60
  // insurance against a future rule that does depend on it; it is not
61
61
  // what the stability rests on.
62
62
  const directDeps = new Map<string, string[]>()
63
- const reachDeps = new Map<string, string[]>()
64
63
  const order = new Map<string, Set<string>>()
65
64
  const peers = new Map<string, Set<string>>()
66
65
  const sorted = [...projects].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
@@ -128,22 +127,30 @@ export function buildPackageGraph(
128
127
  if (!own.has(peer) && !reaches(peer, p.name)) own.add(peer)
129
128
  }
130
129
  }
131
- for (const p of sorted) {
132
- const own = order.get(p.name)!
133
- directDeps.set(p.name, [...own].sort())
134
- reachDeps.set(p.name, [...new Set([...own, ...peers.get(p.name)!])].sort())
135
- }
130
+ for (const p of sorted) directDeps.set(p.name, [...order.get(p.name)!].sort())
136
131
 
137
- // Reverse adjacency: who declares X as a workspace dep.
132
+ // REACH and its reverse (who declares X as a workspace dep) are read only
133
+ // by the transitive questions below, which an unscoped run never asks:
134
+ // built on the first one, as the closures are.
135
+ const reachDeps = new Map<string, string[]>()
138
136
  const directDependents = new Map<string, string[]>()
139
- for (const [name, deps] of reachDeps) {
140
- for (const d of deps) {
141
- const arr = directDependents.get(d)
142
- if (arr) arr.push(name)
143
- else directDependents.set(d, [name])
137
+ let reachBuilt = false
138
+ const buildReach = (): void => {
139
+ if (reachBuilt) return
140
+ reachBuilt = true
141
+ for (const p of sorted) {
142
+ const own = order.get(p.name)!
143
+ reachDeps.set(p.name, [...new Set([...own, ...peers.get(p.name)!])].sort())
144
+ }
145
+ for (const [name, deps] of reachDeps) {
146
+ for (const d of deps) {
147
+ const arr = directDependents.get(d)
148
+ if (arr) arr.push(name)
149
+ else directDependents.set(d, [name])
150
+ }
144
151
  }
152
+ for (const arr of directDependents.values()) arr.sort()
145
153
  }
146
- for (const arr of directDependents.values()) arr.sort()
147
154
 
148
155
  // Set-union DFS closures are O(P²) entries on dense layered graphs
149
156
  // (same disease the scheduler's reachOf had — 68 ms at 1090
@@ -224,6 +231,7 @@ export function buildPackageGraph(
224
231
  return (name) => {
225
232
  const cached = memo.get(name)
226
233
  if (cached) return cached
234
+ buildReach()
227
235
  if (closures === undefined) closures = bitsetClosures(edges)
228
236
  if (closures === null) {
229
237
  const result = reachableFrom(name, edges)