@vzn/vx 0.0.18 → 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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -0,0 +1,87 @@
1
+ // `vx init [--dry] [--force] [--mjs]` — a workspace from nowhere: one vx.config.ts
2
+ // per package from its package.json scripts, and the workspace file every
3
+ // run needs. Turbo and Nx are not read here; a runner's own config beside
4
+ // the scripts is the richer source, and `@vzn/vx-migrate` maps it.
5
+
6
+ import path from 'node:path'
7
+ import { seeHelp } from './help.js'
8
+ import {
9
+ applyMigration,
10
+ findWorkspaceRoot,
11
+ type LoadReads,
12
+ listProjects,
13
+ loadWorkspace,
14
+ migrateScripts,
15
+ } from '../workspace/index.js'
16
+
17
+ export interface InitArgs {
18
+ dry: boolean
19
+ force: boolean
20
+ /** `vx.config.mjs` instead of `.ts` — see `ApplyMigrationArgs.format`. */
21
+ mjs: boolean
22
+ error?: string
23
+ }
24
+
25
+ export function parseInitArgs(args: readonly string[]): InitArgs {
26
+ const out: InitArgs = { dry: false, force: false, mjs: false }
27
+ for (const a of args) {
28
+ if (a === '--dry') out.dry = true
29
+ else if (a === '--force') out.force = true
30
+ else if (a === '--mjs') out.mjs = true
31
+ else if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}${seeHelp('init')}` }
32
+ else return { ...out, error: `unexpected argument: ${a}` }
33
+ }
34
+ return out
35
+ }
36
+
37
+ export async function initCmd(args: readonly string[]): Promise<number> {
38
+ const parsed = parseInitArgs(args)
39
+ if (parsed.error) {
40
+ process.stderr.write(`vx init: ${parsed.error}\n`)
41
+ return 1
42
+ }
43
+ const reads: LoadReads = new Map()
44
+ const root = await findWorkspaceRoot(process.cwd(), reads)
45
+ const metas = await listProjects(await loadWorkspace(root, reads))
46
+ // `init` reads scripts only; a runner's own config beside them is the
47
+ // richer source (dependsOn, inputs, outputs) and was ignored without a
48
+ // word — the walkthrough on a Turbo repo (2026-09-09) got the scripts'
49
+ // TODOs and none of the edges turbo.json already declared.
50
+ const notes: string[] = []
51
+ // Turbo 2.5+ reads `turbo.jsonc` as well (item 938).
52
+ let turbo: string | undefined
53
+ for (const name of ['turbo.json', 'turbo.jsonc']) {
54
+ if (await Bun.file(path.join(root, name)).exists()) {
55
+ turbo = name
56
+ break
57
+ }
58
+ }
59
+ if (turbo !== undefined) {
60
+ notes.push(
61
+ `${turbo} found and not read — ` +
62
+ '`bunx @vzn/vx-migrate` maps it (dependsOn, inputs, ' +
63
+ 'outputs), or `plugins: [turbo()]` from @vzn/vx-migrate runs it with nothing written',
64
+ )
65
+ } else if (
66
+ (await Bun.file(path.join(root, 'nx.json')).exists()) ||
67
+ (await Bun.file(path.join(root, '.nx', 'workspace-data', 'project-graph.json')).exists())
68
+ ) {
69
+ notes.push(
70
+ 'an Nx workspace found and not read — `bunx @vzn/vx-migrate --from nx` maps its ' +
71
+ 'exported project graph, or `plugins: [nx()]` from @vzn/vx-migrate runs it with ' +
72
+ 'nothing written',
73
+ )
74
+ }
75
+ return applyMigration({
76
+ root,
77
+ metas,
78
+ plan: migrateScripts(metas),
79
+ source: 'package.json scripts',
80
+ verb: 'vx init',
81
+ dry: parsed.dry,
82
+ force: parsed.force,
83
+ init: true,
84
+ notes,
85
+ format: parsed.mjs ? 'mjs' : 'ts',
86
+ })
87
+ }
package/src/cli/last.ts CHANGED
@@ -4,15 +4,26 @@
4
4
  // history in cache.db is the only replay surface, and this verb reads it.
5
5
  // Read-only — no config evaluation, no re-hash, no cache probe.
6
6
 
7
- import { Cache } from '../cache/index.js'
8
- import { getInvocation, getRun, listInvocations } from '../orchestrator/index.js'
7
+ import { Cache, noteSchemaReset } from '../cache/index.js'
8
+ import { formatBytes } from './format.js'
9
+ import { seeHelp } from './help.js'
10
+ import {
11
+ exitSignal,
12
+ getInvocation,
13
+ getRun,
14
+ listInvocations,
15
+ type RunSummaryRow,
16
+ } from '../orchestrator/index.js'
9
17
  import { UserError } from '../util/index.js'
10
- import { findWorkspaceRoot, loadWorkspaceConfig, resolveCacheDir } from '../workspace/index.js'
18
+ import { findWorkspaceRoot } from '../workspace/index.js'
19
+ import { cliCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
11
20
 
12
21
  interface LastArgs {
13
22
  runId?: string
14
23
  list?: number
15
24
  format: 'pretty' | 'json'
25
+ /** `--cache-dir`: read the history a run with the same flag wrote. */
26
+ cacheDir?: string
16
27
  error?: string
17
28
  }
18
29
 
@@ -21,7 +32,14 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
21
32
  for (let i = 0; i < args.length; i++) {
22
33
  const a = args[i]!
23
34
  if (a === '--list' || a.startsWith('--list=')) {
24
- 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++
25
43
  const n = Number(lv)
26
44
  if (!Number.isInteger(n) || n < 1 || n > 500) {
27
45
  return { ...out, error: `invalid --list: ${lv} (expected 1..500)` }
@@ -37,10 +55,24 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
37
55
  out.format = fv
38
56
  continue
39
57
  }
40
- if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}` }
58
+ const cd = parseCacheDirFlag(args, i)
59
+ if (cd !== null) {
60
+ if ('error' in cd) return { ...out, error: cd.error }
61
+ out.cacheDir = cd.cacheDir
62
+ i = cd.next
63
+ continue
64
+ }
65
+ if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}${seeHelp('last')}` }
41
66
  if (out.runId !== undefined) return { ...out, error: `unexpected argument: ${a}` }
42
67
  out.runId = a
43
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
+ }
44
76
  return out
45
77
  }
46
78
 
@@ -53,13 +85,103 @@ function fmtMs(ms: number): string {
53
85
  return `${m}m ${Math.round((ms - m * 60_000) / 1000)}s`
54
86
  }
55
87
 
88
+ /**
89
+ * What an executed task used — its peak RSS and its CPU parallelism (CPU
90
+ * time over wall time) — as the runner recorded them. This is the number
91
+ * `@vzn/vx-schedule-history` reserves from, so the replay shows it where
92
+ * a developer can read it; a hit spent nothing and shows nothing.
93
+ */
94
+ function fmtUsage(t: {
95
+ cpuMs: number | null
96
+ peakRssBytes: number | null
97
+ durationMs: number
98
+ }): string {
99
+ const parts: string[] = []
100
+ if (t.peakRssBytes !== null) parts.push(formatBytes(t.peakRssBytes))
101
+ if (t.cpuMs !== null && t.durationMs > 0)
102
+ parts.push(`${(t.cpuMs / t.durationMs).toFixed(1)}× cpu`)
103
+ return parts.length > 0 ? ` ${parts.join(' · ')}` : ''
104
+ }
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
+
56
178
  export async function lastCmd(args: readonly string[]): Promise<number> {
57
179
  const parsed = parseLastArgs(args)
58
180
  if (parsed.error !== undefined) throw new UserError(`vx last: ${parsed.error}`)
59
181
 
60
182
  const root = await findWorkspaceRoot(process.cwd())
61
- const cacheDir = resolveCacheDir(root, await loadWorkspaceConfig(root))
62
- const cache = new Cache(cacheDir)
183
+ const cache = Cache.inspect(await cliCacheDir(root, parsed.cacheDir))
184
+ noteSchemaReset(cache, warnToStderr)
63
185
  try {
64
186
  const db = cache.dbHandle()
65
187
 
@@ -116,22 +238,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
116
238
  ` (${inv.hitLocalCount} local, ${inv.hitRemoteCount} remote)` +
117
239
  `${inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : ''}`,
118
240
  )
119
- const tasks = detail?.tasks ?? []
120
- if (tasks.length > 0) {
121
- lines.push('')
122
- const failedFirst = [
123
- ...tasks.filter((t) => t.status === 'failed'),
124
- ...tasks.filter((t) => t.status !== 'failed'),
125
- ]
126
- const idW = Math.max(...tasks.map((t) => `${t.project}#${t.task}`.length), 4)
127
- for (const t of failedFirst) {
128
- const id = `${t.project}#${t.task}`
129
- lines.push(
130
- ` ${t.status.padEnd(17)} ${id.padEnd(idW)} ${fmtMs(t.durationMs).padStart(8)}` +
131
- `${t.hash !== '' ? ` ${t.hash}` : ''}`,
132
- )
133
- }
134
- }
241
+ lines.push(...formatTaskRows(detail?.tasks ?? []))
135
242
  process.stdout.write(`${lines.join('\n')}\n`)
136
243
  return 0
137
244
  } finally {
package/src/cli/lock.ts CHANGED
@@ -3,9 +3,11 @@
3
3
  // current environment. Design: docs/design/config-lock-2026-06.md.
4
4
 
5
5
  import type { ProjectConfig } from '../config.js'
6
+ import { seeHelp } from './help.js'
6
7
  import { relPosix, xxh3hex } from '../util/index.js'
7
8
  import {
8
9
  findWorkspaceRoot,
10
+ type LoadReads,
9
11
  listProjects,
10
12
  loadProjectConfig,
11
13
  loadWorkspace,
@@ -27,7 +29,7 @@ export function parseLockArgs(args: readonly string[]): LockArgs {
27
29
  const out: LockArgs = { check: false }
28
30
  for (const a of args) {
29
31
  if (a === '--check') out.check = true
30
- else return { check: false, error: `unknown argument: ${a}` }
32
+ else return { check: false, error: `unknown argument: ${a}${seeHelp('lock')}` }
31
33
  }
32
34
  return out
33
35
  }
@@ -40,32 +42,42 @@ export async function lockCmd(args: readonly string[]): Promise<number> {
40
42
  process.stderr.write(`vx lock: ${parsed.error}\n`)
41
43
  return 1
42
44
  }
43
- const root = await findWorkspaceRoot(process.cwd())
44
- const workspace = await loadWorkspace(root)
45
- const metas = (await listProjects(workspace)).filter(
46
- (m): m is ConfiguredMeta => m.configPath !== null,
47
- )
48
- if (parsed.check) return await checkLock(root, metas)
49
- 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)
50
57
  }
51
58
 
52
59
  /** Evaluate one config in the current env, returning its lock entry. */
53
60
  async function evaluateEntry(root: string, meta: ConfiguredMeta): Promise<LockfileEntry> {
54
61
  const bytes = await Bun.file(meta.configPath).bytes()
55
- // `fresh: true` — locking must observe the current environment, not
56
- // 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.
57
64
  const config = await loadProjectConfig(meta.configPath, { fresh: true })
58
65
  return {
59
66
  configPath: relPosix(root, meta.configPath),
60
67
  configHash: xxh3hex(bytes),
61
- // JSON round-trip drops `undefined` fields so the stored object is
62
- // byte-identical to what a later read of the lock will produce —
63
- // the exact form `--check` compares against.
64
- 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,
65
71
  }
66
72
  }
67
73
 
68
- 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> {
69
81
  const entries = await Promise.all(metas.map((m) => evaluateEntry(root, m)))
70
82
  const projects: Record<string, LockfileEntry> = {}
71
83
  // `listProjects` sorts by name — stable lockfile diffs for free.
@@ -73,7 +85,9 @@ async function writeLock(root: string, metas: ConfiguredMeta[]): Promise<number>
73
85
  const lock: Lockfile = { version: LOCKFILE_VERSION, projects }
74
86
  await writeLockfile(root, lock)
75
87
  const n = metas.length
76
- 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
+ )
77
91
  return 0
78
92
  }
79
93
 
@@ -85,7 +99,7 @@ async function writeLock(root: string, metas: ConfiguredMeta[]): Promise<number>
85
99
  * catches eval-time env-var drift that file hashes cannot see —
86
100
  * file bytes unchanged, resolved value changed.
87
101
  */
88
- async function checkLock(root: string, metas: ConfiguredMeta[]): Promise<number> {
102
+ async function checkLock(root: string, metas: ConfiguredMeta[], bare: number): Promise<number> {
89
103
  const lock = await readLockfile(root)
90
104
  if (!lock) {
91
105
  process.stderr.write(
@@ -104,6 +118,8 @@ async function checkLock(root: string, metas: ConfiguredMeta[]): Promise<number>
104
118
  if (hash !== entry.configHash) {
105
119
  return `config file changed since lock (${m.name}: ${rel}) — run 'vx lock'`
106
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.
107
123
  const fresh = JSON.parse(
108
124
  JSON.stringify(await loadProjectConfig(m.configPath, { fresh: true })),
109
125
  ) as ProjectConfig
@@ -130,6 +146,8 @@ async function checkLock(root: string, metas: ConfiguredMeta[]): Promise<number>
130
146
  return 1
131
147
  }
132
148
  const n = metas.length
133
- 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`)
134
152
  return 0
135
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')
@@ -144,19 +144,30 @@ export function formatPlanJson(plan: RunPlan): string {
144
144
  export function formatGraphDot(plan: RunPlan): string {
145
145
  const lines: string[] = ['digraph TaskGraph {', ' rankdir=LR;', ' node [shape=box];']
146
146
  for (const t of plan.tasks) {
147
- const label = `${t.node.id}\\n${t.hash.slice(0, 8)}`
148
147
  const color = dotColor(t.cacheStatus)
149
- lines.push(` "${t.node.id}" [label="${label}", style="filled", fillcolor="${color}"];`)
148
+ lines.push(
149
+ ` ${dotString(t.node.id)} [label=${dotString(`${t.node.id}\n${t.hash.slice(0, 8)}`)}, style="filled", fillcolor="${color}"];`,
150
+ )
150
151
  }
151
152
  for (const t of plan.tasks) {
152
153
  for (const dep of t.deps) {
153
- lines.push(` "${dep}" -> "${t.node.id}";`)
154
+ lines.push(` ${dotString(dep)} -> ${dotString(t.node.id)};`)
154
155
  }
155
156
  }
156
157
  lines.push('}')
157
158
  return lines.join('\n') + '\n'
158
159
  }
159
160
 
161
+ /**
162
+ * A DOT double-quoted ID. A task name is any object key in a config, so
163
+ * `"`, `\` and a newline are all reachable; unescaped they end the
164
+ * string early and the document stops parsing. `\n` is DOT's own line
165
+ * break inside a label.
166
+ */
167
+ function dotString(s: string): string {
168
+ return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\r?\n/g, '\\n')}"`
169
+ }
170
+
160
171
  function symbolFor(s: CacheStatus): string {
161
172
  switch (s) {
162
173
  case 'hit-local':
@@ -8,7 +8,8 @@
8
8
  // before this is asked, so a plugin cannot shadow `run`.
9
9
 
10
10
  import type { VxPlugin, PluginCommand, CommandContext } from '../orchestrator/index.js'
11
- import { findWorkspaceRoot, loadWorkspaceConfig, resolveCacheDir } from '../workspace/index.js'
11
+ import { findWorkspaceRoot } from '../workspace/index.js'
12
+ import { loadCliWorkspace } from './workspace-config.js'
12
13
 
13
14
  export interface ResolvedPluginCommand {
14
15
  plugin: VxPlugin
@@ -16,6 +17,17 @@ export interface ResolvedPluginCommand {
16
17
  ctx: CommandContext
17
18
  }
18
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
+
19
31
  /**
20
32
  * Why a verb could not be looked up: the workspace file failed to load.
21
33
  * Surfaced next to "unknown command" rather than as the whole answer — a
@@ -39,31 +51,31 @@ async function workspacePlugins(
39
51
  } catch {
40
52
  return null
41
53
  }
42
- let config
54
+ let ws
43
55
  try {
44
- config = await loadWorkspaceConfig(workspaceRoot)
56
+ ws = await loadCliWorkspace(workspaceRoot)
45
57
  } catch (err) {
46
58
  return { loadError: err instanceof Error ? err.message : String(err) }
47
59
  }
48
- return {
49
- workspaceRoot,
50
- cacheDir: resolveCacheDir(workspaceRoot, config),
51
- plugins: (config?.plugins ?? []) as readonly VxPlugin[],
52
- }
60
+ return { workspaceRoot, cacheDir: ws.cacheDir, plugins: ws.plugins }
53
61
  }
54
62
 
55
63
  /**
56
- * The plugin verb named `verb` for the workspace around `cwd`; null when
57
- * 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.
58
68
  */
59
69
  export async function resolvePluginCommand(
60
70
  verb: string,
61
71
  cwd = process.cwd(),
62
- ): Promise<ResolvedPluginCommand | UnresolvedPluginCommand | null> {
72
+ ): Promise<ResolvedPluginCommand | NoPluginCommand | UnresolvedPluginCommand | null> {
63
73
  const ws = await workspacePlugins(cwd)
64
74
  if (ws === null) return null
65
75
  if ('loadError' in ws) return ws
76
+ const declaredVerbs = new Set<string>()
66
77
  for (const plugin of ws.plugins) {
78
+ for (const declared of Object.keys(plugin.commands ?? {})) declaredVerbs.add(declared)
67
79
  const command = plugin.commands?.[verb]
68
80
  if (command === undefined) continue
69
81
  return {
@@ -76,7 +88,17 @@ export async function resolvePluginCommand(
76
88
  },
77
89
  }
78
90
  }
79
- return null
91
+ return { declaredVerbs: [...declaredVerbs] }
92
+ }
93
+
94
+ /** The plugin verbs the workspace around `cwd` declares, in declaration order; none when it does not load. */
95
+ export async function pluginVerbs(cwd = process.cwd()): Promise<string[]> {
96
+ const ws = await workspacePlugins(cwd).catch(() => null)
97
+ if (ws === null || 'loadError' in ws) return []
98
+ const verbs = new Set<string>()
99
+ for (const plugin of ws.plugins)
100
+ for (const verb of Object.keys(plugin.commands ?? {})) verbs.add(verb)
101
+ return [...verbs]
80
102
  }
81
103
 
82
104
  /** `vx help` lines for every plugin verb in the workspace around `cwd`. */