@vzn/vx 0.0.18 → 0.0.21

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.
Files changed (121) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +99 -65
  3. package/package.json +10 -20
  4. package/src/bin.ts +12 -2
  5. package/src/cache/archive.ts +120 -18
  6. package/src/cache/cache.ts +313 -1324
  7. package/src/cache/chained-cache.ts +0 -8
  8. package/src/cache/config-evals.ts +89 -0
  9. package/src/cache/file-hashes.ts +262 -0
  10. package/src/cache/git-inputs.ts +637 -0
  11. package/src/cache/index.ts +16 -7
  12. package/src/cache/inputs.ts +246 -733
  13. package/src/cache/layer.ts +604 -0
  14. package/src/cache/layered-cache.ts +12 -17
  15. package/src/cache/output-index.ts +224 -0
  16. package/src/cache/policy.ts +84 -0
  17. package/src/cache/run-history.ts +147 -0
  18. package/src/cache/zstd.ts +160 -0
  19. package/src/cli/cache.ts +39 -11
  20. package/src/cli/completions.ts +104 -0
  21. package/src/cli/core-alias.ts +25 -0
  22. package/src/cli/help.ts +64 -36
  23. package/src/cli/index.ts +54 -15
  24. package/src/cli/info.ts +120 -91
  25. package/src/cli/init.ts +75 -0
  26. package/src/cli/last.ts +38 -6
  27. package/src/cli/lock.ts +2 -1
  28. package/src/cli/plan-format.ts +14 -3
  29. package/src/cli/plugin-commands.ts +15 -8
  30. package/src/cli/run.ts +84 -302
  31. package/src/cli/select.ts +332 -0
  32. package/src/cli/show.ts +144 -69
  33. package/src/cli/upgrade.ts +2 -1
  34. package/src/cli/watch.ts +647 -97
  35. package/src/cli/why.ts +44 -14
  36. package/src/cli/workspace-config.ts +126 -0
  37. package/src/config.ts +156 -136
  38. package/src/exec/env.ts +11 -0
  39. package/src/exec/executor.ts +11 -26
  40. package/src/exec/index.ts +6 -2
  41. package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
  42. package/src/exec/runner.ts +57 -18
  43. package/src/exec/sandbox-binds.ts +182 -0
  44. package/src/exec/sandbox-paths.ts +50 -0
  45. package/src/exec/sandbox-runtime.ts +582 -370
  46. package/src/exec/sandbox-violations.ts +280 -0
  47. package/src/graph/index.ts +1 -9
  48. package/src/graph/scheduler.ts +158 -203
  49. package/src/graph/task-graph.ts +2 -2
  50. package/src/index.ts +97 -102
  51. package/src/orchestrator/admission.ts +137 -0
  52. package/src/orchestrator/deferred-outputs.ts +3 -0
  53. package/src/orchestrator/doctor.ts +255 -0
  54. package/src/orchestrator/events.ts +42 -8
  55. package/src/orchestrator/execute-task.ts +145 -554
  56. package/src/orchestrator/failure-mode.ts +169 -28
  57. package/src/orchestrator/framed-output.ts +41 -36
  58. package/src/orchestrator/history.ts +118 -98
  59. package/src/orchestrator/hit-restore.ts +221 -0
  60. package/src/orchestrator/index.ts +35 -4
  61. package/src/orchestrator/lockfile-claim.ts +281 -0
  62. package/src/orchestrator/logger.ts +13 -22
  63. package/src/orchestrator/metrics.ts +53 -20
  64. package/src/orchestrator/miss-save.ts +169 -0
  65. package/src/orchestrator/options.ts +34 -31
  66. package/src/orchestrator/persistent.ts +79 -0
  67. package/src/orchestrator/placement.ts +182 -0
  68. package/src/orchestrator/plugin-host.ts +195 -38
  69. package/src/orchestrator/plugin.ts +162 -9
  70. package/src/orchestrator/prepare.ts +77 -132
  71. package/src/orchestrator/projects.ts +259 -0
  72. package/src/orchestrator/remote-prefetch.ts +1 -1
  73. package/src/orchestrator/run-artifacts.ts +25 -3
  74. package/src/orchestrator/run-context.ts +1 -1
  75. package/src/orchestrator/run-records.ts +149 -0
  76. package/src/orchestrator/run-report.ts +2 -2
  77. package/src/orchestrator/run.ts +246 -597
  78. package/src/orchestrator/sandbox-request.ts +243 -0
  79. package/src/orchestrator/save-lane.ts +47 -0
  80. package/src/orchestrator/signals.ts +111 -0
  81. package/src/orchestrator/stable-keys.ts +60 -3
  82. package/src/orchestrator/status-line.ts +2 -1
  83. package/src/orchestrator/summary.ts +68 -25
  84. package/src/orchestrator/task-hash.ts +20 -18
  85. package/src/orchestrator/task-log-buffer.ts +8 -11
  86. package/src/orchestrator/telemetry-host.ts +11 -1
  87. package/src/orchestrator/telemetry.ts +3 -17
  88. package/src/util/cgroup.ts +160 -0
  89. package/src/util/edit-distance.ts +49 -0
  90. package/src/util/index.ts +11 -3
  91. package/src/util/paths.ts +23 -2
  92. package/src/util/settle.ts +19 -0
  93. package/src/util/verbs.ts +39 -0
  94. package/src/workspace/affected.ts +111 -11
  95. package/src/workspace/config-cache.ts +11 -2
  96. package/src/workspace/config-eval.ts +11 -4
  97. package/src/workspace/config-imports.ts +5 -4
  98. package/src/workspace/config-schema.ts +837 -0
  99. package/src/workspace/filter.ts +43 -6
  100. package/src/workspace/fingerprint.ts +37 -3
  101. package/src/workspace/index.ts +30 -3
  102. package/src/workspace/lockfile.ts +12 -7
  103. package/src/{cli → workspace}/migrate-scripts.ts +26 -19
  104. package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
  105. package/src/workspace/package-graph.ts +98 -28
  106. package/src/workspace/project-loader.ts +76 -746
  107. package/src/workspace/workspace.ts +98 -13
  108. package/plugins/local-cache/index.ts +0 -2
  109. package/plugins/local-executor/index.ts +0 -2
  110. package/plugins/schedule-history/index.ts +0 -2
  111. package/src/cache/cas-backend.ts +0 -115
  112. package/src/cache/digest.ts +0 -32
  113. package/src/cli/migrate-emit.ts +0 -21
  114. package/src/cli/migrate-nx.ts +0 -473
  115. package/src/cli/migrate-turbo.ts +0 -401
  116. package/src/cli/prune.ts +0 -291
  117. package/src/orchestrator/missing-plugin.ts +0 -9
  118. package/src/orchestrator/resources.ts +0 -45
  119. package/src/orchestrator/verify.ts +0 -174
  120. package/src/plugins/local-cache/index.ts +0 -11
  121. package/src/plugins/schedule-history/index.ts +0 -109
package/src/cli/run.ts CHANGED
@@ -1,34 +1,21 @@
1
- import readline from 'node:readline/promises'
2
1
  import { appendFile } from 'node:fs/promises'
3
2
  import path from 'node:path'
4
- import { documentedFlags } from './help.js'
5
- import {
6
- affectedProjects,
7
- applyFilters,
8
- buildPackageGraph,
9
- defaultAffectedBase,
10
- findWorkspaceRoot,
11
- listProjects,
12
- loadProjectConfig,
13
- loadWorkspace,
14
- parseFilter,
15
- readLockfile,
16
- workspaceGlobsMatch,
17
- type ProjectMeta,
18
- } from '../workspace/index.js'
3
+ import { documentedFlags, seeHelp } from './help.js'
4
+ import { defaultAffectedBase, findWorkspaceRoot } from '../workspace/index.js'
19
5
  import {
20
6
  planRun,
21
7
  formatRunReportMarkdown,
8
+ outcomeLabel,
22
9
  projectOutcome,
23
10
  run as runOrchestrator,
24
11
  type OutcomeView,
25
12
  type RunOptions,
26
13
  type RunResult,
27
14
  } from '../orchestrator/index.js'
28
- import type { ProjectConfig } from '../config.js'
29
15
  import type { ContinueMode } from '../graph/index.js'
30
16
  import { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
31
- import { MAX_TIMEOUT_MS, parseDecimalInt, parseSize, editDistance } from '../util/index.js'
17
+ import { findCwdProject, pickTask, resolveFilters } from './select.js'
18
+ import { MAX_TIMEOUT_MS, parseDecimalInt, nearest, machineParallelism } from '../util/index.js'
32
19
  import { formatGraphDot, formatPlanJson, formatPlanText } from './plan-format.js'
33
20
 
34
21
  export interface RunArgs {
@@ -55,6 +42,8 @@ export interface RunArgs {
55
42
  * / `--force` in precedence order. Defaults to all-on.
56
43
  */
57
44
  cache: CachePolicy
45
+ /** `--cache` named a remote axis (`remote:r`, `:w`, `:rw`): the run says so when no plugin serves one. */
46
+ remoteRequested?: boolean
58
47
  /** `--cache-dir <path>`: override the cache directory (cwd-relative). */
59
48
  cacheDir: string | undefined
60
49
  frozen: boolean
@@ -69,23 +58,6 @@ export interface RunArgs {
69
58
  * env and workspace `timeout` defaults. Undefined when not passed.
70
59
  */
71
60
  timeout: number | undefined
72
- /**
73
- * `--memory <size>` / `--memory=<size>`: memory budget (resolved to
74
- * bytes) for `exec.resources.memory` reservations. Defaults to
75
- * os.totalmem() when not passed — pass it in cgroup-limited containers.
76
- */
77
- memory: number | undefined
78
- /**
79
- * `--verify[=determinism|inputs|fingerprint|all]`: cache-correctness
80
- * verification. Undefined when not passed. `determinism` re-runs and
81
- * content-compares outputs; `inputs` sandboxes with the declared-input
82
- * baseline and flags undeclared reads; `fingerprint` ships output-tree
83
- * fingerprints for the cross-machine diff (no re-run; the determinism
84
- * modes set it too, for free). `allow` (from `--verify-allow=<pkg#task>,…`)
85
- * exempts tasks from failing the run on a divergence.
86
- */
87
- verify: { determinism: boolean; inputs: boolean; fingerprint: boolean } | undefined
88
- verifyAllow: string[]
89
61
  outputLogs?: 'full' | 'errors-only' | 'none' | 'hash-only'
90
62
  download?: 'all' | 'toplevel' | 'none'
91
63
  forwardArgs: string[]
@@ -113,6 +85,30 @@ export interface RunArgs {
113
85
  error?: string
114
86
  }
115
87
 
88
+ // The camelCase spelling the flag shipped with (retired 2026-09-10): two
89
+ // edits past the suggester's reach, so it is named outright. A regex, not a
90
+ // quoted literal, so the doc-drift test does not count it as an accepted flag.
91
+ const RETIRED_EXCLUDE_DEPENDENCIES = /^--excludeDependencies(=|$)/
92
+
93
+ /**
94
+ * `--concurrency <n>` is a positive decimal integer, or `<n>%` of the
95
+ * cores this process may use (`50%` on eight cores is 4, never below 1;
96
+ * over 100% is allowed for I/O-bound work; a container's CPU quota caps
97
+ * the count the way it caps the default). The percent form resolves
98
+ * here, at parse time, so the run sees one number and the summary says
99
+ * what it ran with.
100
+ */
101
+ export function parseConcurrency(v: string, cpus = machineParallelism()): number | null {
102
+ const pct = /^(\d+)%$/.exec(v)
103
+ if (pct) {
104
+ const n = parseDecimalInt(pct[1]!)
105
+ if (n === null || n < 1) return null
106
+ return Math.max(1, Math.round((Math.max(1, cpus) * n) / 100))
107
+ }
108
+ const n = parseDecimalInt(v)
109
+ return n === null || n < 1 ? null : n
110
+ }
111
+
116
112
  export function parseRunArgs(args: readonly string[]): RunArgs {
117
113
  const out: RunArgs = {
118
114
  tasks: [],
@@ -125,9 +121,6 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
125
121
  frozen: false,
126
122
  retries: undefined,
127
123
  timeout: undefined,
128
- memory: undefined,
129
- verify: undefined,
130
- verifyAllow: [],
131
124
  forwardArgs: [],
132
125
  verbosity: 0,
133
126
  dry: undefined,
@@ -162,25 +155,30 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
162
155
  } else if (a === '--concurrency' || a?.startsWith('--concurrency=')) {
163
156
  const v = a === '--concurrency' ? before[++i] : a.slice('--concurrency='.length)
164
157
  if (v === undefined) return { ...out, error: `--concurrency requires a value` }
165
- const n = parseDecimalInt(v)
166
- if (n === null || n < 1) return { ...out, error: `invalid concurrency: ${v}` }
158
+ const n = parseConcurrency(v)
159
+ if (n === null) return { ...out, error: `invalid concurrency: ${v}` }
167
160
  out.concurrency = n
168
161
  } else if (a === '--all') {
169
162
  out.all = true
170
- } else if (a === '--excludeDependencies') {
163
+ } else if (RETIRED_EXCLUDE_DEPENDENCIES.test(a ?? '')) {
164
+ return {
165
+ ...out,
166
+ error: `unknown flag: ${a} (the flag is --exclude-dependencies)${seeHelp('run')}`,
167
+ }
168
+ } else if (a === '--exclude-dependencies') {
171
169
  out.excludeDependencies = 'all'
172
- } else if (a?.startsWith('--excludeDependencies=')) {
173
- const raw = a.slice('--excludeDependencies='.length)
170
+ } else if (a?.startsWith('--exclude-dependencies=')) {
171
+ const raw = a.slice('--exclude-dependencies='.length)
174
172
  // An empty value is genuinely ambiguous — "drop every edge" (the
175
173
  // bare flag) and "drop none" (an empty list) are equally defensible
176
174
  // readings, and silently picking either does the opposite of what
177
175
  // half the callers mean. Reject and name both explicit forms; the
178
- // sibling value flags (--retry=, --timeout=, --memory=, --cache-dir=)
176
+ // sibling value flags (--retry=, --timeout=, --cache-dir=)
179
177
  // reject an empty `=` value the same way.
180
178
  if (raw === '') {
181
179
  return {
182
180
  ...out,
183
- error: `--excludeDependencies= needs a value — pass bare --excludeDependencies to drop every dependsOn edge, or omit the flag to keep them`,
181
+ error: `--exclude-dependencies= needs a value — pass bare --exclude-dependencies to drop every dependsOn edge, or omit the flag to keep them`,
184
182
  }
185
183
  }
186
184
  out.excludeDependencies = raw
@@ -215,42 +213,6 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
215
213
  return { ...out, error: `--timeout must be a positive integer (ms), got: ${v}` }
216
214
  }
217
215
  out.timeout = n
218
- } else if (a === '--memory' || a?.startsWith('--memory=')) {
219
- const v = a === '--memory' ? before[++i] : a.slice('--memory='.length)
220
- if (v === undefined || v === '') return { ...out, error: `--memory requires a value` }
221
- const bytes = parseSize(v)
222
- if (bytes === null || bytes <= 0) {
223
- return { ...out, error: `--memory must be a size like 8GB or 512MB, got: ${v}` }
224
- }
225
- out.memory = bytes
226
- } else if (a === '--verify' || a?.startsWith('--verify=')) {
227
- const what = a === '--verify' ? 'determinism' : a.slice('--verify='.length)
228
- if (what === 'determinism') {
229
- out.verify = { determinism: true, inputs: false, fingerprint: true }
230
- } else if (what === 'inputs') {
231
- out.verify = { determinism: false, inputs: true, fingerprint: false }
232
- } else if (what === 'fingerprint') {
233
- out.verify = { determinism: false, inputs: false, fingerprint: true }
234
- } else if (what === 'all') {
235
- out.verify = { determinism: true, inputs: true, fingerprint: true }
236
- } else {
237
- return {
238
- ...out,
239
- error: `--verify must be determinism | inputs | fingerprint | all (or bare --verify), got: ${what}`,
240
- }
241
- }
242
- } else if (a === '--verify-allow' || a?.startsWith('--verify-allow=')) {
243
- const v = a === '--verify-allow' ? before[++i] : a.slice('--verify-allow='.length)
244
- if (v === undefined) return { ...out, error: `--verify-allow requires a value` }
245
- // A flag-shaped space-form value is always a lost flag (task ids
246
- // never start with `-`), never a value the user meant.
247
- if (a === '--verify-allow' && v.startsWith('-')) {
248
- return { ...out, error: `--verify-allow requires a value, got flag: ${v}` }
249
- }
250
- out.verifyAllow = v
251
- .split(',')
252
- .map((s) => s.trim())
253
- .filter((s) => s.length > 0)
254
216
  } else if (a === '--output-logs' || a?.startsWith('--output-logs=')) {
255
217
  const v = a === '--output-logs' ? before[++i] : a.slice('--output-logs='.length)
256
218
  if (v !== 'full' && v !== 'errors-only' && v !== 'none' && v !== 'hash-only') {
@@ -291,6 +253,7 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
291
253
  }
292
254
  try {
293
255
  cachePolicy = parseCachePolicy(v, cachePolicy)
256
+ if (/remote:[rw]/.test(v)) out.remoteRequested = true
294
257
  } catch (err) {
295
258
  cacheSpecError = err instanceof Error ? err.message : String(err)
296
259
  }
@@ -299,7 +262,14 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
299
262
  } else if (a === '--force') {
300
263
  forceFlag = true
301
264
  } else if (a === '--continue') {
302
- // Bare --continue = 'always' (the Turbo convention).
265
+ // Bare --continue = 'always' (the Turbo convention). Its mode comes
266
+ // with '=' only: `--continue never` would read `never` as a TASK, and
267
+ // the run would then say "No projects declare task(s): never" — true,
268
+ // and no help at all.
269
+ const next = before[i + 1]
270
+ if (next === 'never' || next === 'deps-ok' || next === 'always') {
271
+ return { ...out, error: `--continue takes its mode with '=': --continue=${next}` }
272
+ }
303
273
  out.continueMode = 'always'
304
274
  } else if (a?.startsWith('--continue=')) {
305
275
  const v = a.slice('--continue='.length)
@@ -367,7 +337,7 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
367
337
  }
368
338
  out.report = fmt
369
339
  } else if (a !== undefined && a.startsWith('-')) {
370
- return { ...out, error: `unknown flag: ${a}${didYouMeanFlag(a)}` }
340
+ return { ...out, error: `unknown flag: ${a}${didYouMeanFlag(a)}${seeHelp('run')}` }
371
341
  } else if (a !== undefined) {
372
342
  out.tasks.push(a)
373
343
  }
@@ -460,10 +430,14 @@ export async function resolveRunOptions(
460
430
  const bareTasks = tasks.filter((t) => !t.includes('#'))
461
431
  const anchoredTasks = tasks.filter((t) => t.includes('#'))
462
432
  let projects: string[] | undefined
433
+ let staged: RunOptions['staged']
463
434
  if (bareTasks.length === 0) {
464
435
  projects = undefined
465
436
  } else if (filterStrings.length > 0) {
466
- const resolved = await resolveFilters(cwd, filterStrings)
437
+ const resolved = await resolveFilters(cwd, filterStrings, {
438
+ ...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
439
+ ...(parsed.frozen ? { frozen: true } : {}),
440
+ })
467
441
  if ('error' in resolved) return { error: resolved.error }
468
442
  if ('empty' in resolved) {
469
443
  // "Nothing changed" is a clean exit for the BARE tasks the filter
@@ -476,6 +450,9 @@ export async function resolveRunOptions(
476
450
  projects = []
477
451
  } else {
478
452
  projects = resolved.names
453
+ // The graph walk staged every config; the run reuses those entries
454
+ // instead of evaluating and staging them a second time.
455
+ staged = resolved.staged
479
456
  }
480
457
  } else if (parsed.all) {
481
458
  projects = undefined
@@ -494,6 +471,7 @@ export async function resolveRunOptions(
494
471
  cwd,
495
472
  tasks: [...tasks],
496
473
  cache: parsed.cache,
474
+ ...(parsed.remoteRequested ? { remoteRequested: true } : {}),
497
475
  flow: detectFlow(parsed),
498
476
  ...(parsed.frozen ? { frozen: true } : {}),
499
477
  ...(parsed.outputLogs !== undefined ? { outputLogs: parsed.outputLogs } : {}),
@@ -507,18 +485,10 @@ export async function resolveRunOptions(
507
485
  opts.excludeDependencies = parsed.excludeDependencies
508
486
  }
509
487
  if (projects !== undefined) opts.projects = projects
488
+ if (staged !== undefined) opts.staged = staged
510
489
  if (parsed.retries !== undefined) opts.retries = parsed.retries
511
490
  if (parsed.timeout !== undefined) opts.timeout = parsed.timeout
512
- if (parsed.memory !== undefined) opts.memory = parsed.memory
513
491
  if (parsed.cacheDir !== undefined) opts.cacheDir = parsed.cacheDir
514
- if (parsed.verify !== undefined) {
515
- opts.verify = {
516
- determinism: parsed.verify.determinism,
517
- inputs: parsed.verify.inputs,
518
- fingerprint: parsed.verify.fingerprint,
519
- allow: new Set(parsed.verifyAllow),
520
- }
521
- }
522
492
  if (parsed.concurrency !== undefined) opts.concurrency = parsed.concurrency
523
493
  if (parsed.summarize !== undefined) opts.summarize = parsed.summarize
524
494
  if (parsed.profile !== undefined) opts.profile = parsed.profile
@@ -545,7 +515,14 @@ export async function runCmd(args: readonly string[]): Promise<number> {
545
515
  process.stderr.write(`vx run: missing task name (stdin is not a TTY)\n`)
546
516
  return 1
547
517
  }
548
- const picked = await pickTask(cwd)
518
+ const picked = await pickTask(
519
+ cwd,
520
+ {},
521
+ {
522
+ ...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
523
+ ...(parsed.frozen ? { frozen: true } : {}),
524
+ },
525
+ )
549
526
  if (!picked) return 1
550
527
  tasks = [`${picked.project}#${picked.task}`]
551
528
  }
@@ -630,182 +607,6 @@ export async function runCmd(args: readonly string[]): Promise<number> {
630
607
  return result.ok ? 0 : 1
631
608
  }
632
609
 
633
- /**
634
- * Which projects declare a `cache.inputs.workspaceFiles` glob matching one of
635
- * `orphans` (workspace-relative paths belonging to no project).
636
- *
637
- * Reached ONLY when a changed path belongs to no project, so a run whose every
638
- * change is inside a project never gets here — the scoped-config-loading win
639
- * is untouched for the common case.
640
- *
641
- * `vx-lock.json` FIRST when present: it holds the resolved configs, so CI —
642
- * where `--affected` matters most and the lock is committed — answers with
643
- * zero evaluation. Without a lock this evaluates configs, which is the honest
644
- * cost of the question: nothing cheaper can know which globs a program
645
- * declares. A config that fails to load is skipped rather than failing the
646
- * run, matching the deliberate Turbo-like rule that a broken out-of-scope
647
- * config does not fail a scoped run.
648
- */
649
- async function workspaceGlobOwners(
650
- root: string,
651
- projects: readonly ProjectMeta[],
652
- orphans: readonly string[],
653
- ): Promise<string[]> {
654
- const lock = await readLockfile(root)
655
- const owners: string[] = []
656
- const declaresMatch = (config: ProjectConfig): boolean => {
657
- for (const task of Object.values(config.tasks ?? {})) {
658
- const globs = task.cache?.inputs?.workspaceFiles
659
- if (globs === undefined) continue
660
- if (orphans.some((rel) => workspaceGlobsMatch(globs, rel))) return true
661
- }
662
- return false
663
- }
664
- await Promise.all(
665
- projects.map(async (meta) => {
666
- const locked = lock?.projects[meta.name]
667
- if (locked !== undefined) {
668
- if (declaresMatch(locked.config)) owners.push(meta.name)
669
- return
670
- }
671
- if (meta.configPath === null || meta.configPath === '') return
672
- try {
673
- if (declaresMatch(await loadProjectConfig(meta.configPath))) owners.push(meta.name)
674
- } catch {
675
- // A config that will not load cannot be shown to declare a matching
676
- // glob; surfacing it here would fail a run the loader itself tolerates.
677
- }
678
- }),
679
- )
680
- return owners
681
- }
682
-
683
- async function loadWorkspaceProjects(cwd: string): Promise<ProjectMeta[]> {
684
- const root = await findWorkspaceRoot(cwd)
685
- const ws = await loadWorkspace(root)
686
- return await listProjects(ws)
687
- }
688
-
689
- async function findCwdProject(cwd: string): Promise<string | null> {
690
- const projects = await loadWorkspaceProjects(cwd)
691
- const abs = path.resolve(cwd)
692
- let best: ProjectMeta | null = null
693
- for (const p of projects) {
694
- if (abs === p.dir || abs.startsWith(p.dir + path.sep)) {
695
- if (!best || p.dir.length > best.dir.length) best = p
696
- }
697
- }
698
- return best?.name ?? null
699
- }
700
-
701
- type FilterResolution = { names: string[] } | { error: string } | { empty: string }
702
-
703
- async function resolveFilters(cwd: string, raw: string[]): Promise<FilterResolution> {
704
- const root = await findWorkspaceRoot(cwd)
705
- const projects = await loadWorkspaceProjects(cwd)
706
- const graph = buildPackageGraph(projects)
707
- const parsed = raw.map((r) => parseFilter(r, root))
708
-
709
- // Resolve every `[<since>]` filter against git before the pure
710
- // applyFilters pass runs. One spawn per distinct ref — usually
711
- // there's only one anyway.
712
- const affectedByFilter = new Map<(typeof parsed)[number], Set<string>>()
713
- for (const f of parsed) {
714
- if (f.gitSince === undefined) continue
715
- try {
716
- const names = await affectedProjects({
717
- workspaceRoot: root,
718
- since: f.gitSince,
719
- projects,
720
- workspaceGlobOwners: (orphans) => workspaceGlobOwners(root, projects, orphans),
721
- })
722
- affectedByFilter.set(f, names)
723
- } catch (err) {
724
- const msg = err instanceof Error ? err.message : String(err)
725
- return { error: msg }
726
- }
727
- }
728
-
729
- const selected = applyFilters({
730
- filters: parsed,
731
- projects,
732
- graph,
733
- affectedByFilter,
734
- // A `[<since>]` selector matching nothing is the ordinary "nothing
735
- // changed" outcome, reported below; only a name/path pattern that
736
- // matched nothing is worth flagging as a probable typo.
737
- onNoMatch: (f) => {
738
- if (f.gitSince === undefined) {
739
- process.stderr.write(`vx: filter "${f.raw}" matched no projects\n`)
740
- }
741
- },
742
- })
743
- if (selected.size === 0) {
744
- // "Nothing changed" is a legitimate outcome, not a typo — a docs-only
745
- // commit must not red `vx run … --affected=origin/main`. Only report an
746
- // error when the user named something concrete that failed to resolve.
747
- const includes = parsed.filter((f) => !f.negate)
748
- if (includes.length > 0 && includes.every((f) => f.gitSince !== undefined)) {
749
- const refs = includes.map((f) => f.gitSince).join(', ')
750
- return { empty: `nothing affected since ${refs}` }
751
- }
752
- return { error: `no projects matched filter(s): ${raw.join(', ')}` }
753
- }
754
- return { names: [...selected].sort() }
755
- }
756
-
757
- interface PickedTask {
758
- project: string
759
- task: string
760
- description?: string
761
- }
762
-
763
- export async function pickTask(
764
- cwd: string,
765
- io: { input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream } = {},
766
- ): Promise<PickedTask | null> {
767
- const projects = await loadWorkspaceProjects(cwd)
768
- const entries: PickedTask[] = []
769
- for (const meta of projects) {
770
- if (!meta.configPath) continue
771
- const config = await loadProjectConfig(meta.configPath)
772
- const taskNames = Object.keys(config.tasks ?? {}).sort()
773
- for (const t of taskNames) {
774
- const desc = config.tasks?.[t]?.description
775
- entries.push({ project: meta.name, task: t, ...(desc ? { description: desc } : {}) })
776
- }
777
- }
778
- if (entries.length === 0) {
779
- process.stderr.write(`vx run: no tasks declared in any project\n`)
780
- return null
781
- }
782
- const out = io.output ?? process.stdout
783
- const numW = String(entries.length).length
784
- const idW = Math.max(...entries.map((e) => `${e.project}#${e.task}`.length))
785
- out.write('Tasks:\n')
786
- entries.forEach((e, i) => {
787
- const n = String(i + 1).padStart(numW, ' ')
788
- const id = `${e.project}#${e.task}`.padEnd(idW)
789
- const desc = e.description ? ` ${e.description}` : ''
790
- out.write(` ${n}. ${id}${desc}\n`)
791
- })
792
- const rl = readline.createInterface({
793
- input: io.input ?? process.stdin,
794
- output: io.output ?? process.stdout,
795
- })
796
- try {
797
- const answer = (await rl.question(`Pick a task [1-${entries.length}]: `)).trim()
798
- const n = Number(answer)
799
- if (!Number.isInteger(n) || n < 1 || n > entries.length) {
800
- process.stderr.write(`vx run: invalid selection: ${answer}\n`)
801
- return null
802
- }
803
- return entries[n - 1] ?? null
804
- } finally {
805
- rl.close()
806
- }
807
- }
808
-
809
610
  function printSummary(summary: RunResult): void {
810
611
  const rows = summary.outcomes.map((o) => formatRow(o))
811
612
  if (rows.length === 0) return
@@ -835,41 +636,22 @@ function printSummary(summary: RunResult): void {
835
636
  }
836
637
 
837
638
  function formatRow(o: OutcomeView): { task: string; status: string; duration: string } {
838
- // Same outcome vocabulary as the framed blocks + summary:
839
- // executed / restored-local / restored-remote / up-to-date /
840
- // failed / skipped.
841
- const status =
842
- o.status === 'cache-hit'
843
- ? o.restored === false
844
- ? 'up-to-date'
845
- : 'restored-local'
846
- : o.status === 'cache-hit-remote'
847
- ? o.restored === false
848
- ? 'up-to-date'
849
- : 'restored-remote'
850
- : o.status === 'success'
851
- ? 'executed'
852
- : o.status === 'failed'
853
- ? `failed (exit ${o.exitCode})`
854
- : o.status
855
- return {
856
- task: o.taskId,
857
- status,
858
- duration: `${o.durationMs}ms`,
859
- }
639
+ return { task: o.taskId, status: outcomeLabel(o), duration: `${o.durationMs}ms` }
860
640
  }
861
641
 
862
642
  /** `(did you mean --concurrency?)` for a flag within two edits of a documented one. */
863
643
  function didYouMeanFlag(flag: string): string {
864
644
  const name = flag.replace(/=.*$/, '')
865
- let best: string | undefined
866
- let bestD = 3
867
- for (const f of documentedFlags('run')) {
868
- const d = editDistance(name, f)
869
- if (d < bestD) {
870
- bestD = d
871
- best = f
872
- }
873
- }
874
- return best === undefined || best === name ? '' : ` (did you mean ${best}?)`
645
+ const flags = documentedFlags('run')
646
+ // The usual two edits; a third only between flags that share their
647
+ // first five characters, so `--retries` reaches `--retry` (the i/y)
648
+ // while `--zzz` does not reach `--all` — a plain three-edit budget did.
649
+ const best =
650
+ nearest(name, flags) ??
651
+ nearest(
652
+ name,
653
+ flags.filter((f) => f.slice(0, 5) === name.slice(0, 5)),
654
+ 3,
655
+ )
656
+ return best === undefined ? '' : ` (did you mean ${best}?)`
875
657
  }