@vzn/vx 0.0.349 → 0.0.350

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.349",
3
+ "version": "0.0.350",
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.349",
25
- "@vzn/vx-linux-arm64": "0.0.349",
26
- "@vzn/vx-darwin-x64": "0.0.349",
27
- "@vzn/vx-darwin-arm64": "0.0.349"
24
+ "@vzn/vx-linux-x64": "0.0.350",
25
+ "@vzn/vx-linux-arm64": "0.0.350",
26
+ "@vzn/vx-darwin-x64": "0.0.350",
27
+ "@vzn/vx-darwin-arm64": "0.0.350"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -605,6 +605,14 @@ export class Cache implements CacheLayer {
605
605
  // every open there failed `attempt to write a readonly database`
606
606
  // before the not-writable refusal could name the directory (O-10).
607
607
  if (!absent) this.db.fileControl(SQLITE_FCNTL_PERSIST_WAL, 1)
608
+ // Any non-negative limit makes the last clean close truncate a
609
+ // persistent `-wal` to zero bytes once it is checkpointed (SQLite's
610
+ // walClose). At the default (-1) it kept every frame, so each later
611
+ // connection recovered them at open and copied them into the
612
+ // database again at its close: 738 frames, 4.5 MB, on a 1,000-project
613
+ // cache. The file stays (O-10 above); 64 MiB is far above what a run
614
+ // writes, so the limit never trims a WAL mid-run.
615
+ this.db.exec('PRAGMA journal_size_limit = 67108864')
608
616
  this.db.exec('PRAGMA synchronous = NORMAL')
609
617
  this.db.exec('PRAGMA foreign_keys = ON')
610
618
  this.db.exec(`
@@ -853,6 +853,9 @@ export interface GitEnumeration {
853
853
  startedAtMs: number
854
854
  }
855
855
 
856
+ /** Above this many project dirs the enumeration walks the whole tree. */
857
+ export const MAX_SCOPED_PATHSPECS = 64
858
+
856
859
  /**
857
860
  * Pathspec scoping: when the run only needs a handful of projects
858
861
  * (scoped config loading), let git scan just those dirs — 75 ms →
@@ -868,7 +871,7 @@ export function gitPathspecs(
868
871
  const scoped =
869
872
  !workspaceWide &&
870
873
  rels.length > 0 &&
871
- rels.length <= 64 &&
874
+ rels.length <= MAX_SCOPED_PATHSPECS &&
872
875
  rels.every((r) => r !== '' && r !== '.')
873
876
  return scoped ? rels : ['.']
874
877
  }
@@ -49,6 +49,7 @@ export {
49
49
  GitFilesCache,
50
50
  applyGitEnumeration,
51
51
  gitPathspecs,
52
+ MAX_SCOPED_PATHSPECS,
52
53
  repoRootOf,
53
54
  startGitEnumeration,
54
55
  lazyGitEnumeration,
@@ -1032,27 +1032,67 @@ function refuseOneAlternativeBrace(
1032
1032
  }
1033
1033
  }
1034
1034
 
1035
- async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
1035
+ /** What a `files` declaration and its task's outputs compile to, whatever the project. */
1036
+ interface FilesPlan {
1037
+ positive: string[]
1038
+ negative: string[]
1039
+ excludeGlobs: Bun.Glob[]
1040
+ ownOutput: (rel: string) => boolean
1041
+ positiveGlobs: Bun.Glob[]
1042
+ /** The literal entries, each naming one path (see `unmatchedLiterals`). */
1043
+ literals: string[]
1044
+ }
1045
+
1046
+ /**
1047
+ * Compiled once per declaration for the life of the process: a workspace
1048
+ * declares a handful of `files` lists over thousands of tasks, and
1049
+ * splitting, normalizing and compiling them per task was ~6 ms of a
1050
+ * 1,000-task warm run (I-29). A refused declaration is never stored, so it
1051
+ * throws for every task that carries it.
1052
+ */
1053
+ const filesPlans = new Map<string, FilesPlan | null>()
1054
+
1055
+ function filesPlan(
1056
+ files: readonly string[] | undefined,
1057
+ ownOutputs: readonly string[],
1058
+ ): FilesPlan | null {
1059
+ const key = JSON.stringify([files ?? null, ownOutputs])
1060
+ const memo = filesPlans.get(key)
1061
+ if (memo !== undefined) return memo
1036
1062
  const positive: string[] = []
1037
1063
  const negative: string[] = []
1038
-
1039
- if (args.files === undefined) {
1064
+ if (files === undefined) {
1040
1065
  positive.push(...DEFAULT_FILE_GLOBS)
1041
1066
  } else {
1042
- refuseOneAlternativeBrace(args.files, 'files')
1043
- for (const entry of args.files) {
1067
+ refuseOneAlternativeBrace(files, 'files')
1068
+ for (const entry of files) {
1044
1069
  if (entry.startsWith('!')) negative.push(entry.slice(1))
1045
1070
  else positive.push(entry)
1046
1071
  }
1047
1072
  }
1073
+ const plan =
1074
+ positive.length === 0
1075
+ ? null
1076
+ : {
1077
+ positive,
1078
+ negative,
1079
+ excludeGlobs: [...ALWAYS_IGNORE, ...asTrees(negative)].map(globFor),
1080
+ // A path the task's own outputs take back with `!` is no output, so it
1081
+ // stays an input: a tracked file under `dist` the build reads (A-44).
1082
+ ownOutput: outputMatcher(ownOutputs, globFor),
1083
+ positiveGlobs: asTrees(positive).map(globFor),
1084
+ literals: positive.map(normalizeGlob).filter(isLiteralPattern).map(stripTrailingSlash),
1085
+ }
1086
+ filesPlans.set(key, plan)
1087
+ return plan
1088
+ }
1048
1089
 
1049
- if (positive.length === 0) return []
1090
+ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
1091
+ const plan = filesPlan(args.files, args.ownOutputs)
1092
+ if (plan === null) return []
1093
+ const { positive, negative, excludeGlobs, ownOutput, positiveGlobs } = plan
1050
1094
 
1051
1095
  const nested = inNestedProject(args.projectDir, args.nestedProjectDirs)
1052
- const excludeGlobs = [...ALWAYS_IGNORE, ...asTrees(negative)].map(globFor)
1053
- // A path the task's own outputs take back with `!` is no output, so it
1054
- // stays an input: a tracked file under `dist` the build reads (A-44).
1055
- const ownOutput = outputMatcher(args.ownOutputs, globFor)
1056
1096
 
1057
1097
  // Defer to git for the file set (Turbo / Nx parity). Nested .gitignore
1058
1098
  // files, .git/info/exclude, and global excludes all participate
@@ -1062,7 +1102,6 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
1062
1102
  // per task (build + test + lint + …). Spawning git N times for the
1063
1103
  // same project per run is wasteful; we cache the result for the
1064
1104
  // duration of one orchestrator run.
1065
- const positiveGlobs = asTrees(positive).map(globFor)
1066
1105
  // Everything below the snapshot that decides the result: the project, what
1067
1106
  // it declares, what it excludes as its own outputs, and the boundaries.
1068
1107
  const memoKey = `${args.projectDir}\0${positive.join('\u0001')}\0${negative.join('\u0001')}\0${args.ownOutputs.join('\u0001')}\0${args.nestedProjectDirs.join('\u0001')}`
@@ -1102,9 +1141,7 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
1102
1141
  // an artifact built from an older version of a file the config explicitly
1103
1142
  // claims as an input. See the refusal below for why this is not simply
1104
1143
  // honoured instead.
1105
- const unmatchedLiterals = new Set(
1106
- positive.map(normalizeGlob).filter(isLiteralPattern).map(stripTrailingSlash),
1107
- )
1144
+ const unmatchedLiterals = new Set(plan.literals)
1108
1145
  // First pass: glob-filter to candidate absolute paths (no I/O).
1109
1146
  const candidates: string[] = []
1110
1147
  for (const rel of gitFiles) {
@@ -742,20 +742,19 @@ function canonicalBaselines(
742
742
  * only if it exists in the new root, and otherwise `$HOME`, with no word:
743
743
  * a project granted no read ran in the home directory, where `cat x.txt`
744
744
  * read `~/x.txt`. A grant at or above the cwd holds it, and so does an
745
- * existing one below (bwrap builds the path to a bind), and so does a
746
- * deny that IS the cwd (a single-package workspace's anchor). When none does,
745
+ * existing one below (bwrap builds the path to a bind). When none does,
747
746
  * the cwd is denied instead: an empty directory the task enters, whose
748
- * reads are refused and reported as the anchor's are.
747
+ * reads are refused and reported as the anchor's are. A single-package
748
+ * workspace's cwd is its anchor, denied already; the runtime takes the
749
+ * second entry as the same mount.
749
750
  */
750
751
  function cwdMounted(
751
752
  cwd: string,
752
753
  fs: {
753
754
  allowRead?: readonly string[] | undefined
754
755
  allowWrite?: readonly string[] | undefined
755
- denyRead?: readonly string[] | undefined
756
756
  },
757
757
  ): boolean {
758
- if (fs.denyRead?.includes(cwd) === true) return true
759
758
  return [...(fs.allowRead ?? []), ...(fs.allowWrite ?? [])].some(
760
759
  (p) => atOrUnder(cwd, p) || (atOrUnder(p, cwd) && existsSync(p)),
761
760
  )
@@ -2076,12 +2075,20 @@ async function straceState(): Promise<{ form: false | 'plain' | 'seccomp'; why:
2076
2075
  } else {
2077
2076
  const m = /version (\d+)\.(\d+)/.exec(out)
2078
2077
  const [major, minor] = m ? [Number(m[1]), Number(m[2])] : [0, 0]
2079
- const form = major > 5 || (major === 5 && minor >= 3) ? 'seccomp' : 'plain'
2080
- const refused = await traceRefusal(form)
2078
+ let form: 'plain' | 'seccomp' = major > 5 || (major === 5 && minor >= 3) ? 'seccomp' : 'plain'
2079
+ let refused = await traceRefusal(form)
2080
+ // A strace that cannot check the seccomp filter says so and traces on
2081
+ // without it, exit 0. Inside the sandbox that line read as the trace
2082
+ // cut short, and every sandboxed task ran twice: the plain form, if
2083
+ // it is quiet, is the one that works here.
2084
+ if (refused?.warned === true && form === 'seccomp') {
2085
+ refused = await traceRefusal('plain')
2086
+ if (refused === null) form = 'plain'
2087
+ }
2081
2088
  straceAvailableCache =
2082
2089
  refused === null
2083
2090
  ? { form, why: '' }
2084
- : { form: false, why: `strace cannot trace here (${refused})` }
2091
+ : { form: false, why: `strace cannot trace here (${refused.line})` }
2085
2092
  }
2086
2093
  } catch {
2087
2094
  // Not on PATH. Said as the refused attach is: without it an undeclared
@@ -2111,14 +2118,17 @@ export async function untracedReason(): Promise<string | null> {
2111
2118
 
2112
2119
  /**
2113
2120
  * Why strace may not attach here (null when it may), asked once with the flags a task's trace
2114
- * uses (`ownGroupCommand`). `--version` answers on a host that refuses
2121
+ * uses (`ownGroupCommand`). A strace that exits 0 having said something of
2122
+ * its own is refused too (`warned`): in a task, its line is the retry key. `--version` answers on a host that refuses
2115
2123
  * ptrace (Yama's `ptrace_scope` 2 or 3, a container's seccomp profile),
2116
2124
  * and there every sandboxed task failed twice, the retry included, on
2117
2125
  * `attach: ptrace(PTRACE_SEIZE…): Operation not permitted`. Refused, the
2118
2126
  * run goes untraced, as without strace: bwrap still enforces, and only the
2119
2127
  * read-violation report is lost, which is said once.
2120
2128
  */
2121
- async function traceRefusal(form: 'plain' | 'seccomp'): Promise<string | null> {
2129
+ async function traceRefusal(
2130
+ form: 'plain' | 'seccomp',
2131
+ ): Promise<{ line: string; warned: boolean } | null> {
2122
2132
  const p = Bun.spawn(
2123
2133
  [
2124
2134
  executablePath('strace'),
@@ -2136,8 +2146,9 @@ async function traceRefusal(form: 'plain' | 'seccomp'): Promise<string | null> {
2136
2146
  { stdin: 'ignore', stdout: 'ignore', stderr: 'pipe' },
2137
2147
  )
2138
2148
  const err = await new Response(p.stderr).text()
2139
- if ((await p.exited) === 0) return null
2140
- return err.trim().split('\n')[0] || `exit ${p.exitCode}`
2149
+ const line = err.split('\n').find((l) => STRACE_OWN_ERROR.test(l))
2150
+ if ((await p.exited) === 0) return line === undefined ? null : { line, warned: true }
2151
+ return { line: err.trim().split('\n')[0] || `exit ${p.exitCode}`, warned: false }
2141
2152
  }
2142
2153
  let warnedNoTrace = false
2143
2154
 
@@ -468,6 +468,19 @@ export function formatPersistentTailBlock(
468
468
  return lines.join('\n') + '\n'
469
469
  }
470
470
 
471
+ /**
472
+ * What a kept server writes after the summary, one line at a time under its
473
+ * id (`app#dev │ Local: http://localhost:5173`), so several servers'
474
+ * logs read apart. `text` holds whole lines, its last newline cut off.
475
+ */
476
+ export function formatKeptLines(node: TaskNode, text: string, colors: ColorSupport): string {
477
+ const prefix = `${paintTaskId(node, colors)} ${paint('', '│', colors, { dim: true })} `
478
+ return text
479
+ .split('\n')
480
+ .map((line) => `${prefix}${line}\n`)
481
+ .join('')
482
+ }
483
+
471
484
  /**
472
485
  * Between the requested task's frame and the summary footer, list the
473
486
  * persistent tasks (dev servers / watchers) the run is keeping alive in
@@ -4,6 +4,7 @@ import {
4
4
  formatFailureRecap,
5
5
  formatFrameClose,
6
6
  formatFrameOpen,
7
+ formatKeptLines,
7
8
  formatPersistentTailBlock,
8
9
  formatTaskBlock,
9
10
  formatTaskExecutedLine,
@@ -318,6 +319,39 @@ export function defaultLogger(
318
319
  { node: TaskNode; outcome?: TaskOutcome; out: Tail; err: Tail }
319
320
  >()
320
321
  let flushedPersistent = false
322
+ // After that flush nothing prints a tail again, yet a kept server writes
323
+ // for as long as vx holds it: `vx run dev --all` showed none of its
324
+ // servers' logs while it ran (C-56). So a ready server's output streams
325
+ // from then on, a line at a time under its id; the partial line each
326
+ // holds waits for its newline, a second runEnd, or `settle`.
327
+ const KEPT_LINE_CAP = 64 * 1024
328
+ const keptLines = new Map<string, { node: TaskNode; rest: string }>()
329
+ const streamKept = (node: TaskNode, chunk: string): void => {
330
+ const held = keptLines.get(node.id) ?? { node, rest: '' }
331
+ keptLines.set(node.id, held)
332
+ const text = held.rest + chunk
333
+ const cut = text.lastIndexOf('\n')
334
+ if (cut < 0) {
335
+ // A line that never ends (a `\r` progress bar) is printed at the cap
336
+ // rather than held without bound.
337
+ if (text.length < KEPT_LINE_CAP) held.rest = text
338
+ else {
339
+ held.rest = ''
340
+ writer.write(fenced(formatKeptLines(node, text, colors)))
341
+ }
342
+ return
343
+ }
344
+ held.rest = text.slice(cut + 1)
345
+ writer.write(fenced(formatKeptLines(node, text.slice(0, cut), colors)))
346
+ }
347
+ const flushKept = (): void => {
348
+ for (const held of keptLines.values()) {
349
+ if (held.rest.length > 0) writer.write(fenced(formatKeptLines(held.node, held.rest, colors)))
350
+ held.rest = ''
351
+ }
352
+ }
353
+ const printsKept = (tail: { outcome?: TaskOutcome }): boolean =>
354
+ flushedPersistent && tail.outcome !== undefined && view.mode !== 'errors-only'
321
355
  // The recap's material, decided at each failure so nothing is held for a
322
356
  // task that passed. A buffered task's tail is read off the buffers the
323
357
  // frame drains; a live-streamed one keeps no buffer, so a bounded ring
@@ -411,6 +445,8 @@ export function defaultLogger(
411
445
 
412
446
  return {
413
447
  settle() {
448
+ // The bus delivers one `run:end`, so the CLI's last word reaches here.
449
+ flushKept()
414
450
  writer.settle()
415
451
  },
416
452
  failureRecap() {
@@ -483,6 +519,7 @@ export function defaultLogger(
483
519
  // like the failures below: run() calls runEnd twice on the success
484
520
  // path (once before the summary, once in its finally), and a
485
521
  // kept-alive child keeps writing between the two.
522
+ if (flushedPersistent) flushKept()
486
523
  if (!flushedPersistent) {
487
524
  flushedPersistent = true
488
525
  if (view.mode !== 'none' && view.mode !== 'errors-only') {
@@ -533,7 +570,8 @@ export function defaultLogger(
533
570
  }
534
571
  const tail = persistentTails.get(node.id)
535
572
  if (tail !== undefined) {
536
- appendTail(tail.out, chunk)
573
+ if (printsKept(tail)) streamKept(node, chunk)
574
+ else appendTail(tail.out, chunk)
537
575
  return
538
576
  }
539
577
  pushChunk(stdoutBuffers, node.id, chunk)
@@ -550,7 +588,8 @@ export function defaultLogger(
550
588
  }
551
589
  const tail = persistentTails.get(node.id)
552
590
  if (tail !== undefined) {
553
- appendTail(tail.err, chunk)
591
+ if (printsKept(tail)) streamKept(node, chunk)
592
+ else appendTail(tail.err, chunk)
554
593
  return
555
594
  }
556
595
  pushChunk(stderrBuffers, node.id, chunk)
@@ -180,9 +180,10 @@ export interface RunOptions {
180
180
  */
181
181
  signal?: AbortSignal
182
182
  /**
183
- * Hand the requested persistent tasks still running, and the persistent
184
- * tasks they depend on (C-46), back on `RunSummary.persistent` instead
185
- * of stopping them when the graph ends.
183
+ * Hand the requested persistent tasks still running, those a requested
184
+ * group stands for (C-52), and the persistent tasks they depend on
185
+ * (C-46), back on `RunSummary.persistent` instead of stopping them when
186
+ * the graph ends.
186
187
  * The caller owns them from then on and stops them with its `stop()`.
187
188
  * The watch loop sets this: a dev server stays up while watch idles and
188
189
  * is replaced only when the next cycle starts.
@@ -386,12 +386,13 @@ export function buildAdmission(
386
386
  }
387
387
 
388
388
  /**
389
- * Collect every plugin's `cache` layer in declaration order. One layer is
390
- * used as is; two or more are chained (lookup walks them, save reaches all;
391
- * see ChainedCache). A bare local layer that another declared layer already
392
- * wraps (`layer.local === ctx.localCache`) is dropped, so a remote plugin
393
- * that layers over the local handle does not also write the local store
394
- * directly. A plugin declaring nothing leaves that store, unwrapped.
389
+ * Collect every plugin's `cache` layer in declaration order, with the local
390
+ * store at the tail, and chain them (lookup walks them, save reaches all;
391
+ * see ChainedCache). The local store is dropped when a declared layer wraps
392
+ * it (`layer.local === ctx.localCache`), so a remote plugin that layers over
393
+ * the local handle does not also write it directly. Only a single layer
394
+ * left is used as is: the local store when no plugin declares one, or the
395
+ * one layer that wraps it.
395
396
  */
396
397
  export async function resolveCache(
397
398
  plugins: readonly VxPlugin[],
@@ -20,6 +20,7 @@ import {
20
20
  LayeredCache,
21
21
  applyGitEnumeration,
22
22
  gitPathspecs,
23
+ MAX_SCOPED_PATHSPECS,
23
24
  startGitEnumeration,
24
25
  lazyGitEnumeration,
25
26
  } from '../cache/index.js'
@@ -181,9 +182,13 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
181
182
  // config, discovery, the cache open and the config evaluation. Its
182
183
  // ~60 ms is the warm run's wall floor on a 1000-project tree; it used to
183
184
  // start after discovery and the cache open, ~25 ms later. A scoped run
184
- // waits: its pathspecs depend on which projects the configs pull in.
185
- const unscoped =
186
- options.projects === undefined && options.tasks.some((spec) => spec.indexOf('#') <= 0)
185
+ // waits: its pathspecs depend on which projects the configs pull in —
186
+ // unless it already names more projects than pathspecs scope, where the
187
+ // walk is the whole tree either way (an `--affected` run on 1,000
188
+ // projects waited ~60 ms for it after the configs loaded).
189
+ const wholeTree =
190
+ (options.projects === undefined || options.projects.length > MAX_SCOPED_PATHSPECS) &&
191
+ options.tasks.some((spec) => spec.indexOf('#') <= 0)
187
192
  // A `discover` hook that reads the worktree (nx()'s graph key) starts the
188
193
  // whole-tree enumeration a scoped run would otherwise scope later, here or
189
194
  // in the CLI's selection pass; the run reuses it instead of walking the
@@ -192,7 +197,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
192
197
  const git =
193
198
  (reused !== undefined ? gitOfDiscovery(reused.projects) : undefined) ??
194
199
  lazyGitEnumeration(workspaceRoot)
195
- if (unscoped) void git.start()
200
+ if (wholeTree) void git.start()
196
201
  const workspace = await loadWorkspace(workspaceRoot, reads)
197
202
  const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, (m) =>
198
203
  log.status(m),
@@ -592,7 +592,8 @@ async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]>
592
592
  /**
593
593
  * Resolve the default base for `--affected` with no explicit value.
594
594
  * Tries the remote's HEAD branch first (`origin/main`, `origin/master`,
595
- * etc.), then falls back to `HEAD~1`. A clone with no `origin/HEAD` and
595
+ * etc.), then `origin/main`, `origin/master`, `main`, `master`, then
596
+ * falls back to `HEAD~1`. A clone with no `origin/HEAD` and
596
597
  * no parent commit — a CI checkout at `fetch-depth: 1` — has no base at
597
598
  * all, and says so here rather than failing on a ref nobody typed.
598
599
  */
@@ -609,6 +610,12 @@ export async function defaultAffectedBase(workspaceRoot: string): Promise<string
609
610
  if (probe.exitCode === 0 && out.length > 0 && revParse(workspaceRoot, out) !== undefined) {
610
611
  return out
611
612
  }
613
+ // No origin/HEAD: `git remote add` + fetch, as actions/checkout does, sets
614
+ // none, and `HEAD~1` saw only a feature branch's last commit where Turbo
615
+ // and Nx compare with `main` (D-93). The usual trunk names, remote first;
616
+ // one that IS HEAD (a push to main) falls through to `HEAD~1` as before.
617
+ const trunk = trunkBase(workspaceRoot)
618
+ if (trunk !== undefined) return trunk
612
619
  if (revParse(workspaceRoot, 'HEAD~1') === undefined) {
613
620
  throw new UserError(
614
621
  '--affected has no base here: origin/HEAD is not set (or names a branch that is gone) and HEAD has no parent to compare ' +
@@ -619,6 +626,32 @@ export async function defaultAffectedBase(workspaceRoot: string): Promise<string
619
626
  return 'HEAD~1'
620
627
  }
621
628
 
629
+ const TRUNKS = ['origin/main', 'origin/master', 'main', 'master']
630
+
631
+ /** The first of `TRUNKS` that exists and is not HEAD's commit, in one spawn. */
632
+ function trunkBase(workspaceRoot: string): string | undefined {
633
+ const proc = spawnGitSync(
634
+ [
635
+ 'for-each-ref',
636
+ '--format=%(refname:short) %(objectname)',
637
+ '--end-of-options',
638
+ ...TRUNKS.map((t) => (t.startsWith('origin/') ? `refs/remotes/${t}` : `refs/heads/${t}`)),
639
+ ],
640
+ workspaceRoot,
641
+ 'ignore',
642
+ )
643
+ if (proc.exitCode !== 0) return undefined
644
+ const at = new Map(
645
+ new TextDecoder()
646
+ .decode(proc.stdout)
647
+ .split('\n')
648
+ .filter((l) => l !== '')
649
+ .map((l) => l.split(' ') as [string, string]),
650
+ )
651
+ const head = revParse(workspaceRoot, 'HEAD')
652
+ return TRUNKS.find((t) => at.has(t) && at.get(t) !== head)
653
+ }
654
+
622
655
  /**
623
656
  * Does `ref` name the commit HEAD is on? A base that IS HEAD can never
624
657
  * mark anything affected — the shape of a single-branch clone whose
@@ -742,6 +742,16 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
742
742
  '`exec.command`: vx runs one shell command (`nx()` from `@vzn/vx-migrate` runs an Nx executor as one)',
743
743
  options: '`exec.command` (a run-commands `options.command`) and `exec.env`',
744
744
  outputLogs: 'the `--output-logs` flag of `vx run`',
745
+ // Turbo's `interactive` / `with`, Nx's `cwd` / `parallelism` /
746
+ // `configurations`: refused with no word on where they went (D-89).
747
+ interactive:
748
+ 'a command run outside vx: a task never reads the terminal (its stdin is EOF, or a pipe vx holds under `exec.persistent`)',
749
+ with: '`dependsOn` on each task it runs beside: a persistent one stays up, and this task starts once it is ready',
750
+ cwd: '`cd <dir> && …` in `exec.command`: a task runs in its project directory',
751
+ parallelism:
752
+ '`concurrency: 1` in vx.workspace or `--concurrency 1` on the run: no task runs alone beside others',
753
+ configurations:
754
+ 'one task per configuration (`build:production`, with its own `exec.command`)',
745
755
  },
746
756
  ],
747
757
  [EXEC_FIELDS, { cmd: '`command`', script: '`command`' }],
@@ -155,7 +155,10 @@ function ownerOf(dir: string, memo: Map<string, Owner>): Owner {
155
155
  try {
156
156
  const pm = (JSON.parse(read('package.json') ?? '') as { packageManager?: unknown })
157
157
  .packageManager
158
- if (typeof pm === 'string')
158
+ // A manager vx knows nothing of (zod's `nub@0.8.3`) says nothing about
159
+ // hooks: the lockfile beside it does, and "nub ran `postbuild`" was a
160
+ // claim nothing had checked (D-96).
161
+ if (typeof pm === 'string' && /^(npm|pnpm|yarn|bun)@/.test(pm))
159
162
  manager = /^yarn@([2-9]|\d{2,})/.test(pm) ? 'berry' : pm.split('@')[0]
160
163
  } catch {}
161
164
  if (manager === undefined) {
@@ -342,6 +345,44 @@ function cdsToMembers(script: string, rootDir: string, memberDirs: readonly stri
342
345
  const RUNS_SCRIPT =
343
346
  /(?:^|[\s;&|(])(?:pnpm|npm|yarn|bun)\s+(?:run(?:-script)?\s+)?([^\s;&|()'"-][^\s;&|()'"]*)/g
344
347
 
348
+ /**
349
+ * The scripts a command runs by name: a package manager's (`pnpm x`, `npm
350
+ * run x`), `run-s` / `run-p` / `npm-run-all` (`build:*` is one segment,
351
+ * `build:**` any), and `concurrently "npm:x"`. A root `ci: run-s build:all
352
+ * lint` over `build:all: pnpm -r build` mapped as a root task (D-95).
353
+ */
354
+ function scriptRefs(command: string, scripts: readonly (readonly [string, string])[]): string[] {
355
+ const refs = [...command.matchAll(RUNS_SCRIPT)].map((m) => m[1]!)
356
+ for (const m of command.matchAll(/["']?\b(?:npm|pnpm|yarn|bun):([^\s"']+)/g)) refs.push(m[1]!)
357
+ for (const segment of command.split(/&&|\|\||[;|()]/)) {
358
+ const words = segment.trim().split(/\s+/)
359
+ const at = words.findIndex((w) => /^(run-s|run-p|npm-run-all)$/.test(w))
360
+ if (at < 0) continue
361
+ for (const w of words.slice(at + 1)) {
362
+ if (w.startsWith('-')) continue
363
+ const name = w.replace(/^(["'])(.*)\1$/, '$2')
364
+ if (!name.includes('*')) {
365
+ refs.push(name)
366
+ continue
367
+ }
368
+ const re = new RegExp(
369
+ `^${name
370
+ .split(/(\*\*|\*)/)
371
+ .map((part) =>
372
+ part === '**'
373
+ ? '.*'
374
+ : part === '*'
375
+ ? '[^:]*'
376
+ : part.replace(/[.+?^${}()|[\]\\]/g, '\\$&'),
377
+ )
378
+ .join('')}$`,
379
+ )
380
+ for (const [n] of scripts) if (re.test(n)) refs.push(n)
381
+ }
382
+ }
383
+ return refs
384
+ }
385
+
345
386
  /**
346
387
  * The scripts that run the members, directly or through another of these
347
388
  * scripts: vite's `ci-docs` (`pnpm build && pnpm docs-build`) runs each
@@ -367,7 +408,7 @@ function runningMembers(
367
408
  grew = false
368
409
  for (const [n, v] of text) {
369
410
  if (out.has(n)) continue
370
- if ([...v.matchAll(RUNS_SCRIPT)].some((m) => out.has(m[1]!))) {
411
+ if (scriptRefs(v, text).some((r) => out.has(r))) {
371
412
  out.add(n)
372
413
  grew = true
373
414
  }