@vzn/vx 0.0.652 → 0.0.653

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 (167) hide show
  1. package/README.md +2 -2
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +1 -0
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +25 -3
  8. package/src/cache/archive.ts +196 -31
  9. package/src/cache/cache.ts +863 -225
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +22 -5
  16. package/src/cache/index.ts +10 -1
  17. package/src/cache/inputs.ts +573 -152
  18. package/src/cache/key-fold.ts +1 -1
  19. package/src/cache/layer.ts +23 -10
  20. package/src/cache/layered-cache.ts +28 -7
  21. package/src/cache/output-index.ts +106 -44
  22. package/src/cache/plugin-cache.ts +2 -2
  23. package/src/cache/policy.ts +6 -2
  24. package/src/cache/remote-lane.ts +3 -1
  25. package/src/cache/run-history.ts +50 -10
  26. package/src/cache/schema.ts +42 -9
  27. package/src/cli/cache.ts +147 -34
  28. package/src/cli/completions.ts +2 -2
  29. package/src/cli/core-alias.ts +9 -2
  30. package/src/cli/docs.ts +4 -2
  31. package/src/cli/help.ts +25 -2
  32. package/src/cli/index.ts +38 -7
  33. package/src/cli/info.ts +4 -3
  34. package/src/cli/init.ts +55 -20
  35. package/src/cli/last.ts +184 -41
  36. package/src/cli/lock.ts +187 -24
  37. package/src/cli/plan-format.ts +24 -21
  38. package/src/cli/plugin-commands.ts +6 -1
  39. package/src/cli/plugin-templates.ts +1 -1
  40. package/src/cli/run.ts +68 -34
  41. package/src/cli/select.ts +160 -64
  42. package/src/cli/show.ts +63 -18
  43. package/src/cli/task-verb.ts +12 -7
  44. package/src/cli/upgrade.ts +210 -76
  45. package/src/cli/watch-filter.ts +88 -15
  46. package/src/cli/watch-fs.ts +34 -47
  47. package/src/cli/watch-judge.ts +72 -8
  48. package/src/cli/watch-set.ts +6 -4
  49. package/src/cli/watch.ts +179 -25
  50. package/src/cli/why.ts +107 -33
  51. package/src/cli/workspace-config.ts +11 -1
  52. package/src/config.ts +9 -0
  53. package/src/exec/env.ts +3 -1
  54. package/src/exec/executor.ts +76 -15
  55. package/src/exec/kill-tree.ts +7 -7
  56. package/src/exec/runner.ts +270 -108
  57. package/src/exec/sandbox-binds.ts +109 -18
  58. package/src/exec/sandbox-runtime.ts +134 -37
  59. package/src/exec/sandbox-violations.ts +250 -46
  60. package/src/exec/spawn-off.ts +10 -5
  61. package/src/exec/warm-shell.ts +29 -7
  62. package/src/graph/folds.ts +89 -16
  63. package/src/graph/index.ts +8 -0
  64. package/src/graph/priorities.ts +8 -5
  65. package/src/graph/scheduler.ts +173 -38
  66. package/src/graph/stand-ins.ts +36 -0
  67. package/src/graph/task-graph.ts +327 -141
  68. package/src/graph/task-status.ts +5 -1
  69. package/src/handoff.ts +45 -10
  70. package/src/history/failure-mode.ts +15 -5
  71. package/src/history/history.ts +103 -30
  72. package/src/history/index.ts +1 -0
  73. package/src/history/metrics.ts +189 -69
  74. package/src/index.ts +2 -1
  75. package/src/orchestrator/admission.ts +13 -9
  76. package/src/orchestrator/affected-tasks.ts +35 -13
  77. package/src/orchestrator/command-windows.ts +8 -5
  78. package/src/orchestrator/deferred-outputs.ts +41 -12
  79. package/src/orchestrator/doctor.ts +21 -9
  80. package/src/orchestrator/download-policy.ts +3 -3
  81. package/src/orchestrator/excluded-keys.ts +14 -2
  82. package/src/orchestrator/execute-args.ts +9 -2
  83. package/src/orchestrator/execute-task.ts +564 -123
  84. package/src/orchestrator/graph-memo.ts +26 -4
  85. package/src/orchestrator/graph-stage.ts +27 -4
  86. package/src/orchestrator/held-servers.ts +36 -14
  87. package/src/orchestrator/hit-restore.ts +136 -87
  88. package/src/orchestrator/index.ts +4 -1
  89. package/src/orchestrator/keyed-projects.ts +25 -0
  90. package/src/orchestrator/local-floor.ts +21 -3
  91. package/src/orchestrator/lockfile-claim.ts +30 -4
  92. package/src/orchestrator/miss-save.ts +33 -53
  93. package/src/orchestrator/options.ts +13 -0
  94. package/src/orchestrator/output-host.ts +16 -4
  95. package/src/orchestrator/output-log.ts +1 -54
  96. package/src/orchestrator/persistent-task.ts +31 -17
  97. package/src/orchestrator/persistent.ts +152 -15
  98. package/src/orchestrator/placement.ts +81 -15
  99. package/src/orchestrator/plan.ts +37 -15
  100. package/src/orchestrator/plugin-host.ts +232 -108
  101. package/src/orchestrator/plugin.ts +53 -27
  102. package/src/orchestrator/prepare.ts +194 -38
  103. package/src/orchestrator/projects.ts +89 -10
  104. package/src/orchestrator/read-only.ts +115 -0
  105. package/src/orchestrator/run-context.ts +6 -2
  106. package/src/orchestrator/run-failures.ts +43 -14
  107. package/src/orchestrator/run-id.ts +13 -2
  108. package/src/orchestrator/run-keys.ts +87 -28
  109. package/src/orchestrator/run-live.ts +16 -13
  110. package/src/orchestrator/run-lock.ts +27 -4
  111. package/src/orchestrator/run-records.ts +12 -10
  112. package/src/orchestrator/run-report.ts +2 -1
  113. package/src/orchestrator/run-stop.ts +17 -9
  114. package/src/orchestrator/run.ts +443 -107
  115. package/src/orchestrator/sandbox-fix.ts +40 -12
  116. package/src/orchestrator/sandbox-request.ts +286 -59
  117. package/src/orchestrator/save-lane.ts +24 -13
  118. package/src/orchestrator/shell-verdict.ts +20 -1
  119. package/src/orchestrator/signals.ts +103 -8
  120. package/src/orchestrator/stable-keys.ts +17 -9
  121. package/src/orchestrator/task-artifacts.ts +15 -2
  122. package/src/orchestrator/task-hash.ts +187 -30
  123. package/src/orchestrator/telemetry-host.ts +25 -6
  124. package/src/orchestrator/telemetry.ts +80 -12
  125. package/src/orchestrator/upstream.ts +3 -1
  126. package/src/report/events.ts +20 -3
  127. package/src/report/forecast.ts +103 -37
  128. package/src/report/framed-output.ts +165 -157
  129. package/src/report/index.ts +2 -1
  130. package/src/report/logger.ts +107 -30
  131. package/src/report/plain-output.ts +13 -9
  132. package/src/report/status-line.ts +28 -55
  133. package/src/report/summary.ts +57 -14
  134. package/src/settings.ts +42 -6
  135. package/src/ui/components.ts +36 -42
  136. package/src/ui/index.ts +3 -1
  137. package/src/ui/live-region.ts +67 -8
  138. package/src/ui/terminal.ts +12 -0
  139. package/src/ui/theme.ts +16 -1
  140. package/src/util/config-frame.ts +17 -3
  141. package/src/util/edit-distance.ts +17 -1
  142. package/src/util/errors.ts +23 -7
  143. package/src/util/foreign-cache-dir.ts +60 -0
  144. package/src/util/git-ignored.ts +59 -5
  145. package/src/util/index.ts +15 -1
  146. package/src/util/paths.ts +50 -6
  147. package/src/util/plain-text.ts +19 -0
  148. package/src/util/secret-mask.ts +57 -1
  149. package/src/util/settle.ts +16 -0
  150. package/src/util/size.ts +12 -2
  151. package/src/util/write-file.ts +86 -9
  152. package/src/workspace/affected.ts +225 -27
  153. package/src/workspace/config-bytecode.ts +23 -4
  154. package/src/workspace/config-eval.ts +90 -16
  155. package/src/workspace/config-imports.ts +96 -41
  156. package/src/workspace/config-schema.ts +126 -25
  157. package/src/workspace/dependencies-lock.ts +7 -14
  158. package/src/workspace/filter.ts +10 -1
  159. package/src/workspace/index.ts +3 -1
  160. package/src/workspace/json-data.ts +34 -8
  161. package/src/workspace/lockfile.ts +86 -37
  162. package/src/workspace/migration.ts +46 -25
  163. package/src/workspace/module-state.ts +62 -0
  164. package/src/workspace/nested-dirs.ts +8 -0
  165. package/src/workspace/project-loader.ts +276 -32
  166. package/src/workspace/repo-id.ts +29 -6
  167. package/src/workspace/workspace.ts +190 -30
package/src/cli/run.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  shellQuote,
10
10
  type RunOptions,
11
11
  } from '../orchestrator/index.js'
12
- import type { ContinueMode } from '../graph/index.js'
12
+ import { type ContinueMode, isKeyFailed } from '../graph/index.js'
13
13
  import {
14
14
  applyCacheFlags,
15
15
  type CacheFlags,
@@ -18,7 +18,13 @@ import {
18
18
  gitRefusal,
19
19
  parseCachePolicy,
20
20
  } from '../cache/index.js'
21
- import { affectedFilterFor, findCwdSelection, pickTask, resolveFilters } from './select.js'
21
+ import {
22
+ type AffectedFilter,
23
+ affectedFilterFor,
24
+ findCwdSelection,
25
+ pickTask,
26
+ resolveFilters,
27
+ } from './select.js'
22
28
  import { nxTargetHint, taskNamesHere, undeclaredTasks } from './task-verb.js'
23
29
  import { envFlagOn, hasControl, parseDecimalInt } from '../util/index.js'
24
30
  import * as ui from '../ui/index.js'
@@ -408,7 +414,7 @@ async function scopeFilters(
408
414
  parsed: RunArgs,
409
415
  cwd: string,
410
416
  ): Promise<
411
- { filterStrings: string[]; affectedFilter?: string } | { error: string; code?: string }
417
+ { filterStrings: string[]; affected?: AffectedFilter } | { error: string; code?: string }
412
418
  > {
413
419
  // `--affected[=<base>]` is sugar for `--filter '...[<base>]'`: the
414
420
  // changed projects AND their dependents — what a CI gate must run, what
@@ -421,14 +427,21 @@ async function scopeFilters(
421
427
  // drops app from either side (item 955 had put it first for that). First
422
428
  // it stays, so a message naming the filters names it first.
423
429
  const filterStrings = [...parsed.filters]
424
- let affectedFilter: string | undefined
430
+ let affected: AffectedFilter | undefined
425
431
  if (parsed.affected !== undefined) {
426
- const f = await affectedFilterFor(cwd, parsed.affected)
427
- if (typeof f === 'object') return f
428
- affectedFilter = f
429
- filterStrings.unshift(affectedFilter)
432
+ // With --all given, "run --all" is advice already taken.
433
+ const f = await affectedFilterFor(
434
+ cwd,
435
+ parsed.affected,
436
+ parsed.all
437
+ ? 'drop --affected, --all runs every project (the cache skips what did not change)'
438
+ : undefined,
439
+ )
440
+ if ('error' in f) return f
441
+ affected = f
442
+ filterStrings.unshift(f.filter)
430
443
  }
431
- return { filterStrings, ...(affectedFilter !== undefined ? { affectedFilter } : {}) }
444
+ return { filterStrings, ...(affected !== undefined ? { affected } : {}) }
432
445
  }
433
446
 
434
447
  /**
@@ -477,10 +490,10 @@ export async function resolveRunOptions(
477
490
 
478
491
  const scope = await scopeFilters(parsed, cwd)
479
492
  if ('error' in scope) return scope
480
- const { filterStrings, affectedFilter } = scope
493
+ const { filterStrings, affected: affectedFilter } = scope
481
494
  // `...[<base>]`: the base alone.
482
495
  if (scopeOut !== undefined && affectedFilter !== undefined)
483
- scopeOut.affectedBase = affectedFilter.slice(4, -1)
496
+ scopeOut.affectedBase = affectedFilter.filter.slice(4, -1)
484
497
 
485
498
  // Project scope applies to bare task names only. Anchored entries
486
499
  // (pkg#task) resolve directly to their own project regardless.
@@ -611,17 +624,47 @@ export async function runCmd(args: readonly string[]): Promise<number> {
611
624
  // anchored pkg#task; the rest of the pipeline treats it like any
612
625
  // explicit anchored positional.
613
626
  if (tasks.length === 0) {
627
+ const load = parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}
628
+ // `--filter` / `--affected` scope the menu and the list, as they scope a
629
+ // bare task: the menu listed every project and the anchored pick ran
630
+ // outside it; the list named the cwd's tasks.
631
+ const selection = async (): Promise<
632
+ { only?: ReadonlySet<string> } | { error: string; code?: string } | { empty: string }
633
+ > => {
634
+ const scope = await scopeFilters(parsed, cwd)
635
+ if ('error' in scope) return scope
636
+ if (scope.filterStrings.length === 0) return {}
637
+ const selected = await resolveFilters(cwd, scope.filterStrings, load, scope.affected)
638
+ if ('error' in selected || 'empty' in selected) return selected
639
+ return { only: new Set(selected.names) }
640
+ }
614
641
  // A JSON answer is for a program, which cannot pick from a menu: an
615
642
  // agent on a pty waited at the picker forever.
616
643
  const forProgram = parsed.dry === 'json'
617
644
  if (!process.stdin.isTTY || forProgram) {
618
- const names = await taskNamesHere(cwd)
619
- const shown = (names ?? []).slice(0, TASKS_SHOWN)
620
- const more = (names?.length ?? 0) - shown.length
645
+ const scoped = await selection()
646
+ if ('error' in scoped) return refuse('run', args, scoped.error, scoped.code ?? 'VX_E_USAGE')
647
+ const asked =
648
+ 'empty' in scoped ? undefined : (scoped.only ?? (parsed.all ? 'all' : undefined))
649
+ const here = 'empty' in scoped ? null : await taskNamesHere(cwd, asked)
650
+ const names = here?.names ?? []
651
+ const shown = names.slice(0, TASKS_SHOWN)
652
+ const more = names.length - shown.length
653
+ const list = `${shown.join(', ')}${more > 0 ? ` and ${more} more` : ''}`
654
+ // Outside a project a bare name is refused without --all: the list
655
+ // said "tasks here", and `vx run build` then said "not inside a project".
621
656
  const tasksHere =
622
- shown.length === 0
623
- ? 'vx run <task>, e.g. vx run build'
624
- : `tasks here: ${shown.join(', ')}${more > 0 ? ` and ${more} more` : ''}`
657
+ 'empty' in scoped
658
+ ? scoped.empty
659
+ : shown.length === 0
660
+ ? 'vx run <task>, e.g. vx run build'
661
+ : asked === 'all'
662
+ ? `every project's tasks: ${list}`
663
+ : asked !== undefined
664
+ ? `the selected projects' tasks: ${list}`
665
+ : here?.inProject === false
666
+ ? `not inside a project, the workspace's tasks with --all: ${list}`
667
+ : `tasks here: ${list}`
625
668
  return refuse(
626
669
  'run',
627
670
  args,
@@ -635,23 +678,13 @@ export async function runCmd(args: readonly string[]): Promise<number> {
635
678
  sayLine('error', refusal.message, 'vx run')
636
679
  return 1
637
680
  }
638
- const load = parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}
639
- // `--filter` / `--affected` scope the menu, as they scope a bare task:
640
- // the menu listed every project and the anchored pick ran outside it.
641
- const scope = await scopeFilters(parsed, cwd)
642
- if ('error' in scope) return refuse('run', args, scope.error, scope.code ?? 'VX_E_USAGE')
643
- let only: ReadonlySet<string> | undefined
644
- if (scope.filterStrings.length > 0) {
645
- const selected = await resolveFilters(cwd, scope.filterStrings, load, scope.affectedFilter)
646
- if ('error' in selected)
647
- return refuse('run', args, selected.error, selected.code ?? 'VX_E_USAGE')
648
- if ('empty' in selected) {
649
- sayNotice('info', 'vx run', selected.empty)
650
- return 0
651
- }
652
- only = new Set(selected.names)
681
+ const scoped = await selection()
682
+ if ('error' in scoped) return refuse('run', args, scoped.error, scoped.code ?? 'VX_E_USAGE')
683
+ if ('empty' in scoped) {
684
+ sayNotice('info', 'vx run', scoped.empty)
685
+ return 0
653
686
  }
654
- const chosen = await pickTask(cwd, {}, load, only)
687
+ const chosen = await pickTask(cwd, {}, load, scoped.only)
655
688
  if (chosen === 'interrupted') return 130
656
689
  if (!chosen) return 1
657
690
  picked = `${chosen.project}#${chosen.task}`
@@ -713,7 +746,8 @@ export async function runCmd(args: readonly string[]): Promise<number> {
713
746
  } else {
714
747
  ui.print('out', planReport(plan))
715
748
  }
716
- return 0
749
+ // The run fails a task whose key cannot be derived; its plan does too.
750
+ return isKeyFailed(plan) ? 1 : 0
717
751
  }
718
752
 
719
753
  // A run executes in THIS process, always. Where an individual task's
package/src/cli/select.ts CHANGED
@@ -25,10 +25,10 @@ import {
25
25
  type ProjectMeta,
26
26
  readLockfile,
27
27
  } from '../workspace/index.js'
28
- import type { ProjectConfig } from '../config.js'
28
+ import type { ProjectConfig, TaskConfig } from '../config.js'
29
29
  import type { ProjectEntry } from '../workspace/index.js'
30
30
  import { isDefaultBuild, parseDependencySpec } from '../graph/index.js'
31
- import { declaresInput, workspaceFilesReachInto } from '../cache/index.js'
31
+ import { declaresInput, lazyGitEnumeration, workspaceFilesReachInto } from '../cache/index.js'
32
32
  import {
33
33
  isUserError,
34
34
  listed,
@@ -45,7 +45,6 @@ import {
45
45
  claimedExtraFiles,
46
46
  claimedDigests,
47
47
  fingerprintClaims,
48
- gitOfDiscovery,
49
48
  hasHook,
50
49
  keepDiscoveryGraph,
51
50
  } from '../orchestrator/index.js'
@@ -77,17 +76,53 @@ export async function workspaceGlobOwners(
77
76
  nested: readonly string[] = [],
78
77
  /** `AffectedChanges.dirLinks`. */
79
78
  dirLinks: ReadonlySet<string> = new Set(),
79
+ /** `AffectedChanges.untracked`. */
80
+ untracked: ReadonlySet<string> = new Set(),
80
81
  ): Promise<string[]> {
81
- const declaresMatch = (config: ProjectConfig): boolean => {
82
+ // The changed paths a `workspaceFiles` list matches, asked once per
83
+ // distinct list rather than once per task: tasks × changed paths was
84
+ // 14.5 s on 8000 projects with 64k changed paths. A tracked match selects
85
+ // any task declaring the list; an untracked one only a task whose own
86
+ // outputs it is not, so those are kept for the per-task check.
87
+ const byList = new Map<string, { tracked: boolean; loose: string[] }>()
88
+ const matchesOf = (ws: readonly string[]) => {
89
+ const key = ws.join('\0')
90
+ let found = byList.get(key)
91
+ if (found !== undefined) return found
92
+ found = { tracked: false, loose: [] }
93
+ const probe = { inputs: { files: [], workspaceFiles: ws } } as unknown as TaskConfig
94
+ for (const rel of changed) {
95
+ const loose = untracked.has(rel)
96
+ if (!declaresInput(probe, null, rel, dirLinks.has(rel))) continue
97
+ if (!loose) {
98
+ found.tracked = true
99
+ break
100
+ }
101
+ found.loose.push(rel)
102
+ }
103
+ byList.set(key, found)
104
+ return found
105
+ }
106
+ const declaresMatch = (config: ProjectConfig, projectAbs: string): boolean => {
107
+ let projectDir: string | undefined
82
108
  for (const task of Object.values(config.tasks ?? {})) {
83
- if (changed.some((rel) => declaresInput(task, null, rel, dirLinks.has(rel)))) return true
109
+ const ws = task.inputs?.workspaceFiles
110
+ if (ws === undefined || ws.length === 0) continue
111
+ const input = (rel: string): boolean => {
112
+ const loose = untracked.has(rel)
113
+ if (loose) projectDir ??= relPosix(root, projectAbs)
114
+ return declaresInput(task, null, rel, dirLinks.has(rel), loose, projectDir)
115
+ }
116
+ // A hand-edited lock entry that is no list is asked path by path.
117
+ const found = Array.isArray(ws) ? matchesOf(ws) : { tracked: changed.some(input), loose: [] }
118
+ if (found.tracked || found.loose.some(input)) return true
84
119
  if (nested.some((dir) => workspaceFilesReachInto(task, dir))) return true
85
120
  }
86
121
  return false
87
122
  }
88
123
  try {
89
124
  const staged = await stagedLoad()
90
- return [...staged.values()].filter((p) => declaresMatch(p.config)).map((p) => p.name)
125
+ return [...staged.values()].filter((p) => declaresMatch(p.config, p.dir)).map((p) => p.name)
91
126
  } catch {
92
127
  // Fall through to the per-file sweep.
93
128
  }
@@ -102,7 +137,7 @@ export async function workspaceGlobOwners(
102
137
  const dir = lockKey(root, meta.dir)
103
138
  if (!Object.hasOwn(lock.projects, dir)) continue
104
139
  try {
105
- if (declaresMatch(lock.projects[dir]!.config)) owners.push(meta.name)
140
+ if (declaresMatch(lock.projects[dir]!.config, meta.dir)) owners.push(meta.name)
106
141
  } catch {
107
142
  // A hand-edited entry that will not read cannot be shown to declare a
108
143
  // matching glob; surfacing it here would fail a run the load tolerates.
@@ -287,6 +322,12 @@ function refusalOf(err: unknown): Refusal {
287
322
  : { error: err.message }
288
323
  }
289
324
 
325
+ /** The filter `--affected` became; `env` is the `VX_AFFECTED_BASE` value that gave its base. */
326
+ export interface AffectedFilter {
327
+ readonly filter: string
328
+ readonly env?: string
329
+ }
330
+
290
331
  /**
291
332
  * The filter `--affected[=<base>]` stands for: `...[<base>]`, the changed
292
333
  * projects and their dependents. An empty base is `VX_AFFECTED_BASE`'s,
@@ -298,9 +339,10 @@ export async function affectedFilterFor(
298
339
  affected: string,
299
340
  /** What the verb does instead on the base branch (`defaultAffectedBase`). */
300
341
  instead?: string,
301
- ): Promise<string | Refusal> {
342
+ ): Promise<AffectedFilter | Refusal> {
302
343
  const root = await findWorkspaceRoot(cwd)
303
- let base = affected || ((settingEnv('affectedBase') as string | undefined) ?? '')
344
+ const env = affected === '' ? (settingEnv('affectedBase') as string | undefined) : undefined
345
+ let base = affected || (env ?? '')
304
346
  if (base === '') {
305
347
  const ws = await loadCliWorkspace(root)
306
348
  try {
@@ -312,42 +354,98 @@ export async function affectedFilterFor(
312
354
  return refusalOf(err)
313
355
  }
314
356
  }
315
- return `...[${base}]`
357
+ return env === undefined ? { filter: `...[${base}]` } : { filter: `...[${base}]`, env }
316
358
  }
317
359
 
318
360
  export async function resolveFilters(
319
361
  cwd: string,
320
362
  raw: string[],
321
363
  load: CliLoadOptions = {},
322
- /** The raw filter `--affected` became: its diff is kept per path for the run. */
323
- affected?: string,
364
+ /** The filter `--affected` became: its diff is kept per path for the run. */
365
+ affected?: AffectedFilter,
324
366
  /** The verb a warning names: `vx show` and `vx watch` select as a run does. */
325
367
  verb: 'run' | 'show' | 'watch' = 'run',
326
368
  ): Promise<FilterResolution> {
327
369
  const root = await findWorkspaceRoot(cwd)
328
- const projects = await loadWorkspaceProjects(cwd, load)
329
370
  let parsed: ReturnType<typeof parseFilter>[]
330
371
  try {
331
372
  parsed = raw.map((r) => parseFilter(r, root))
332
373
  } catch (err) {
374
+ await loadWorkspaceProjects(cwd, load)
333
375
  return refusalOf(err)
334
376
  }
377
+ // A filter that diffs against git reads its untracked files from the
378
+ // run's whole-tree walk (one walk where it spawned its own), and the run
379
+ // reuses it with the discovery (I-26). The walk needs only the root, so
380
+ // it starts before the workspace config and discovery, not after them.
381
+ const git = parsed.some((f) => f.gitSince !== undefined) ? lazyGitEnumeration(root) : undefined
382
+ void git?.start()
383
+ const discovering = discoverCliProjects(root, load, git)
384
+ discovering.catch(() => {})
335
385
  // `sinceViaDeps` (`e2e...[main]`) walks the changed projects' dependents.
336
386
  const walksGraph = parsed.some(
337
387
  (f) => f.withDeps || f.withDependents || f.onlyDeps || f.sinceViaDeps === true,
338
388
  )
339
- // A filter that diffs against git starts the run's whole-tree walk now:
340
- // the diff reads its untracked files from it (one walk where it spawned
341
- // its own), and the run reuses it with the discovery (I-26).
342
- const git = parsed.some((f) => f.gitSince !== undefined) ? gitOfDiscovery(projects) : undefined
343
- void git?.start()
344
389
  // Every reader of the staged configs in this pass — the `pkg#task`
345
390
  // edge walk, the `workspaceFiles` owners of a changed path — shares
346
391
  // ONE load, and the run reuses it (`RunOptions.staged`): the `project`
347
392
  // stage runs once per project per run.
348
393
  let stagedPromise: Promise<Map<string, ProjectEntry>> | undefined
349
394
  const stagedOnce = (): Promise<Map<string, ProjectEntry>> =>
350
- (stagedPromise ??= loadCliProjects(root, projects, 'all', load))
395
+ (stagedPromise ??= discovering.then((projects) => loadCliProjects(root, projects, 'all', load)))
396
+ // A scoped enumeration lists only its projects: null asks git.
397
+ const enumeration = async () => (git === undefined ? undefined : await git.start())
398
+ // Each `[<since>]` diff starts now, before discovery ends: merge-base and
399
+ // diff need only the root and the ref, so they overlap the workspace
400
+ // config, discovery and the config load, where they waited on all three.
401
+ // One after another, as a shallow clone's fetches must be; each is
402
+ // awaited in filter order below, so a refusal comes where it came.
403
+ const diffs = new Map<(typeof parsed)[number], Promise<AffectedChanges | Set<string>>>()
404
+ let previous: Promise<unknown> | undefined
405
+ for (const f of parsed) {
406
+ if (f.gitSince === undefined) continue
407
+ const args: Parameters<typeof affectedChanges>[0] = {
408
+ workspaceRoot: root,
409
+ since: f.gitSince,
410
+ projects: discovering,
411
+ workspaceGlobOwners: async (
412
+ changed: readonly string[],
413
+ nested: readonly string[],
414
+ dirLinks: ReadonlySet<string>,
415
+ untracked: ReadonlySet<string>,
416
+ ) =>
417
+ workspaceGlobOwners(
418
+ root,
419
+ await discovering,
420
+ changed,
421
+ load,
422
+ stagedOnce,
423
+ nested,
424
+ dirLinks,
425
+ untracked,
426
+ ),
427
+ fingerprintClaims: async () => workspaceFingerprintClaims(root, await discovering, load),
428
+ untracked: async () => (await enumeration())?.untracked ?? null,
429
+ links: async () => {
430
+ const g = await enumeration()
431
+ if (g === undefined || !g.scopes.includes('.')) return null
432
+ const out: string[] = []
433
+ for (const [rel, oid] of g.trusted) if (oid.startsWith('120000:')) out.push(rel)
434
+ return out
435
+ },
436
+ flagged: async () => {
437
+ const g = await enumeration()
438
+ return g !== undefined && g.scopes.includes('.') ? g.flagged : null
439
+ },
440
+ }
441
+ const diff = (): Promise<AffectedChanges | Set<string>> =>
442
+ f.raw === affected?.filter ? affectedChanges(args) : affectedProjects(args)
443
+ const started = previous === undefined ? diff() : previous.then(diff)
444
+ started.catch(() => {})
445
+ diffs.set(f, started)
446
+ previous = started
447
+ }
448
+ const projects = await discovering
351
449
  let edges: Map<string, string[]> | undefined
352
450
  if (walksGraph) {
353
451
  try {
@@ -367,50 +465,34 @@ export async function resolveFilters(
367
465
  }
368
466
  }
369
467
  const graph = buildPackageGraph(projects, edges)
370
- // The graph a run reusing this discovery builds is this one when no task
371
- // edge went into it.
372
- if (edges === undefined) keepDiscoveryGraph(projects, graph)
468
+ // The graph a run reusing this discovery builds is this one when the task
469
+ // edges added none: each is a discovered edge already, or names no project.
470
+ const known = new Set(projects.map((p) => p.name))
471
+ if (
472
+ edges === undefined ||
473
+ projects.every((p) =>
474
+ (edges.get(p.name) ?? []).every((t) => !known.has(t) || p.deps?.includes(t) === true),
475
+ )
476
+ )
477
+ keepDiscoveryGraph(projects, graph)
373
478
 
374
- // Resolve every `[<since>]` filter against git before the pure
375
- // applyFilters pass runs. One spawn per distinct ref — usually
376
- // there's only one anyway.
479
+ // Every `[<since>]` filter's diff, started above, is in before the pure
480
+ // applyFilters pass runs.
377
481
  const affectedByFilter = new Map<(typeof parsed)[number], Set<string>>()
482
+ // The diffs that named a change, though maybe in no project: a root file
483
+ // no task declares. Then the tree is not clean against HEAD.
484
+ const changedAny = new Set<(typeof parsed)[number]>()
378
485
  let changes: AffectedChanges | undefined
379
- for (const f of parsed) {
380
- if (f.gitSince === undefined) continue
486
+ for (const [f, diff] of diffs) {
381
487
  try {
382
- const args = {
383
- workspaceRoot: root,
384
- since: f.gitSince,
385
- projects,
386
- workspaceGlobOwners: (
387
- changed: readonly string[],
388
- nested: readonly string[],
389
- dirLinks: ReadonlySet<string>,
390
- ) => workspaceGlobOwners(root, projects, changed, load, stagedOnce, nested, dirLinks),
391
- fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
392
- ...(git !== undefined
393
- ? {
394
- untracked: async () => (await git.start()).untracked,
395
- // A scoped enumeration lists only its projects: null asks git.
396
- links: async () => {
397
- const g = await git.start()
398
- if (!g.scopes.includes('.')) return null
399
- const out: string[] = []
400
- for (const [rel, oid] of g.trusted) if (oid.startsWith('120000:')) out.push(rel)
401
- return out
402
- },
403
- flagged: async () => {
404
- const g = await git.start()
405
- return g.scopes.includes('.') ? g.flagged : null
406
- },
407
- }
408
- : {}),
409
- }
488
+ const answer = await diff
410
489
  let names: Set<string>
411
- if (f.raw === affected) {
412
- changes = await affectedChanges(args)
490
+ if (answer instanceof Set) {
491
+ names = answer
492
+ } else {
493
+ changes = answer
413
494
  names = changes.projects
495
+ if (changes.changed.length > 0 || changes.nested !== undefined) changedAny.add(f)
414
496
  // A `graph` plugin may tie any task to a changed path (an edge, an
415
497
  // input), and the run alone builds that graph: every project is a
416
498
  // candidate, and the run keeps the tasks its final graph reaches.
@@ -420,12 +502,18 @@ export async function resolveFilters(
420
502
  ) {
421
503
  names = new Set(projects.map((p) => p.name))
422
504
  }
423
- } else {
424
- names = await affectedProjects(args)
425
505
  }
426
506
  affectedByFilter.set(f, names)
427
507
  } catch (err) {
428
- return refusalOf(err)
508
+ const refusal = refusalOf(err)
509
+ // A base the variable gave was reported as if typed.
510
+ if (
511
+ affected?.env !== undefined &&
512
+ f.raw === affected.filter &&
513
+ refusal.code === 'VX_E_AFFECTED_BASE'
514
+ )
515
+ return { ...refusal, error: `VX_AFFECTED_BASE=${affected.env}: ${refusal.error}` }
516
+ return refusal
429
517
  }
430
518
  }
431
519
 
@@ -482,10 +570,14 @@ export async function resolveFilters(
482
570
  // the branch under test) can never mark anything affected, and this
483
571
  // exit-0 note was the only sign (CI persona, 2026-09-16). Say so.
484
572
  const self = includes.map((f) => headBase(root, f.gitSince!)).find((b) => b !== undefined)
573
+ // `HEAD` asked by name checks uncommitted work: "HEAD is HEAD itself"
574
+ // read as nonsense for a valid question.
485
575
  const hint =
486
- self === undefined
576
+ self === undefined || (self === 'HEAD' && includes.some((f) => changedAny.has(f)))
487
577
  ? ''
488
- : ` — ${self} is HEAD itself: compare with the branch you merge into (--affected=origin/main) or the previous commit (--affected=HEAD~1)`
578
+ : self === 'HEAD'
579
+ ? ' — the working tree has no change against HEAD; to compare commits, use the branch you merge into (--affected=origin/main) or the previous commit (--affected=HEAD~1)'
580
+ : ` — ${self} is HEAD itself: compare with the branch you merge into (--affected=origin/main) or the previous commit (--affected=HEAD~1)`
489
581
  return { empty: `nothing affected since ${refs}${hint}` }
490
582
  }
491
583
  // The diff matched and a walk or an exclusion left nothing: still the
@@ -507,7 +599,11 @@ export async function resolveFilters(
507
599
  // app, and `--affected` a `...[<base>]` nobody typed.
508
600
  const hint =
509
601
  (await unlockedHint(root, parsed, unmatched)) ?? didYouMean(unmatched, projects, tags)
510
- return { error: `no projects matched filter(s): ${unmatched.join(', ')}${hint}` }
602
+ // Quoted, as the warning below quotes it: bare, `apq...` and the hint's
603
+ // full stop read `apq....` in the flat message `--format json` carries.
604
+ return {
605
+ error: `no projects matched filter(s): ${unmatched.map((f) => `"${f}"`).join(', ')}${hint}`,
606
+ }
511
607
  }
512
608
  // Something matched, so the run proceeds; a pattern that matched nothing
513
609
  // alongside it is still worth a line — it is probably a typo.
@@ -520,7 +616,7 @@ export async function resolveFilters(
520
616
  // Each include is a union: what `--filter other` selected runs its tasks
521
617
  // whether or not the `--affected` diff reaches them, and an exclude
522
618
  // still removes it (X-10).
523
- const others = parsed.filter((f) => f.negate || f.raw !== affected)
619
+ const others = parsed.filter((f) => f.negate || f.raw !== affected?.filter)
524
620
  const outright =
525
621
  changes !== undefined && others.some((f) => !f.negate)
526
622
  ? [