@vzn/vx 0.0.345 → 0.0.347

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.345",
3
+ "version": "0.0.347",
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.345",
25
- "@vzn/vx-linux-arm64": "0.0.345",
26
- "@vzn/vx-darwin-x64": "0.0.345",
27
- "@vzn/vx-darwin-arm64": "0.0.345"
24
+ "@vzn/vx-linux-x64": "0.0.347",
25
+ "@vzn/vx-linux-arm64": "0.0.347",
26
+ "@vzn/vx-darwin-x64": "0.0.347",
27
+ "@vzn/vx-darwin-arm64": "0.0.347"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -841,6 +841,12 @@ export interface GitEnumeration {
841
841
  * paths (both sides of a rename) and untracked files. Null when it failed.
842
842
  */
843
843
  changed: readonly string[] | null
844
+ /**
845
+ * What `git status` listed as untracked, workspace-relative and before
846
+ * nested repositories are expanded — `git ls-files --others
847
+ * --exclude-standard`'s set. Null when it failed.
848
+ */
849
+ untracked: readonly string[] | null
844
850
  /** Workspace-relative paths whose names are not UTF-8 (`decodeGitZ`). */
845
851
  undecodable: readonly string[]
846
852
  /** `Date.now()` before the spawns: what `trusted` says is true as of no earlier. */
@@ -1048,6 +1054,7 @@ export async function startGitEnumeration(
1048
1054
  trusted,
1049
1055
  dirty: worktreeDirty,
1050
1056
  changed: dirty === null ? null : [...dirty, ...untracked],
1057
+ untracked: dirty === null ? null : untracked,
1051
1058
  undecodable,
1052
1059
  startedAtMs,
1053
1060
  }
package/src/cli/select.ts CHANGED
@@ -26,7 +26,7 @@ import type { ProjectConfig } from '../config.js'
26
26
  import type { ProjectEntry } from '../workspace/index.js'
27
27
  import { parseDependencySpec } from '../graph/index.js'
28
28
  import { nearest, UserError } from '../util/index.js'
29
- import { claimedAffected, fingerprintClaims } from '../orchestrator/index.js'
29
+ import { claimedAffected, fingerprintClaims, gitOfDiscovery } from '../orchestrator/index.js'
30
30
  import {
31
31
  type CliLoadOptions,
32
32
  discoverCliProjects,
@@ -230,6 +230,11 @@ export async function resolveFilters(
230
230
  return { error: err instanceof Error ? err.message : String(err) }
231
231
  }
232
232
  const walksGraph = parsed.some((f) => f.withDeps || f.withDependents || f.onlyDeps)
233
+ // A filter that diffs against git starts the run's whole-tree walk now:
234
+ // the diff reads its untracked files from it (one walk where it spawned
235
+ // its own), and the run reuses it with the discovery (I-26).
236
+ const git = parsed.some((f) => f.gitSince !== undefined) ? gitOfDiscovery(projects) : undefined
237
+ void git?.start()
233
238
  // Every reader of the staged configs in this pass — the `pkg#task`
234
239
  // edge walk, the `workspaceFiles` owners of a changed path — shares
235
240
  // ONE load, and the run reuses it (`RunOptions.staged`): the `project`
@@ -262,6 +267,7 @@ export async function resolveFilters(
262
267
  workspaceGlobOwners(root, projects, changed, load, stagedOnce),
263
268
  fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
264
269
  taskEdges: async () => edges ?? taskEdgesFrom(await stagedOnce()),
270
+ ...(git !== undefined ? { untracked: async () => (await git.start()).untracked } : {}),
265
271
  })
266
272
  affectedByFilter.set(f, names)
267
273
  } catch (err) {
@@ -12,7 +12,6 @@ import { Cache, noteSchemaReset } from '../cache/index.js'
12
12
  import { discoverProjects, loadProjects, loadWorkspacePlugins } from '../orchestrator/index.js'
13
13
  import type { VxPlugin } from '../orchestrator/index.js'
14
14
  import {
15
- buildPackageGraph,
16
15
  computeWorkspaceFingerprint,
17
16
  FROZEN_WITHOUT_LOCK,
18
17
  type Workspace,
@@ -140,7 +139,6 @@ export async function loadCliProjects(
140
139
  cacheDir,
141
140
  plugins,
142
141
  projectMetas: metas,
143
- packageGraph: buildPackageGraph([...metas]),
144
142
  seeds: scope,
145
143
  closure: false,
146
144
  lock,
@@ -206,6 +206,10 @@ const SHELL_BUILTINS = new Set([
206
206
  'let',
207
207
  'declare',
208
208
  'typeset',
209
+ // bash's reserved words that may open a one-line command; macOS's `sh`
210
+ // is bash, and `exec [[ … ]]` there is "exec: [[: not found".
211
+ '[[',
212
+ 'time',
209
213
  ])
210
214
 
211
215
  /**
@@ -1125,7 +1125,9 @@ function ownGroupCommand(
1125
1125
  ...(trace === 'seccomp' ? ['--seccomp-bpf'] : []),
1126
1126
  '-qq',
1127
1127
  '-e',
1128
- 'trace=openat',
1128
+ // A process's cwd moves on `chdir` and starts as its parent's at the
1129
+ // fork (`deniedCalls`); `?` lets an arch without `fork` skip it.
1130
+ TRACED_CALLS,
1129
1131
  '-o',
1130
1132
  `/dev/fd/${TRACE_FD}`,
1131
1133
  '--',
@@ -1141,6 +1143,9 @@ function ownGroupCommand(
1141
1143
  return { command: `${tag0} ${run} ${watch} wait "$c"`, forwards: true, traced: true }
1142
1144
  }
1143
1145
 
1146
+ /** What strace stops on: the reads, and what moves or makes a process's cwd. */
1147
+ const TRACED_CALLS = 'trace=openat,chdir,fchdir,clone,?clone3,?fork,?vfork'
1148
+
1144
1149
  /** The descriptor an in-sandbox strace writes its trace to (`ownGroupCommand`). */
1145
1150
  const TRACE_FD = 5
1146
1151
 
@@ -2122,7 +2127,7 @@ async function traceRefusal(form: 'plain' | 'seccomp'): Promise<string | null> {
2122
2127
  ...(form === 'seccomp' ? ['--seccomp-bpf'] : []),
2123
2128
  '-qq',
2124
2129
  '-e',
2125
- 'trace=openat',
2130
+ TRACED_CALLS,
2126
2131
  '-o',
2127
2132
  '/dev/null',
2128
2133
  '--',
@@ -88,22 +88,71 @@ export interface DeniedCall {
88
88
  syscall: string
89
89
  rawPath: string
90
90
  errno: string
91
+ /**
92
+ * The directory a relative `rawPath` was opened from, when the trace
93
+ * shows the process (or one it was forked from) changed into one; absent
94
+ * where it never did, or where it lost track (`fchdir`).
95
+ */
96
+ dir?: string
91
97
  }
92
98
 
99
+ // The calls that move a process's cwd, and the ones that make a process:
100
+ // a child starts in its parent's cwd as it was at the fork.
101
+ const CHDIR_DONE_RE = new RegExp(`^(\\d+)\\s+chdir\\(${QUOTED}\\)\\s*=\\s*0`)
102
+ const CHDIR_UNFINISHED_RE = new RegExp(`^(\\d+)\\s+chdir\\(${QUOTED} <unfinished`)
103
+ const CHDIR_RESUMED_RE = /^(\d+)\s+<\.\.\. chdir resumed>.*?=\s*(-?\d+)/
104
+ const FCHDIR_RE = /^(\d+)\s+(?:fchdir\(\d+\)|<\.\.\. fchdir resumed>.*?)\s*=\s*0/
105
+ const FORK = '(?:clone3?|v?fork)'
106
+ const FORK_DONE_RE = new RegExp(`^(\\d+)\\s+${FORK}\\(.*\\)\\s*=\\s*(\\d+)$`)
107
+ const FORK_UNFINISHED_RE = new RegExp(`^(\\d+)\\s+${FORK}\\(.*<unfinished`)
108
+ const FORK_RESUMED_RE = new RegExp(`^(\\d+)\\s+<\\.\\.\\. ${FORK} resumed>.*?=\\s*(\\d+)$`)
109
+
110
+ /** What a process did, in order, as far as its cwd and its denials go. */
111
+ type Op = { chdir: string } | { lost: true } | { call: number }
112
+
93
113
  /**
94
114
  * Walk the trace, pairing `<unfinished ...>` with its `<... resumed>` line.
115
+ * Given the task's starting `cwd`, also follow each process's `chdir` and
116
+ * forks, so a denial of `secret.txt` after `cd src` is `src/secret.txt`:
117
+ * resolved against the starting cwd, it named a file that does not exist
118
+ * and no `ignore` pattern for the real one matched. Resolved after the
119
+ * whole pass: a `vfork` child's lines come before its parent's
120
+ * `resumed` line names it. (`-y` names the directory on every line, but
121
+ * cost 40% on 2,000 opens; this costs a stop per process.)
95
122
  *
96
123
  * Exported for testing: this is the security-relevant half of the Linux
97
124
  * detector, and a synthetic trace pins the split-line shapes deterministically
98
125
  * where an end-to-end run only produces them when strace happens to interleave.
99
126
  */
100
- export function deniedCalls(text: string): DeniedCall[] {
127
+ export function deniedCalls(text: string, cwd?: string): DeniedCall[] {
101
128
  const pending = new Map<string, { syscall: string; rawPath: string }>()
102
129
  const out: DeniedCall[] = []
130
+ const ops = new Map<string, Op[]>()
131
+ // A clone with CLONE_FS (every thread) shares its creator's cwd, so a
132
+ // `chdir` by either moves both: such a pid keeps the creator's list.
133
+ const sharing = new Map<string, string>()
134
+ const owner = (pid: string): string => sharing.get(pid) ?? pid
135
+ const opsOf = (pid: string): Op[] => {
136
+ let list = ops.get(owner(pid))
137
+ if (list === undefined) ops.set(owner(pid), (list = []))
138
+ return list
139
+ }
140
+ const parent = new Map<string, { pid: string; at: number }>()
141
+ const forking = new Map<string, { at: number; shared: boolean }>()
142
+ const forked = (from: string, child: string, at: number, shared: boolean): void => {
143
+ // Shared only while the child has done nothing of its own yet.
144
+ if (shared && !ops.has(child)) sharing.set(child, owner(from))
145
+ else parent.set(child, { pid: owner(from), at })
146
+ }
147
+ const chdirring = new Map<string, string>()
148
+ const denied = (pid: string, call: DeniedCall): void => {
149
+ opsOf(pid).push({ call: out.length })
150
+ out.push(call)
151
+ }
103
152
  for (const line of text.split('\n')) {
104
153
  const done = STRACE_DONE_RE.exec(line)
105
154
  if (done?.[2] !== undefined && done[3] !== undefined && done[4] !== undefined) {
106
- out.push({ syscall: done[2], rawPath: cStringPath(done[3]), errno: done[4] })
155
+ denied(done[1]!, { syscall: done[2], rawPath: cStringPath(done[3]), errno: done[4] })
107
156
  continue
108
157
  }
109
158
  const unfinished = STRACE_UNFINISHED_RE.exec(line)
@@ -116,15 +165,61 @@ export function deniedCalls(text: string): DeniedCall[] {
116
165
  continue
117
166
  }
118
167
  const resumedOk = STRACE_RESUMED_OK_RE.exec(line)
119
- if (resumedOk?.[1] === undefined) continue
120
- const held = pending.get(resumedOk[1])
121
- pending.delete(resumedOk[1])
122
- const resumed = STRACE_RESUMED_RE.exec(line)
123
- // Only a resume that carries a DENIAL is a violation; a successful
124
- // resume just retires the pending entry.
125
- if (held !== undefined && resumed?.[3] !== undefined) {
126
- out.push({ syscall: held.syscall, rawPath: held.rawPath, errno: resumed[3] })
168
+ if (resumedOk?.[1] !== undefined) {
169
+ const held = pending.get(resumedOk[1])
170
+ pending.delete(resumedOk[1])
171
+ const resumed = STRACE_RESUMED_RE.exec(line)
172
+ // Only a resume that carries a DENIAL is a violation; a successful
173
+ // resume just retires the pending entry.
174
+ if (held !== undefined && resumed?.[3] !== undefined) {
175
+ denied(resumedOk[1], { ...held, errno: resumed[3] })
176
+ }
177
+ continue
127
178
  }
179
+ if (cwd === undefined) continue
180
+ let m: RegExpExecArray | null
181
+ if ((m = CHDIR_DONE_RE.exec(line)) !== null) opsOf(m[1]!).push({ chdir: cStringPath(m[2]!) })
182
+ else if ((m = CHDIR_UNFINISHED_RE.exec(line)) !== null) chdirring.set(m[1]!, cStringPath(m[2]!))
183
+ else if ((m = CHDIR_RESUMED_RE.exec(line)) !== null) {
184
+ const to = chdirring.get(m[1]!)
185
+ chdirring.delete(m[1]!)
186
+ if (to !== undefined && m[2] === '0') opsOf(m[1]!).push({ chdir: to })
187
+ } else if ((m = FCHDIR_RE.exec(line)) !== null) opsOf(m[1]!).push({ lost: true })
188
+ else if ((m = FORK_DONE_RE.exec(line)) !== null) {
189
+ forked(m[1]!, m[2]!, opsOf(m[1]!).length, line.includes('CLONE_FS'))
190
+ } else if ((m = FORK_UNFINISHED_RE.exec(line)) !== null) {
191
+ forking.set(m[1]!, { at: opsOf(m[1]!).length, shared: line.includes('CLONE_FS') })
192
+ } else if ((m = FORK_RESUMED_RE.exec(line)) !== null) {
193
+ const start = forking.get(m[1]!)
194
+ forking.delete(m[1]!)
195
+ forked(m[1]!, m[2]!, start?.at ?? opsOf(m[1]!).length, start?.shared === true)
196
+ }
197
+ }
198
+ if (cwd === undefined) return out
199
+ // dirsOf(pid)[i]: the process's cwd before its op i (undefined: lost).
200
+ const dirs = new Map<string, (string | undefined)[]>()
201
+ const dirsOf = (pid: string): (string | undefined)[] => {
202
+ const known = dirs.get(pid)
203
+ if (known !== undefined) return known
204
+ dirs.set(pid, []) // a cycle reads as lost
205
+ const from = parent.get(pid)
206
+ let dir: string | undefined = from === undefined ? cwd : dirsOf(from.pid)[from.at]
207
+ const list: (string | undefined)[] = [dir]
208
+ for (const op of ops.get(pid) ?? []) {
209
+ if ('chdir' in op) {
210
+ dir = path.isAbsolute(op.chdir) ? op.chdir : dir && path.resolve(dir, op.chdir)
211
+ } else if ('lost' in op) dir = undefined
212
+ list.push(dir)
213
+ }
214
+ dirs.set(pid, list)
215
+ return list
216
+ }
217
+ for (const [pid, list] of ops) {
218
+ list.forEach((op, i) => {
219
+ if (!('call' in op)) return
220
+ const dir = dirsOf(pid)[i]
221
+ if (dir !== undefined && dir !== cwd) out[op.call]!.dir = dir
222
+ })
128
223
  }
129
224
  return out
130
225
  }
@@ -150,8 +245,8 @@ export async function parseStraceViolations(
150
245
 
151
246
  const seen = new Set<string>()
152
247
  const out: SandboxViolation[] = []
153
- for (const { syscall, rawPath, errno } of deniedCalls(text)) {
154
- const abs = toRealPath(absolutize(rawPath, baselines.cwd))
248
+ for (const { syscall, rawPath, errno, dir } of deniedCalls(text, baselines.cwd)) {
249
+ const abs = toRealPath(absolutize(rawPath, dir ?? baselines.cwd))
155
250
  // Only report paths under the workspace-root deny anchor — system
156
251
  // libs / /proc / /sys / etc. probes are not interesting violations.
157
252
  if (!denyAnchors.some((root) => atOrUnder(abs, root))) continue
@@ -20,7 +20,6 @@ import {
20
20
  import { VERSION } from '../version.js'
21
21
  import { probeSandbox, resetSandbox, untracedReason } from '../exec/index.js'
22
22
  import {
23
- buildPackageGraph,
24
23
  computeWorkspaceFingerprint,
25
24
  findWorkspaceRoot,
26
25
  type LoadReads,
@@ -144,7 +143,6 @@ export async function collectInfo(cwd: string, opts: CollectInfoOptions = {}): P
144
143
  cacheDir,
145
144
  plugins,
146
145
  projectMetas: metas,
147
- packageGraph: buildPackageGraph([...metas]),
148
146
  seeds: 'all',
149
147
  closure: false,
150
148
  lock: null,
@@ -5,6 +5,7 @@ export { run, planRun, nxProjectTarget, projectNamed } from './run.js'
5
5
  export { prepareRun, type PreparedRun } from './prepare.js'
6
6
  export {
7
7
  discoverProjects,
8
+ gitOfDiscovery,
8
9
  loadProjects,
9
10
  loadResolvedProjects,
10
11
  loadWorkspacePlugins,
@@ -47,6 +47,7 @@ export interface RunOptions {
47
47
  * as `staged` is.
48
48
  */
49
49
  discovered?: { root: string; projects: ProjectMeta[] }
50
+ /** Worker slots: a positive integer; run() refuses anything else (C-61). */
50
51
  concurrency?: number
51
52
  /**
52
53
  * Cache directory override (`--cache-dir <path>`). Absolute, or relative
@@ -115,6 +116,7 @@ export interface RunOptions {
115
116
  * `exec.retries` (explicit config wins, including `retries: 0`).
116
117
  * Threaded as an option only — never folded into any cache key, so
117
118
  * the same run with and without `--retry` derives identical keys.
119
+ * A non-negative integer; run() refuses anything else (C-61).
118
120
  */
119
121
  retries?: number
120
122
  /**
@@ -124,6 +126,8 @@ export interface RunOptions {
124
126
  * `exec.timeout` always wins. Threaded as an option only — never
125
127
  * folded into any cache key (a timed-out task fails and is never
126
128
  * cached), so a `--timeout` run cache-hits a plain run's entry.
129
+ * A positive integer of ms up to `2 ** 31 - 1`; run() refuses anything
130
+ * else (C-61).
127
131
  */
128
132
  timeout?: number
129
133
  /**
@@ -22,7 +22,6 @@ import type { WorkspaceConfig } from '../config.js'
22
22
  import { Cache, lazyGitEnumeration, type LazyGitEnumeration } from '../cache/index.js'
23
23
  import { UserError } from '../util/index.js'
24
24
  import {
25
- buildPackageGraph,
26
25
  computeWorkspaceFingerprint,
27
26
  listProjects,
28
27
  loadWorkspace,
@@ -81,8 +80,10 @@ export async function discoverProjects(
81
80
  git: LazyGitEnumeration = lazyGitEnumeration(workspace.root),
82
81
  ): Promise<ProjectMeta[]> {
83
82
  const projects = await listProjects(workspace)
84
- if (!hasHook(plugins, 'discover')) return projects
83
+ // Registered whatever the hooks: an `--affected` selection reads its
84
+ // untracked files from this walk, and the run reuses it (I-26).
85
85
  discoveredGit.set(projects, git)
86
+ if (!hasHook(plugins, 'discover')) return projects
86
87
  const worktreeChanges = (): Promise<readonly string[] | null> =>
87
88
  git.start().then(
88
89
  (e) => e.changed,
@@ -97,12 +98,16 @@ export async function discoverProjects(
97
98
  return projects.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
98
99
  }
99
100
 
100
- export interface LoadProjectsArgs {
101
+ export type LoadProjectsArgs = LoadProjectsBase &
102
+ // The graph is read only for the closure: a load without one built it
103
+ // for nothing (1,000 projects: ~10 ms, once per CLI selection pass).
104
+ ({ closure: true; packageGraph: PackageGraph } | { closure: false; packageGraph?: PackageGraph })
105
+
106
+ interface LoadProjectsBase {
101
107
  workspaceRoot: string
102
108
  cacheDir: string
103
109
  plugins: readonly VxPlugin[]
104
110
  projectMetas: readonly ProjectMeta[]
105
- packageGraph: PackageGraph
106
111
  /**
107
112
  * Projects whose configs must load — `'all'`, or names (unknown and
108
113
  * config-less ones are ignored). With `closure`, each seed's transitive
@@ -111,7 +116,6 @@ export interface LoadProjectsArgs {
111
116
  * way, since the package graph cannot see the cross form.
112
117
  */
113
118
  seeds: 'all' | Iterable<string>
114
- closure: boolean
115
119
  /** Read configs from the lock instead of evaluating them (`--frozen`). */
116
120
  lock: Lockfile | null
117
121
  evalCache: LoadProjectConfigOptions['evalCache']
@@ -148,7 +152,8 @@ export interface LoadedProjects {
148
152
  * enters scope.
149
153
  */
150
154
  export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjects> {
151
- const { plugins, packageGraph, lock, workspaceRoot } = args
155
+ const { plugins, lock, workspaceRoot } = args
156
+ const graph = args.closure ? args.packageGraph : null
152
157
  // A package with no config file declares no tasks — unless a plugin
153
158
  // fills the `project` stage, in which case it is a project the stage may
154
159
  // give tasks to (the zero-migration shape: `turbo.json` or `package.json`
@@ -178,7 +183,7 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
178
183
  }
179
184
  }
180
185
  const walked = new Set<string>()
181
- const considerWithDeps = (name: string): void => {
186
+ const considerWithDeps = (packageGraph: PackageGraph, name: string): void => {
182
187
  consider(name)
183
188
  // Every config-bearing project already considered: the closure can
184
189
  // add nothing, and asking for it would build the package graph's
@@ -190,8 +195,10 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
190
195
  }
191
196
  const seeds = args.seeds === 'all' ? metaByName.keys() : [...args.seeds]
192
197
  for (const seed of seeds) consider(seed)
193
- if (args.closure && pending.length < metaByName.size) {
194
- for (const seed of args.seeds === 'all' ? metaByName.keys() : seeds) considerWithDeps(seed)
198
+ if (graph !== null && pending.length < metaByName.size) {
199
+ for (const seed of args.seeds === 'all' ? metaByName.keys() : seeds) {
200
+ considerWithDeps(graph, seed)
201
+ }
195
202
  }
196
203
 
197
204
  // Load in rounds to a fixpoint. A `pkg#task` dependsOn entry names a
@@ -227,7 +234,7 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
227
234
  if (pre !== undefined) {
228
235
  projects.set(meta.name, pre)
229
236
  for (const name of crossDepProjects(pre.config)) {
230
- if (args.closure) considerWithDeps(name)
237
+ if (graph !== null) considerWithDeps(graph, name)
231
238
  else consider(name)
232
239
  }
233
240
  continue
@@ -258,7 +265,7 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
258
265
  const named = rootName === undefined ? config : rootSpelled(config, rootName)
259
266
  projects.set(meta.name, { name: meta.name, dir: meta.dir, config: named })
260
267
  for (const name of crossDepProjects(named)) {
261
- if (args.closure) considerWithDeps(name)
268
+ if (graph !== null) considerWithDeps(graph, name)
262
269
  else consider(name)
263
270
  }
264
271
  }
@@ -349,7 +356,6 @@ export async function loadResolvedProjects(
349
356
  cacheDir,
350
357
  plugins,
351
358
  projectMetas: metas,
352
- packageGraph: buildPackageGraph([...metas]),
353
359
  seeds: opts.scope ?? 'all',
354
360
  closure: false,
355
361
  lock: null,
@@ -193,7 +193,34 @@ export function invocationCommand(argv: readonly string[]): string {
193
193
  return [...argv.slice(0, sep), '--', `<${rest} argument${rest === 1 ? '' : 's'}>`].join(' ')
194
194
  }
195
195
 
196
+ /**
197
+ * The bounds the CLI and the workspace config already hold, at the façade
198
+ * (C-61): a `concurrency` of 0, a negative or NaN left no worker slot and
199
+ * the run waited for good; a `retries` of NaN retried a failing task
200
+ * without end; a `timeout` of 0, a negative, NaN or past the timer's range
201
+ * killed every task at once.
202
+ */
203
+ function refuseRunNumbers(options: RunOptions): void {
204
+ const refuse = (name: string, value: number, rule: string): never => {
205
+ throw new UserError(`RunOptions.${name} is ${String(value)}: it must be ${rule}`)
206
+ }
207
+ // No task named read "No projects declare task(s): ." (C-61).
208
+ if (options.tasks.length === 0 || options.tasks.includes(''))
209
+ throw new UserError(`RunOptions.tasks names no task: give at least one task name`)
210
+ const { concurrency, retries, timeout } = options
211
+ if (concurrency !== undefined && !(Number.isInteger(concurrency) && concurrency > 0))
212
+ refuse('concurrency', concurrency, 'a positive integer')
213
+ if (retries !== undefined && !(Number.isInteger(retries) && retries >= 0))
214
+ refuse('retries', retries, 'a non-negative integer')
215
+ if (
216
+ timeout !== undefined &&
217
+ !(Number.isInteger(timeout) && timeout > 0 && timeout <= MAX_TIMEOUT_MS)
218
+ )
219
+ refuse('timeout', timeout, `a positive integer of ms, at most ${MAX_TIMEOUT_MS}`)
220
+ }
221
+
196
222
  export async function run(options: RunOptions): Promise<RunSummary> {
223
+ refuseRunNumbers(options)
197
224
  // Color decision: a custom logger (tests, embedders) handles its
198
225
  // own formatting and asserts on plain strings, so we suppress
199
226
  // ANSI escapes for them. Only the defaultLogger (real terminal
@@ -844,7 +871,21 @@ async function runOnBus(
844
871
  const foreground = options.log === undefined && (options.handleSignals ?? true)
845
872
  // An aborted run's children are already being torn down: nothing to hold.
846
873
  const hold = options.holdPersistent === true && !stopRun.signal.aborted
847
- const keepAlive = selectKeepAlive(persistentRegistry, nodes, foreground || hold)
874
+ // A run that failed elsewhere holds nothing: `vx run dev --all` with one
875
+ // server that never became ready sat on the others for good, so a
876
+ // script hung, and the Ctrl-C that ended it read 130 over the failure
877
+ // (C-60). A kept server's own crash ends the wait below as before, and
878
+ // `--continue=always` asked to keep going, and does. The watch loop
879
+ // (`holdPersistent`) still holds: a failing test in a cycle stopped
880
+ // the dev server the next change would restart anyway.
881
+ let keepAlive = selectKeepAlive(persistentRegistry, nodes, foreground || hold)
882
+ const failedElsewhere =
883
+ ![...outcomes.values()].every((o) => isPassStatus(o.status)) ||
884
+ [...persistentRegistry.values()].some(
885
+ (c) => hasEnded(c) && c.exitCode !== 0 && !keepAlive.children.includes(c),
886
+ )
887
+ if (failedElsewhere && !hold && options.continueMode !== 'always')
888
+ keepAlive = { nodes: [], children: [] }
848
889
  const crashedPersistent = (
849
890
  await shutdownPersistent(persistentRegistry, keepAlive.children)
850
891
  ).filter((c) => endedBeforeStop?.has(persistentRegistry.get(c.id)!) ?? true)
@@ -98,6 +98,13 @@ export interface AffectedArgs {
98
98
  * package graph cannot see (item 1085).
99
99
  */
100
100
  taskEdges?: () => Promise<ReadonlyMap<string, readonly string[]>>
101
+ /**
102
+ * The untracked files, workspace-relative, from a walk the caller shares
103
+ * with the run (`GitEnumeration.untracked`); null or absent spawns `git
104
+ * ls-files --others` here. The selection's own walk was a second one the
105
+ * run then paid again (I-26).
106
+ */
107
+ untracked?: () => Promise<readonly string[] | null>
101
108
  }
102
109
 
103
110
  export interface FingerprintClaims {
@@ -186,7 +193,9 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
186
193
  // enumeration does (`git ls-files --cached --others --exclude-standard`),
187
194
  // so a brand-new source file changes a task's cache key. Union it in or
188
195
  // `--affected` skips a package that genuinely has new work.
189
- gitPaths(args.workspaceRoot, ['ls-files', '--others', '--exclude-standard', '-z']),
196
+ (async () =>
197
+ (await args.untracked?.().catch(() => null)) ??
198
+ gitPaths(args.workspaceRoot, ['ls-files', '--others', '--exclude-standard', '-z']))(),
190
199
  ])
191
200
 
192
201
  // vx-lock.json (workspace-root metadata) is excluded like a gitignored
@@ -153,7 +153,15 @@ export async function frozenProjectConfigs(
153
153
  const entries = metas.map((meta) => {
154
154
  const rel = relPosix(root, meta.configPath)
155
155
  const entry = lock.projects[meta.name]
156
- if (!entry || entry.configPath !== rel) {
156
+ if (entry !== undefined && entry.configPath !== rel) {
157
+ // A renamed or moved config: "has no entry for a" named an entry the
158
+ // lock holds, under its old path (D-88).
159
+ throw new UserError(
160
+ `${LOCKFILE_NAME} locks "${meta.name}" at ${entry.configPath}, but its config is ${rel} now — ` +
161
+ `run \`vx lock\` to refresh, or delete ${LOCKFILE_NAME}`,
162
+ )
163
+ }
164
+ if (!entry) {
157
165
  throw new UserError(
158
166
  `${LOCKFILE_NAME} has no entry for "${meta.name}" (${rel}) — ` +
159
167
  `run \`vx lock\` to refresh, or delete ${LOCKFILE_NAME}`,
@@ -407,6 +407,20 @@ export function migrateScripts(
407
407
  typeof outsideScripts === 'object' &&
408
408
  outsideScripts !== null &&
409
409
  Object.values(outsideScripts).some((v) => typeof v === 'string' && v !== '')
410
+ const unnamedMaps = (): string[] => {
411
+ const meta: ProjectMeta = {
412
+ name: 'package.json',
413
+ dir: outsideDir!,
414
+ packageJson: outside as never,
415
+ configPath: null,
416
+ }
417
+ const scripts = scriptsOf(meta)
418
+ return Object.keys(rootScripts(meta)).filter((n) => {
419
+ // A hook rides in its script's command and is no script to name.
420
+ const base = /^(?:pre|post)(.+)$/.exec(n)?.[1]
421
+ return !LIFECYCLE.test(n) && (base === undefined || !(base in scripts))
422
+ })
423
+ }
410
424
  const rootName =
411
425
  root?.name ??
412
426
  (outsideRuns
@@ -418,6 +432,14 @@ export function migrateScripts(
418
432
  notes.push(
419
433
  `${rootName} (the workspace root): its scripts that check the whole repo are its tasks; those that run the members (\`pnpm -r\`, \`--filter\`, a runner) or share a member's task name are left out`,
420
434
  )
435
+ } else if (rootName === 'package.json' && outsideDir !== undefined && unnamedMaps().length > 0) {
436
+ // react's nameless root: "its scripts run the workspace" was not why,
437
+ // and 30 of its scripts (`build`, `lint`, `test`) map once it has a
438
+ // name (D-87).
439
+ const would = unnamedMaps()
440
+ notes.push(
441
+ `package.json (the workspace root) not mapped: it has no "name", and vx names a project by it; give it one and run \`vx init\` again to map ${would.length} of its scripts (${would.slice(0, 8).join(', ')}${would.length > 8 ? ', …' : ''})`,
442
+ )
421
443
  } else if (rootName !== undefined) {
422
444
  // A nameless root's vx.config is skipped (vx names projects by their
423
445
  // manifest's name), so the hand-written one needs a name first (vuejs/core).