@vzn/vx 0.0.21 → 0.0.22

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 (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +611 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. package/src/workspace/workspace.ts +173 -23
package/src/cli/last.ts CHANGED
@@ -7,7 +7,13 @@
7
7
  import { Cache, noteSchemaReset } from '../cache/index.js'
8
8
  import { formatBytes } from './format.js'
9
9
  import { seeHelp } from './help.js'
10
- import { getInvocation, getRun, listInvocations } from '../orchestrator/index.js'
10
+ import {
11
+ exitSignal,
12
+ getInvocation,
13
+ getRun,
14
+ listInvocations,
15
+ type RunSummaryRow,
16
+ } from '../orchestrator/index.js'
11
17
  import { UserError } from '../util/index.js'
12
18
  import { findWorkspaceRoot } from '../workspace/index.js'
13
19
  import { cliCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
@@ -26,7 +32,14 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
26
32
  for (let i = 0; i < args.length; i++) {
27
33
  const a = args[i]!
28
34
  if (a === '--list' || a.startsWith('--list=')) {
29
- const lv = a === '--list' ? '10' : a.slice(7)
35
+ // `--list 5` is the count, like every other value flag's space form:
36
+ // it read as a run id, which `--list` then ignored, and ten runs came
37
+ // back (item 899). A run id is never a bare integer, so the next
38
+ // argument is taken only when it is one.
39
+ const next = args[i + 1]
40
+ const spaced = a === '--list' && next !== undefined && /^\d+$/.test(next)
41
+ const lv = a === '--list' ? (spaced ? next : '10') : a.slice(7)
42
+ if (spaced) i++
30
43
  const n = Number(lv)
31
44
  if (!Number.isInteger(n) || n < 1 || n > 500) {
32
45
  return { ...out, error: `invalid --list: ${lv} (expected 1..500)` }
@@ -53,6 +66,13 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
53
66
  if (out.runId !== undefined) return { ...out, error: `unexpected argument: ${a}` }
54
67
  out.runId = a
55
68
  }
69
+ // One run's replay or a list of runs, not both: the id was dropped.
70
+ if (out.runId !== undefined && out.list !== undefined) {
71
+ return {
72
+ ...out,
73
+ error: `a run id and --list do not combine: replay ${out.runId}, or list runs`,
74
+ }
75
+ }
56
76
  return out
57
77
  }
58
78
 
@@ -83,12 +103,84 @@ function fmtUsage(t: {
83
103
  return parts.length > 0 ? ` ${parts.join(' · ')}` : ''
84
104
  }
85
105
 
106
+ /**
107
+ * Why a row failed or was skipped, as the run's own footer said it (the
108
+ * v27 columns): a timeout or a never-ready server instead of the signal
109
+ * their exit stands for, the sandbox's violation count, a skip's blocker.
110
+ * Older rows carry none and read as before.
111
+ */
112
+ function reasonParts(t: RunSummaryRow): string {
113
+ const parts: string[] = []
114
+ if (t.status === 'failed') {
115
+ if (t.notReady !== null) {
116
+ parts.push(
117
+ `never ready: ${t.notReady === 'timeout' ? 'timed out' : t.notReady === 'exited' ? 'exited' : 'spawn failed'}`,
118
+ )
119
+ } else if (t.timedOut === true) {
120
+ parts.push('timed out')
121
+ } else {
122
+ const signal = exitSignal(t.exitCode)
123
+ if (signal !== undefined) parts.push(`128 + ${signal}`)
124
+ }
125
+ if (t.sandboxViolations !== null && t.sandboxViolations > 0) {
126
+ parts.push(`${t.sandboxViolations} sandbox violation${t.sandboxViolations === 1 ? '' : 's'}`)
127
+ }
128
+ } else if (t.status === 'skipped' && t.blockedBy !== null) {
129
+ parts.push(`after ${t.blockedBy} failed`)
130
+ }
131
+ return parts.map((p) => ` ${p}`).join('')
132
+ }
133
+
134
+ /** Hit rows shown before the rest fold — the slowest restores. */
135
+ const HITS_SHOWN = 16
136
+
137
+ /**
138
+ * The per-task rows: failures first, then what executed or was skipped,
139
+ * then cache hits. Hits are the noise of a warm run — 996 of a thousand
140
+ * rows on a red run put the failure a screen's height above the prompt
141
+ * (item 280) — so past `HITS_SHOWN` they fold into one line with their
142
+ * count; the ones shown are the slowest restores, the one thing a hit's
143
+ * row tells. `--format json` lists every row.
144
+ */
145
+ export function formatTaskRows(tasks: readonly RunSummaryRow[]): string[] {
146
+ if (tasks.length === 0) return []
147
+ const failed = tasks.filter((t) => t.status === 'failed')
148
+ const hits = tasks.filter((t) => t.status !== 'failed' && t.cacheHit === true)
149
+ const rest = tasks.filter((t) => t.status !== 'failed' && t.cacheHit !== true)
150
+ const folded = hits.length > HITS_SHOWN
151
+ const shownHits = folded
152
+ ? [...hits].sort((a, b) => b.durationMs - a.durationMs).slice(0, HITS_SHOWN)
153
+ : hits
154
+ const ordered = [...failed, ...rest, ...shownHits]
155
+ const idW = Math.max(...ordered.map((t) => `${t.project}#${t.task}`.length), 4)
156
+ const lines = ['']
157
+ for (const t of ordered) {
158
+ const id = `${t.project}#${t.task}`
159
+ // The terminal summary's own word for a task that runs every time
160
+ // by design; a reader must not take its row for a miss.
161
+ // A failure's row reads as the frame did — `failed (exit 137)` —
162
+ // and above 128 names the signal the number stands for (260).
163
+ const status = t.status === 'failed' ? `failed (exit ${t.exitCode})` : t.status
164
+ lines.push(
165
+ ` ${status.padEnd(17)} ${id.padEnd(idW)} ${fmtMs(t.durationMs).padStart(8)}` +
166
+ `${t.hash !== '' ? ` ${t.hash}` : ''}${t.cached === false ? ' no-cache' : ''}${fmtUsage(t)}` +
167
+ reasonParts(t),
168
+ )
169
+ }
170
+ if (folded) {
171
+ lines.push(
172
+ ` … +${hits.length - HITS_SHOWN} more cache hits (the ${HITS_SHOWN} slowest restores shown) — vx last --format json lists every row`,
173
+ )
174
+ }
175
+ return lines
176
+ }
177
+
86
178
  export async function lastCmd(args: readonly string[]): Promise<number> {
87
179
  const parsed = parseLastArgs(args)
88
180
  if (parsed.error !== undefined) throw new UserError(`vx last: ${parsed.error}`)
89
181
 
90
182
  const root = await findWorkspaceRoot(process.cwd())
91
- const cache = new Cache(await cliCacheDir(root, parsed.cacheDir))
183
+ const cache = Cache.inspect(await cliCacheDir(root, parsed.cacheDir))
92
184
  noteSchemaReset(cache, warnToStderr)
93
185
  try {
94
186
  const db = cache.dbHandle()
@@ -146,24 +238,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
146
238
  ` (${inv.hitLocalCount} local, ${inv.hitRemoteCount} remote)` +
147
239
  `${inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : ''}`,
148
240
  )
149
- const tasks = detail?.tasks ?? []
150
- if (tasks.length > 0) {
151
- lines.push('')
152
- const failedFirst = [
153
- ...tasks.filter((t) => t.status === 'failed'),
154
- ...tasks.filter((t) => t.status !== 'failed'),
155
- ]
156
- const idW = Math.max(...tasks.map((t) => `${t.project}#${t.task}`.length), 4)
157
- for (const t of failedFirst) {
158
- const id = `${t.project}#${t.task}`
159
- // The terminal summary's own word for a task that runs every time
160
- // by design; a reader must not take its row for a miss.
161
- lines.push(
162
- ` ${t.status.padEnd(17)} ${id.padEnd(idW)} ${fmtMs(t.durationMs).padStart(8)}` +
163
- `${t.hash !== '' ? ` ${t.hash}` : ''}${t.cached === false ? ' no-cache' : ''}${fmtUsage(t)}`,
164
- )
165
- }
166
- }
241
+ lines.push(...formatTaskRows(detail?.tasks ?? []))
167
242
  process.stdout.write(`${lines.join('\n')}\n`)
168
243
  return 0
169
244
  } finally {
package/src/cli/lock.ts CHANGED
@@ -7,6 +7,7 @@ import { seeHelp } from './help.js'
7
7
  import { relPosix, xxh3hex } from '../util/index.js'
8
8
  import {
9
9
  findWorkspaceRoot,
10
+ type LoadReads,
10
11
  listProjects,
11
12
  loadProjectConfig,
12
13
  loadWorkspace,
@@ -41,32 +42,42 @@ export async function lockCmd(args: readonly string[]): Promise<number> {
41
42
  process.stderr.write(`vx lock: ${parsed.error}\n`)
42
43
  return 1
43
44
  }
44
- const root = await findWorkspaceRoot(process.cwd())
45
- const workspace = await loadWorkspace(root)
46
- const metas = (await listProjects(workspace)).filter(
47
- (m): m is ConfiguredMeta => m.configPath !== null,
48
- )
49
- if (parsed.check) return await checkLock(root, metas)
50
- return await writeLock(root, metas)
45
+ const reads: LoadReads = new Map()
46
+ const root = await findWorkspaceRoot(process.cwd(), reads)
47
+ const workspace = await loadWorkspace(root, reads)
48
+ const all = await listProjects(workspace)
49
+ const metas = all.filter((m): m is ConfiguredMeta => m.configPath !== null)
50
+ // A project with no vx.config has nothing to freeze: its tasks (a plugin's,
51
+ // or none) load live under --frozen too, so both verbs say how many the
52
+ // count above leaves out — `locked 0 project configs` on a turbo() repo
53
+ // read like an audit of something (2026-09-16).
54
+ const bare = all.length - metas.length
55
+ if (parsed.check) return await checkLock(root, metas, bare)
56
+ return await writeLock(root, metas, bare)
51
57
  }
52
58
 
53
59
  /** Evaluate one config in the current env, returning its lock entry. */
54
60
  async function evaluateEntry(root: string, meta: ConfiguredMeta): Promise<LockfileEntry> {
55
61
  const bytes = await Bun.file(meta.configPath).bytes()
56
- // `fresh: true` — locking must observe the current environment, not
57
- // a module-cache replay from earlier in this process.
62
+ // `fresh: true` — the lock freezes this environment's evaluation, never
63
+ // a stored one, whatever options a later edit passes beside it.
58
64
  const config = await loadProjectConfig(meta.configPath, { fresh: true })
59
65
  return {
60
66
  configPath: relPosix(root, meta.configPath),
61
67
  configHash: xxh3hex(bytes),
62
- // JSON round-trip drops `undefined` fields so the stored object is
63
- // byte-identical to what a later read of the lock will produce —
64
- // the exact form `--check` compares against.
65
- config: JSON.parse(JSON.stringify(config)) as ProjectConfig,
68
+ // Stored through `writeLockfile`'s JSON.stringify, which drops a field
69
+ // left `undefined` — the form `--check` compares a fresh evaluation in.
70
+ config,
66
71
  }
67
72
  }
68
73
 
69
- async function writeLock(root: string, metas: ConfiguredMeta[]): Promise<number> {
74
+ function bareNote(bare: number): string {
75
+ return bare === 0
76
+ ? ''
77
+ : ` (${bare} project${bare === 1 ? ' has' : 's have'} no vx.config; their tasks are never frozen)`
78
+ }
79
+
80
+ async function writeLock(root: string, metas: ConfiguredMeta[], bare: number): Promise<number> {
70
81
  const entries = await Promise.all(metas.map((m) => evaluateEntry(root, m)))
71
82
  const projects: Record<string, LockfileEntry> = {}
72
83
  // `listProjects` sorts by name — stable lockfile diffs for free.
@@ -74,7 +85,9 @@ async function writeLock(root: string, metas: ConfiguredMeta[]): Promise<number>
74
85
  const lock: Lockfile = { version: LOCKFILE_VERSION, projects }
75
86
  await writeLockfile(root, lock)
76
87
  const n = metas.length
77
- process.stdout.write(`vx: locked ${n} project config${n === 1 ? '' : 's'} → ${LOCKFILE_NAME}\n`)
88
+ process.stdout.write(
89
+ `vx: locked ${n} project config${n === 1 ? '' : 's'} → ${LOCKFILE_NAME}${bareNote(bare)}\n`,
90
+ )
78
91
  return 0
79
92
  }
80
93
 
@@ -86,7 +99,7 @@ async function writeLock(root: string, metas: ConfiguredMeta[]): Promise<number>
86
99
  * catches eval-time env-var drift that file hashes cannot see —
87
100
  * file bytes unchanged, resolved value changed.
88
101
  */
89
- async function checkLock(root: string, metas: ConfiguredMeta[]): Promise<number> {
102
+ async function checkLock(root: string, metas: ConfiguredMeta[], bare: number): Promise<number> {
90
103
  const lock = await readLockfile(root)
91
104
  if (!lock) {
92
105
  process.stderr.write(
@@ -105,6 +118,8 @@ async function checkLock(root: string, metas: ConfiguredMeta[]): Promise<number>
105
118
  if (hash !== entry.configHash) {
106
119
  return `config file changed since lock (${m.name}: ${rel}) — run 'vx lock'`
107
120
  }
121
+ // The lock's form: a first load is the module's live export, where a
122
+ // field left `undefined` is a key the stored JSON never has.
108
123
  const fresh = JSON.parse(
109
124
  JSON.stringify(await loadProjectConfig(m.configPath, { fresh: true })),
110
125
  ) as ProjectConfig
@@ -131,6 +146,8 @@ async function checkLock(root: string, metas: ConfiguredMeta[]): Promise<number>
131
146
  return 1
132
147
  }
133
148
  const n = metas.length
134
- process.stdout.write(`vx: lock is up to date (${n} project${n === 1 ? '' : 's'})\n`)
149
+ const audited = `${n} project${n === 1 ? '' : 's'}`
150
+ const skipped = bare === 0 ? '' : `; ${bare} without a vx.config not audited`
151
+ process.stdout.write(`vx: lock is up to date (${audited}${skipped})\n`)
135
152
  return 0
136
153
  }
@@ -9,12 +9,12 @@ import { formatDuration } from '../orchestrator/index.js'
9
9
  * task with recorded history also shows its typical duration (~p50).
10
10
  *
11
11
  * would run:
12
- * ✓ @vzn/vx#lint cache hit (local) abc12345
12
+ * ◉ @vzn/vx#lint cache hit (local) abc12345
13
13
  * ↓ @vzn/vx#test cache hit (remote) def67890
14
- * ▶ @vzn/vx#build cache miss — would exec fedcba98 ~1.2s
14
+ * ▶ @vzn/vx#build cache miss — would exec fedcba98 ~1.20s
15
15
  *
16
- * 3 task(s) planned: 2 cache hits (1 local, 1 remote), 1 would run.
17
- * predicted: ~1.2s wall · ~1.2s total execution
16
+ * 3 task(s) planned, 2 cache hits (1 local, 1 remote), 1 would run.
17
+ * predicted: ~1.20s wall · ~1.20s total execution
18
18
  */
19
19
  export function formatPlanText(plan: RunPlan): string {
20
20
  const real = plan.tasks.filter((t) => t.cacheStatus !== 'group')
@@ -17,6 +17,17 @@ export interface ResolvedPluginCommand {
17
17
  ctx: CommandContext
18
18
  }
19
19
 
20
+ /**
21
+ * No plugin here declares the verb, and these are the verbs they DO declare
22
+ * — the same load that failed to find the verb already knows them, so the
23
+ * "did you mean" and the "where verbs come from" line cost nothing extra.
24
+ * An empty list is a workspace whose plugins declare no verb at all; `null`
25
+ * from the lookup is no workspace.
26
+ */
27
+ interface NoPluginCommand {
28
+ declaredVerbs: readonly string[]
29
+ }
30
+
20
31
  /**
21
32
  * Why a verb could not be looked up: the workspace file failed to load.
22
33
  * Surfaced next to "unknown command" rather than as the whole answer — a
@@ -50,17 +61,21 @@ async function workspacePlugins(
50
61
  }
51
62
 
52
63
  /**
53
- * The plugin verb named `verb` for the workspace around `cwd`; null when
54
- * there is none; the load error when the workspace file could not say.
64
+ * The plugin verb named `verb` for the workspace around `cwd`; the verbs
65
+ * this workspace's plugins do declare when none of them is it; null when
66
+ * there is no workspace; the load error when the workspace file could not
67
+ * say.
55
68
  */
56
69
  export async function resolvePluginCommand(
57
70
  verb: string,
58
71
  cwd = process.cwd(),
59
- ): Promise<ResolvedPluginCommand | UnresolvedPluginCommand | null> {
72
+ ): Promise<ResolvedPluginCommand | NoPluginCommand | UnresolvedPluginCommand | null> {
60
73
  const ws = await workspacePlugins(cwd)
61
74
  if (ws === null) return null
62
75
  if ('loadError' in ws) return ws
76
+ const declaredVerbs = new Set<string>()
63
77
  for (const plugin of ws.plugins) {
78
+ for (const declared of Object.keys(plugin.commands ?? {})) declaredVerbs.add(declared)
64
79
  const command = plugin.commands?.[verb]
65
80
  if (command === undefined) continue
66
81
  return {
@@ -73,10 +88,9 @@ export async function resolvePluginCommand(
73
88
  },
74
89
  }
75
90
  }
76
- return null
91
+ return { declaredVerbs: [...declaredVerbs] }
77
92
  }
78
93
 
79
- /** `vx help` lines for every plugin verb in the workspace around `cwd`. */
80
94
  /** The plugin verbs the workspace around `cwd` declares, in declaration order; none when it does not load. */
81
95
  export async function pluginVerbs(cwd = process.cwd()): Promise<string[]> {
82
96
  const ws = await workspacePlugins(cwd).catch(() => null)
@@ -87,6 +101,7 @@ export async function pluginVerbs(cwd = process.cwd()): Promise<string[]> {
87
101
  return [...verbs]
88
102
  }
89
103
 
104
+ /** `vx help` lines for every plugin verb in the workspace around `cwd`. */
90
105
  export async function pluginCommandHelp(cwd = process.cwd()): Promise<string[]> {
91
106
  const ws = await workspacePlugins(cwd).catch(() => null)
92
107
  if (ws === null || 'loadError' in ws) return []
package/src/cli/run.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { appendFile } from 'node:fs/promises'
1
+ import { appendFile, mkdir } from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
  import { documentedFlags, seeHelp } from './help.js'
4
4
  import { defaultAffectedBase, findWorkspaceRoot } from '../workspace/index.js'
@@ -15,7 +15,13 @@ import {
15
15
  import type { ContinueMode } from '../graph/index.js'
16
16
  import { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
17
17
  import { findCwdProject, pickTask, resolveFilters } from './select.js'
18
- import { MAX_TIMEOUT_MS, parseDecimalInt, nearest, machineParallelism } from '../util/index.js'
18
+ import {
19
+ MAX_TIMEOUT_MS,
20
+ isUserError,
21
+ parseDecimalInt,
22
+ nearest,
23
+ machineParallelism,
24
+ } from '../util/index.js'
19
25
  import { formatGraphDot, formatPlanJson, formatPlanText } from './plan-format.js'
20
26
 
21
27
  export interface RunArgs {
@@ -359,11 +365,21 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
359
365
  if (out.dry !== undefined && out.graph !== undefined) {
360
366
  return { ...out, error: '--dry and --graph are mutually exclusive' }
361
367
  }
362
- if (out.dry !== undefined && (out.summarize !== undefined || out.profile !== undefined)) {
363
- return { ...out, error: '--dry skips execution; --summarize / --profile need a real run' }
368
+ // Every flag that promises a file or a section about the run: the
369
+ // planning branch returns before any of them is written, and
370
+ // `--report-file="$GITHUB_STEP_SUMMARY" --dry` exited 0 having written
371
+ // nothing (item 992).
372
+ const needsRun =
373
+ out.summarize !== undefined ||
374
+ out.profile !== undefined ||
375
+ out.report !== undefined ||
376
+ out.reportFile !== undefined
377
+ const runOnly = '--summarize / --profile / --report / --report-file need a real run'
378
+ if (out.dry !== undefined && needsRun) {
379
+ return { ...out, error: `--dry skips execution; ${runOnly}` }
364
380
  }
365
- if (out.graph !== undefined && (out.summarize !== undefined || out.profile !== undefined)) {
366
- return { ...out, error: '--graph skips execution; --summarize / --profile need a real run' }
381
+ if (out.graph !== undefined && needsRun) {
382
+ return { ...out, error: `--graph skips execution; ${runOnly}` }
367
383
  }
368
384
  return out
369
385
  }
@@ -415,14 +431,28 @@ export async function resolveRunOptions(
415
431
  }
416
432
  }
417
433
 
418
- // `--affected[=<base>]` is sugar for `--filter '[<base>]'`. Merging
419
- // it into the filter list means the same code path handles plain
420
- // filter use, --affected alone, and the combo.
434
+ // `--affected[=<base>]` is sugar for `--filter '...[<base>]'`: the
435
+ // changed projects AND their dependents — what a CI gate must run, what
436
+ // the flag's name says, what the guides promised while the sugar was the
437
+ // changed-only `[<base>]` (item 287). `--filter '[<base>]'` stays the
438
+ // "only what I touched" form. Merging it into the filter list means the
439
+ // same code path handles plain filter use, --affected alone, and the combo.
440
+ // It goes FIRST: filters apply in argv order, and appended last it came
441
+ // after every `!` exclude, so `--affected --filter '!app'` still ran app
442
+ // (item 955). An include's place does not change a union.
421
443
  const filterStrings = [...parsed.filters]
422
444
  if (parsed.affected !== undefined) {
423
445
  const root = await findWorkspaceRoot(cwd)
424
- const base = parsed.affected === '' ? await defaultAffectedBase(root) : parsed.affected
425
- filterStrings.push(`[${base}]`)
446
+ let base = parsed.affected
447
+ if (base === '') {
448
+ try {
449
+ base = await defaultAffectedBase(root)
450
+ } catch (err) {
451
+ if (!isUserError(err)) throw err
452
+ return { error: err.message }
453
+ }
454
+ }
455
+ filterStrings.unshift(`...[${base}]`)
426
456
  }
427
457
 
428
458
  // Project scope applies to bare task names only. Anchored entries
@@ -560,7 +590,17 @@ export async function runCmd(args: readonly string[]): Promise<number> {
560
590
  if (parsed.graph === '') {
561
591
  process.stdout.write(out)
562
592
  } else {
563
- await Bun.write(parsed.graph, out)
593
+ // The graph is the command's one product, so a path it cannot write
594
+ // is exit 1, said in one line as the other output paths say theirs:
595
+ // a directory or an unreachable path printed a raw stack (item 993).
596
+ const target = path.resolve(cwd, parsed.graph)
597
+ try {
598
+ await Bun.write(target, out)
599
+ } catch (err) {
600
+ const message = err instanceof Error ? err.message : String(err)
601
+ process.stderr.write(`vx run: failed to write graph to ${target}: ${message}\n`)
602
+ return 1
603
+ }
564
604
  }
565
605
  } else if (parsed.dry === 'json') {
566
606
  process.stdout.write(formatPlanJson(plan))
@@ -595,6 +635,9 @@ export async function runCmd(args: readonly string[]): Promise<number> {
595
635
  // same job also write to. Truncating it would silently destroy their
596
636
  // content, and would make replacing the documented `>>` recipe a
597
637
  // behaviour change rather than a drop-in.
638
+ // Its directory made first, as `--summarize`, `--profile` and
639
+ // `--graph` make theirs: `nr/r.md` failed alone of the four (item 993).
640
+ await mkdir(path.dirname(target), { recursive: true })
598
641
  await appendFile(target, md)
599
642
  } catch (err) {
600
643
  // Same contract as --summarize / --profile: the run already
package/src/cli/select.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // What a run is asked to run: the projects a `--filter` selects, the
2
- // ones `--affected` touches (with the orphan-path owners), the project the
2
+ // ones `--affected` touches (with the `workspaceFiles` owners), the project the
3
3
  // cwd sits in, and the interactive picker. Every read of the workspace
4
4
  // here goes through the staged load, so the answer is the run's.
5
5
 
@@ -7,9 +7,11 @@ import readline from 'node:readline/promises'
7
7
  import path from 'node:path'
8
8
  import {
9
9
  affectedProjects,
10
+ refIsHead,
10
11
  applyFilters,
11
12
  buildPackageGraph,
12
13
  findWorkspaceRoot,
14
+ type LoadReads,
13
15
  type FingerprintClaims,
14
16
  FROZEN_WITHOUT_LOCK,
15
17
  listProjects,
@@ -29,8 +31,8 @@ import { type CliLoadOptions, loadCliProjects, loadCliWorkspace } from './worksp
29
31
 
30
32
  /**
31
33
  * The projects whose tasks declare a `cache.inputs.workspaceFiles` glob
32
- * matching an ORPHAN changed path (one no project owns): `--affected`
33
- * selects them, since the glob is that task's input. Through the run
34
+ * matching a changed path, one inside another project included (item
35
+ * 954): `--affected` selects them, since the glob is that task's input. Through the run
34
36
  * path's staged load, so a glob a `project` plugin gave a config-less
35
37
  * package counts; evaluated live as a default run does, or read from the
36
38
  * lock as a `--frozen` run does. A load that fails drops to the config files that
@@ -40,7 +42,7 @@ import { type CliLoadOptions, loadCliProjects, loadCliWorkspace } from './worksp
40
42
  export async function workspaceGlobOwners(
41
43
  root: string,
42
44
  projects: readonly ProjectMeta[],
43
- orphans: readonly string[],
45
+ changed: readonly string[],
44
46
  load: CliLoadOptions = {},
45
47
  stagedLoad: () => Promise<ReadonlyMap<string, ProjectEntry>> = () =>
46
48
  loadCliProjects(root, projects, 'all', load),
@@ -49,7 +51,7 @@ export async function workspaceGlobOwners(
49
51
  for (const task of Object.values(config.tasks ?? {})) {
50
52
  const globs = task.cache?.inputs?.workspaceFiles
51
53
  if (globs === undefined) continue
52
- if (orphans.some((rel) => workspaceGlobsMatch(globs, rel))) return true
54
+ if (changed.some((rel) => workspaceGlobsMatch(globs, rel))) return true
53
55
  }
54
56
  return false
55
57
  }
@@ -104,10 +106,10 @@ async function workspaceFingerprintClaims(
104
106
  }
105
107
  }
106
108
 
107
- export async function loadWorkspaceProjects(cwd: string): Promise<ProjectMeta[]> {
108
- const root = await findWorkspaceRoot(cwd)
109
- const ws = await loadWorkspace(root)
110
- return await listProjects(ws)
109
+ async function loadWorkspaceProjects(cwd: string): Promise<ProjectMeta[]> {
110
+ const reads: LoadReads = new Map()
111
+ const root = await findWorkspaceRoot(cwd, reads)
112
+ return await listProjects(await loadWorkspace(root, reads))
111
113
  }
112
114
 
113
115
  export async function findCwdProject(cwd: string): Promise<string | null> {
@@ -180,7 +182,7 @@ export async function resolveFilters(
180
182
  const parsed = raw.map((r) => parseFilter(r, root))
181
183
  const walksGraph = parsed.some((f) => f.withDeps || f.withDependents || f.onlyDeps)
182
184
  // Every reader of the staged configs in this pass — the `pkg#task`
183
- // edge walk, the `workspaceFiles` owners of an orphan path — shares
185
+ // edge walk, the `workspaceFiles` owners of a changed path — shares
184
186
  // ONE load, and the run reuses it (`RunOptions.staged`): the `project`
185
187
  // stage runs once per project per run.
186
188
  let stagedPromise: Promise<Map<string, ProjectEntry>> | undefined
@@ -207,8 +209,8 @@ export async function resolveFilters(
207
209
  workspaceRoot: root,
208
210
  since: f.gitSince,
209
211
  projects,
210
- workspaceGlobOwners: (orphans) =>
211
- workspaceGlobOwners(root, projects, orphans, load, stagedOnce),
212
+ workspaceGlobOwners: (changed) =>
213
+ workspaceGlobOwners(root, projects, changed, load, stagedOnce),
212
214
  fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
213
215
  })
214
216
  affectedByFilter.set(f, names)
@@ -238,7 +240,15 @@ export async function resolveFilters(
238
240
  const includes = parsed.filter((f) => !f.negate)
239
241
  if (includes.length > 0 && includes.every((f) => f.gitSince !== undefined)) {
240
242
  const refs = includes.map((f) => f.gitSince).join(', ')
241
- return { empty: `nothing affected since ${refs}` }
243
+ // A base that IS HEAD (a single-branch clone whose `origin/HEAD` is
244
+ // the branch under test) can never mark anything affected, and this
245
+ // exit-0 note was the only sign (CI persona, 2026-09-16). Say so.
246
+ const self = includes.find((f) => refIsHead(root, f.gitSince!))
247
+ const hint =
248
+ self === undefined
249
+ ? ''
250
+ : ` — ${self.gitSince} is HEAD itself: compare with the branch you merge into (--affected=origin/main) or the previous commit (--affected=HEAD~1)`
251
+ return { empty: `nothing affected since ${refs}${hint}` }
242
252
  }
243
253
  // One line, not a warning per pattern and then an error saying the same:
244
254
  // the patterns are in the error, and the nearest project name is the
package/src/cli/show.ts CHANGED
@@ -7,11 +7,13 @@
7
7
  // Deliberately NOT the lock — vx-lock.json is already the frozen JSON.
8
8
 
9
9
  import type { ProjectConfig, TaskConfig } from '../config.js'
10
+ import { declaredTask } from '../graph/index.js'
10
11
  import { seeHelp } from './help.js'
11
12
  import { nearMatches, relPosix, UserError } from '../util/index.js'
12
13
  import { loadCliProjects } from './workspace-config.js'
13
14
  import {
14
15
  findWorkspaceRoot,
16
+ type LoadReads,
15
17
  listProjects,
16
18
  loadWorkspace,
17
19
  type ProjectEntry,
@@ -51,8 +53,9 @@ export async function showCmd(args: readonly string[]): Promise<number> {
51
53
  process.stderr.write(`vx show: ${parsed.error}\n`)
52
54
  return 1
53
55
  }
54
- const root = await findWorkspaceRoot(process.cwd())
55
- const metas = await listProjects(await loadWorkspace(root))
56
+ const reads: LoadReads = new Map()
57
+ const root = await findWorkspaceRoot(process.cwd(), reads)
58
+ const metas = await listProjects(await loadWorkspace(root, reads))
56
59
  const byName = new Map(metas.map((m) => [m.name, m]))
57
60
 
58
61
  const hashAt = parsed.target?.indexOf('#') ?? -1
@@ -84,7 +87,9 @@ export async function showCmd(args: readonly string[]): Promise<number> {
84
87
  }
85
88
 
86
89
  if (bareTask) {
87
- const declaring = [...projects.values()].filter((p) => p.config.tasks?.[projectName!])
90
+ const declaring = [...projects.values()].filter(
91
+ (p) => declaredTask(p.config, projectName!) !== undefined,
92
+ )
88
93
  if (declaring.length === 0) {
89
94
  const names = new Set<string>()
90
95
  for (const p of projects.values())
@@ -109,7 +114,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
109
114
  return 0
110
115
  }
111
116
 
112
- const task = config?.tasks?.[taskName]
117
+ const task = declaredTask(config, taskName)
113
118
  if (task === undefined) {
114
119
  throw new UserError(
115
120
  `unknown task: "${meta.name}#${taskName}"${suggest(taskName, Object.keys(config?.tasks ?? {}))}`,