@vzn/vx 0.0.651 → 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 (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  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 +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. package/src/workspace/workspace.ts +190 -30
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
  ? [
package/src/cli/show.ts CHANGED
@@ -6,11 +6,12 @@
6
6
  // resolved config. `<pkg>#<task>`: one task. `<task>`: that task in every
7
7
  // project declaring it.
8
8
 
9
+ import { existsSync } from 'node:fs'
9
10
  import path from 'node:path'
10
11
  import type { ProjectConfig, TaskConfig } from '../config.js'
11
12
  import { declaredTask } from '../graph/index.js'
12
13
  import { isDefaultBuild } from '../graph/index.js'
13
- import { affectedTaskProjects } from '../orchestrator/index.js'
14
+ import { affectedTaskProjects, projectAtDir, unlockedDir } from '../orchestrator/index.js'
14
15
  import { flagHint, formatValue, refuse, seeHelp } from './help.js'
15
16
  import {
16
17
  listed,
@@ -22,7 +23,7 @@ import {
22
23
  } from '../util/index.js'
23
24
  import * as ui from '../ui/index.js'
24
25
  import { sayNotice } from '../ui/index.js'
25
- import { affectedFilterFor, resolveFilters } from './select.js'
26
+ import { type AffectedFilter, affectedFilterFor, resolveFilters } from './select.js'
26
27
  import {
27
28
  discoverCliProjects,
28
29
  loadCliProjects,
@@ -34,6 +35,7 @@ import {
34
35
  buildPackageGraph,
35
36
  EMPTY_LOCK,
36
37
  findWorkspaceRoot,
38
+ PROJECT_CONFIG_FILENAMES,
37
39
  type ProjectEntry,
38
40
  type ProjectMeta,
39
41
  } from '../workspace/index.js'
@@ -113,6 +115,22 @@ export async function showCmd(args: readonly string[]): Promise<number> {
113
115
  }
114
116
  parsed.target = `${rootMeta.name}${parsed.target.slice(2)}`
115
117
  }
118
+ // A project's directory typed for its name: by edit distance, `p/a`
119
+ // hinted `p/b`, another project.
120
+ const atDir = (typed: string, suffix: string): string | undefined => {
121
+ const name = projectAtDir(typed, root, metas)
122
+ return name === undefined ? undefined : ` — did you mean ${name}${suffix}?`
123
+ }
124
+ // A directory whose config came after the lock: the line `vx run` gives,
125
+ // not "unknown project" and a guess.
126
+ const refuseUnlocked = async (typed: string): Promise<void> => {
127
+ const config = await unlockedDir(typed, metas, root)
128
+ if (config === undefined) return
129
+ throw new UserError(
130
+ `${parsed.target}: ${relPosix(root, config)} is not in the lock yet — run \`vx lock\``,
131
+ 'VX_E_LOCK',
132
+ )
133
+ }
116
134
  const hashAt = parsed.target?.indexOf('#') ?? -1
117
135
  const projectName =
118
136
  parsed.target === undefined
@@ -136,8 +154,9 @@ export async function showCmd(args: readonly string[]): Promise<number> {
136
154
  }
137
155
  if (projectName !== undefined && (taskName !== undefined || byName.has(projectName))) {
138
156
  if (!byName.has(projectName)) {
157
+ await refuseUnlocked(projectName)
139
158
  throw new UserError(
140
- `vx show: unknown project: "${projectName}"${suggest(projectName, [...byName.keys()], '', 'projects')}`,
159
+ `vx show: unknown project: "${projectName}"${atDir(projectName, `#${taskName}`) ?? suggest(projectName, [...byName.keys()], '', 'projects')}`,
141
160
  'VX_E_UNKNOWN_PROJECT',
142
161
  )
143
162
  }
@@ -161,16 +180,16 @@ export async function showCmd(args: readonly string[]): Promise<number> {
161
180
  )
162
181
  }
163
182
  const raw = [...parsed.filters]
164
- let affected: string | undefined
183
+ let affected: AffectedFilter | undefined
165
184
  if (parsed.affected !== undefined) {
166
185
  const f = await affectedFilterFor(
167
186
  process.cwd(),
168
187
  parsed.affected,
169
188
  'drop --affected to list them all',
170
189
  )
171
- if (typeof f === 'object') throw new UserError(`vx show: ${f.error}`, f.code ?? 'VX_E_USAGE')
190
+ if ('error' in f) throw new UserError(`vx show: ${f.error}`, f.code ?? 'VX_E_USAGE')
172
191
  affected = f
173
- raw.unshift(f)
192
+ raw.unshift(f.filter)
174
193
  }
175
194
  const r = await resolveFilters(process.cwd(), raw, {}, affected, 'show')
176
195
  if ('error' in r) throw new UserError(`vx show: ${r.error}`, r.code ?? 'VX_E_USAGE')
@@ -203,13 +222,14 @@ export async function showCmd(args: readonly string[]): Promise<number> {
203
222
  declares(declaredTask(p.config, projectName!)),
204
223
  )
205
224
  if (declaring.length === 0) {
225
+ await refuseUnlocked(projectName!)
206
226
  const names = new Set<string>()
207
227
  for (const p of projects.values())
208
228
  for (const [t, task] of Object.entries(p.config.tasks ?? {}))
209
229
  if (declares(task)) names.add(t)
210
230
  // `nx show projects` lists them; here a bare `vx show` does.
211
231
  const nx = projectName === 'projects' ? ' (`nx show projects` is `vx show` here)' : ''
212
- const unknown = `vx show: unknown project or task: "${projectName}"${nx}${suggest(projectName!, [...byName.keys(), ...names], '', 'projects and tasks')}`
232
+ const unknown = `vx show: unknown project or task: "${projectName}"${nx}${atDir(projectName!, '') ?? suggest(projectName!, [...byName.keys(), ...names], '', 'projects and tasks')}`
213
233
  const base =
214
234
  parsed.spacedBase === projectName
215
235
  ? `${unknown.endsWith('.') ? '' : '.'} \`--affected\` takes its base with '=': --affected=${projectName}`
@@ -258,7 +278,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
258
278
  const dir = projectDir(root, meta)
259
279
 
260
280
  if (taskName === undefined) {
261
- emit(renderProject(meta.name, dir, config, meta.configPath !== null, parsed.format))
281
+ emit(renderProject(meta.name, dir, config, configNote(meta), parsed.format))
262
282
  return 0
263
283
  }
264
284
 
@@ -369,7 +389,9 @@ function renderList(
369
389
  tags: projects.get(meta.name)?.config.tags ?? [],
370
390
  tasks: Object.keys(tasks),
371
391
  onlyDefault: Object.values(tasks).every((t) => isDefaultBuild(t)),
392
+ hasDefault: Object.values(tasks).some((t) => isDefaultBuild(t)),
372
393
  configured: meta.configPath !== null,
394
+ note: configNote(meta),
373
395
  }
374
396
  })
375
397
  if (format === 'json') {
@@ -383,23 +405,38 @@ function renderList(
383
405
  // A package with no config file has only tasks a plugin gave it, or the
384
406
  // default `build` a configured dependant's closure gave it.
385
407
  const tasks = r.configured
386
- ? count
387
- : n === 0
388
- ? 'no vx config'
389
- : r.onlyDefault
390
- ? `${count}, default build (no vx config)`
391
- : `${count} from plugins (no vx config)`
408
+ ? r.tasks.length > 0 && r.hasDefault
409
+ ? `${count}, default build`
410
+ : count
411
+ : r.note !== null && r.note !== 'none'
412
+ ? r.note
413
+ : n === 0
414
+ ? 'no vx config'
415
+ : r.onlyDefault
416
+ ? `${count}, default build (no vx config)`
417
+ : `${count} from plugins (no vx config)`
392
418
  return [ui.id(r.name), r.dir, tasks, ...(anyTags ? [r.tags.join(', ')] : [])]
393
419
  })
394
420
  const columns = ['project', 'dir', 'tasks', ...(anyTags ? ['tags'] : [])]
395
421
  return [ui.section('Projects', String(rows.length), ui.table(columns, cells))]
396
422
  }
397
423
 
424
+ /**
425
+ * Null for a project the lock holds a config for; else `none`, or a config
426
+ * written after the lock: "no vx config" sent a user who had just written
427
+ * one looking for a typo.
428
+ */
429
+ function configNote(meta: ProjectMeta): string | null {
430
+ if (meta.configPath !== null) return null
431
+ const file = PROJECT_CONFIG_FILENAMES.find((n) => existsSync(path.join(meta.dir, n)))
432
+ return file === undefined ? 'none' : `${file} not locked — run vx lock`
433
+ }
434
+
398
435
  function renderProject(
399
436
  name: string,
400
437
  dir: string,
401
438
  config: ProjectConfig | null,
402
- hasConfigFile: boolean,
439
+ note: string | null,
403
440
  format: 'pretty' | 'json',
404
441
  ): Shown {
405
442
  if (format === 'json') {
@@ -414,11 +451,16 @@ function renderProject(
414
451
  ['dir', dir],
415
452
  ...(tags.length > 0 ? [['tags', tags.join(', ')] as const] : []),
416
453
  // A config-less package a dependant reaches has the default `build` (X-9).
417
- ...(hasConfigFile ? [] : [['config', 'none'] as const]),
418
- ...(tasks.length === 0 && hasConfigFile ? [['tasks', 'none declared'] as const] : []),
454
+ ...(note === null ? [] : [['config', note] as const]),
455
+ ...(tasks.length === 0 && note === null ? [['tasks', 'none declared'] as const] : []),
419
456
  ]
420
457
  const blocks = tasks.map(([taskName, task]) =>
421
- joined([ui.text(ui.id(`${name}#${taskName}`)), ui.fields(taskRows(task))]),
458
+ joined([
459
+ isDefaultBuild(task)
460
+ ? ui.line(' ', ui.id(`${name}#${taskName}`), ui.dim(' default'))
461
+ : ui.text(ui.id(`${name}#${taskName}`)),
462
+ ui.fields(taskRows(task)),
463
+ ]),
422
464
  )
423
465
  return [ui.section(name, ui.fields(head), ...blocks)]
424
466
  }
@@ -507,17 +549,17 @@ function taskRows(raw: TaskConfig): ui.FieldRow[] {
507
549
  add('inputs.tasks', list(inputs.tasks))
508
550
  add('inputs.runtime', list(inputs.runtime))
509
551
  add('inputs.workspaceRuntime', list(inputs.workspaceRuntime))
552
+ add('inputs.stability.strategy', inputs.stability?.strategy)
553
+ add('inputs.stability.severity', inputs.stability?.severity)
510
554
  }
511
- const cache = task.cache
512
- if (cache !== undefined) {
513
- add('outputs.files', list(cache.outputs.files))
514
- add('outputs.workspaceFiles', list(cache.outputs.workspaceFiles))
515
- add(
516
- 'outputs.clean',
517
- cache.outputs.clean === undefined ? undefined : cache.outputs.clean ? 'yes' : 'no',
518
- )
519
- add('inputStability.strategy', cache.inputStability?.strategy)
520
- add('inputStability.severity', cache.inputStability?.severity)
555
+ const outputs = task.outputs
556
+ if (outputs !== undefined) {
557
+ add('outputs.files', list(outputs.files))
558
+ add('outputs.workspaceFiles', list(outputs.workspaceFiles))
559
+ add('outputs.clean', outputs.clean === undefined ? undefined : outputs.clean ? 'yes' : 'no')
521
560
  }
561
+ // The first question `vx why` raises ("declares no `cache: true`"), which
562
+ // only --format json answered.
563
+ if (task.cache === true) add('cache', 'yes')
522
564
  return rows
523
565
  }
@@ -68,20 +68,25 @@ export async function nxTargetHint(tasks: readonly string[], cwd: string): Promi
68
68
  }
69
69
 
70
70
  /**
71
- * The task names `vx run <task>` can take here: the cwd project's, else
72
- * every project's, sorted; null when no workspace loads. What a run with
73
- * no task and no terminal for the picker names instead of a guess.
71
+ * The task names `vx run <task>` can take in the asked scope (`asked`: the
72
+ * projects `--filter` / `--affected` selected, or `all`), else the cwd
73
+ * project's, else every project's, sorted; null when no workspace loads.
74
+ * What a run with no task and no terminal for the picker names instead of
75
+ * a guess.
74
76
  */
75
- export async function taskNamesHere(cwd: string): Promise<string[] | null> {
77
+ export async function taskNamesHere(
78
+ cwd: string,
79
+ asked?: ReadonlySet<string> | 'all',
80
+ ): Promise<{ names: string[]; inProject: boolean } | null> {
76
81
  const projects = await workspaceProjects(cwd)
77
82
  if (projects === null) return null
78
- const own = await findCwdProject(cwd)
83
+ const own = asked === undefined ? await findCwdProject(cwd) : null
79
84
  const names = new Set<string>()
80
85
  for (const p of projects.values())
81
- if (own === null || p.name === own)
86
+ if (typeof asked === 'object' ? asked.has(p.name) : own === null || p.name === own)
82
87
  for (const [t, task] of Object.entries(p.config.tasks ?? {}))
83
88
  if (!isDefaultBuild(task)) names.add(t)
84
- return [...names].sort()
89
+ return { names: [...names].sort(), inProject: own !== null }
85
90
  }
86
91
 
87
92
  /**