@vzn/vx 0.0.636 → 0.0.637

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 (141) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +213 -207
  3. package/docs/cli.md +699 -655
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +376 -198
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +2 -2
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +16 -12
  19. package/src/bin.ts +17 -2
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +105 -53
  22. package/src/cache/git-inputs.ts +277 -63
  23. package/src/cache/index.ts +7 -1
  24. package/src/cache/inputs.ts +125 -35
  25. package/src/cache/key-fold.ts +8 -7
  26. package/src/cache/layer.ts +8 -17
  27. package/src/cache/layered-cache.ts +16 -102
  28. package/src/cache/policy.ts +25 -29
  29. package/src/cache/run-history.ts +36 -14
  30. package/src/cache/schema.ts +21 -23
  31. package/src/cache/scoped-cache.ts +224 -0
  32. package/src/cli/cache.ts +150 -28
  33. package/src/cli/completions.ts +2 -2
  34. package/src/cli/core-alias.ts +1 -1
  35. package/src/cli/foreign-flags.ts +13 -12
  36. package/src/cli/help.ts +94 -63
  37. package/src/cli/index.ts +42 -13
  38. package/src/cli/info.ts +38 -23
  39. package/src/cli/init.ts +115 -406
  40. package/src/cli/last.ts +145 -4
  41. package/src/cli/lock.ts +333 -111
  42. package/src/cli/plan-format.ts +2 -2
  43. package/src/cli/plugin-commands.ts +5 -9
  44. package/src/cli/plugin-templates.ts +10 -10
  45. package/src/cli/run.ts +103 -149
  46. package/src/cli/select.ts +33 -39
  47. package/src/cli/show.ts +17 -19
  48. package/src/cli/task-verb.ts +2 -9
  49. package/src/cli/upgrade.ts +402 -145
  50. package/src/cli/watch-cycle.ts +9 -2
  51. package/src/cli/watch-filter.ts +33 -60
  52. package/src/cli/watch-fs.ts +29 -16
  53. package/src/cli/watch-judge.ts +12 -3
  54. package/src/cli/watch-set.ts +16 -42
  55. package/src/cli/watch.ts +188 -215
  56. package/src/cli/why.ts +28 -17
  57. package/src/cli/workspace-config.ts +31 -68
  58. package/src/config.ts +169 -70
  59. package/src/exec/env.ts +1 -31
  60. package/src/exec/executor.ts +1 -5
  61. package/src/exec/kill-tree.ts +2 -1
  62. package/src/exec/sandbox-runtime.ts +12 -1
  63. package/src/exec/sandbox-violations.ts +9 -1
  64. package/src/graph/dependency-spec.ts +1 -1
  65. package/src/graph/index.ts +1 -0
  66. package/src/graph/priorities.ts +1 -1
  67. package/src/graph/scheduler.ts +19 -7
  68. package/src/graph/task-graph.ts +44 -20
  69. package/src/handoff.ts +169 -0
  70. package/src/index.ts +24 -57
  71. package/src/orchestrator/affected-tasks.ts +7 -5
  72. package/src/orchestrator/command-windows.ts +44 -0
  73. package/src/orchestrator/deferred-outputs.ts +12 -10
  74. package/src/orchestrator/doctor.ts +98 -110
  75. package/src/orchestrator/download-policy.ts +3 -3
  76. package/src/orchestrator/events.ts +19 -3
  77. package/src/orchestrator/excluded-keys.ts +0 -2
  78. package/src/orchestrator/execute-task.ts +158 -63
  79. package/src/orchestrator/fingerprint-watch.ts +18 -15
  80. package/src/orchestrator/forecast.ts +10 -2
  81. package/src/orchestrator/framed-output.ts +8 -3
  82. package/src/orchestrator/graph-stage.ts +5 -5
  83. package/src/orchestrator/history.ts +70 -30
  84. package/src/orchestrator/hit-restore.ts +15 -3
  85. package/src/orchestrator/index.ts +18 -4
  86. package/src/orchestrator/local-shortcircuit.ts +2 -1
  87. package/src/orchestrator/lockfile-claim.ts +79 -20
  88. package/src/orchestrator/logger.ts +71 -39
  89. package/src/orchestrator/metrics.ts +71 -6
  90. package/src/orchestrator/miss-save.ts +3 -0
  91. package/src/orchestrator/options.ts +42 -29
  92. package/src/orchestrator/output-host.ts +72 -0
  93. package/src/orchestrator/plan.ts +6 -8
  94. package/src/orchestrator/plugin-host.ts +87 -63
  95. package/src/orchestrator/plugin.ts +94 -60
  96. package/src/orchestrator/prepare.ts +113 -72
  97. package/src/orchestrator/projects.ts +102 -99
  98. package/src/orchestrator/remote-prefetch.ts +2 -1
  99. package/src/orchestrator/run-context.ts +25 -6
  100. package/src/orchestrator/run-failures.ts +17 -8
  101. package/src/orchestrator/run-lock.ts +1 -1
  102. package/src/orchestrator/run-report.ts +9 -9
  103. package/src/orchestrator/run-settings.ts +48 -0
  104. package/src/orchestrator/run.ts +121 -95
  105. package/src/orchestrator/sandbox-fix.ts +39 -20
  106. package/src/orchestrator/sandbox-request.ts +84 -13
  107. package/src/orchestrator/signals.ts +1 -1
  108. package/src/orchestrator/stable-keys.ts +13 -11
  109. package/src/orchestrator/status-line.ts +48 -6
  110. package/src/orchestrator/summary.ts +46 -62
  111. package/src/orchestrator/task-hash.ts +12 -83
  112. package/src/orchestrator/telemetry.ts +3 -3
  113. package/src/settings.ts +417 -0
  114. package/src/util/errors.ts +1 -1
  115. package/src/util/essential-env.ts +31 -0
  116. package/src/util/hide-env.ts +107 -0
  117. package/src/util/index.ts +8 -2
  118. package/src/util/paths.ts +10 -0
  119. package/src/util/settle.ts +49 -30
  120. package/src/util/size.ts +23 -4
  121. package/src/util/timing.ts +3 -2
  122. package/src/util/ulid.ts +1 -1
  123. package/src/util/verbs.ts +0 -15
  124. package/src/workspace/affected.ts +77 -222
  125. package/src/workspace/config-eval.ts +15 -20
  126. package/src/workspace/config-imports.ts +78 -3
  127. package/src/workspace/config-schema.ts +209 -268
  128. package/src/workspace/dependency-rules.ts +57 -0
  129. package/src/workspace/filter.ts +2 -2
  130. package/src/workspace/fingerprint.ts +11 -121
  131. package/src/workspace/index.ts +20 -27
  132. package/src/workspace/load-reads.ts +4 -4
  133. package/src/workspace/lockfile.ts +96 -140
  134. package/src/workspace/migration.ts +242 -146
  135. package/src/workspace/package-graph.ts +30 -216
  136. package/src/workspace/project-loader.ts +80 -285
  137. package/src/workspace/repo-id.ts +2 -1
  138. package/src/workspace/workspace.ts +362 -831
  139. package/src/cache/config-evals.ts +0 -130
  140. package/src/workspace/config-cache.ts +0 -661
  141. package/src/workspace/migrate-scripts.ts +0 -1121
package/src/cli/lock.ts CHANGED
@@ -1,24 +1,44 @@
1
- // `vx lock` — freeze every resolved vx.config.* into vx-lock.json.
1
+ // `vx lock` — freeze every resolved vx.config.* into .vx/projects.lock.json, and
2
+ // each one, pretty, into a `vx.lock.json` beside it for review.
2
3
  // `vx lock --check` — audit the lock against a fresh evaluation in the
3
4
  // current environment. Design: docs/design/config-lock-2026-06.md.
4
5
 
6
+ import { open } from 'node:fs/promises'
7
+ import path from 'node:path'
5
8
  import type { ProjectConfig } from '../config.js'
6
9
  import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
7
- import { relPosix, secretMask, xxh3hex } from '../util/index.js'
10
+ import { buildTaskGraph, expandRequested } from '../graph/index.js'
8
11
  import {
12
+ applyGraphStage,
13
+ discoverProjects,
14
+ hasHook,
15
+ isDefaultBuild,
16
+ loadProjects,
17
+ } from '../orchestrator/index.js'
18
+ import { resolveSetting, runRules } from '../settings.js'
19
+ import {
20
+ configErrorFrame,
21
+ DOT_VX_GITATTRIBUTES,
22
+ isUserError,
23
+ relPosix,
24
+ secretMask,
25
+ } from '../util/index.js'
26
+ import {
27
+ findProjectConfigs,
9
28
  findWorkspaceRoot,
10
- type LoadReads,
11
- loadProjectConfig,
12
- loadWorkspace,
29
+ loadProjectConfigs,
13
30
  LOCKFILE_NAME,
14
31
  LOCKFILE_VERSION,
32
+ lockKey,
15
33
  readLockfile,
34
+ buildPackageGraph,
35
+ checkDependencyRules,
16
36
  writeLockfile,
17
37
  type Lockfile,
18
- type LockfileEntry,
19
38
  type ProjectMeta,
20
39
  } from '../workspace/index.js'
21
- import { discoverCliProjects } from './workspace-config.js'
40
+ import { taskEdgesFrom } from './select.js'
41
+ import { loadCliWorkspace, warnToStderr } from './workspace-config.js'
22
42
 
23
43
  interface LockArgs {
24
44
  check: boolean
@@ -57,55 +77,238 @@ export async function lockCmd(args: readonly string[]): Promise<number> {
57
77
  `--format json answers --check only${seeHelp('lock')}`,
58
78
  'VX_E_USAGE',
59
79
  )
60
- const reads: LoadReads = new Map()
61
- const root = await findWorkspaceRoot(process.cwd(), reads)
62
- const workspace = await loadWorkspace(root, reads)
63
- // As a run discovers them: a project a plugin's `discover` stage adds
64
- // was left out of the lock, and every `--frozen` run then refused it
65
- // with "run vx lock", which changed nothing (X-143).
66
- // Sorted by name, as core's discovery is, for stable lockfile diffs.
67
- const all = (await discoverCliProjects(workspace)).sort((a, b) =>
68
- a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
69
- )
70
- const metas = all.filter((m): m is ConfiguredMeta => m.configPath !== null)
71
- // A project with no vx.config has nothing to freeze: its tasks (a plugin's,
72
- // or none) load live under --frozen too, so both verbs say how many the
73
- // count above leaves out — `locked 0 project configs` on a turbo() repo
74
- // read like an audit of something (2026-09-16).
75
- const bare = all.length - metas.length
76
- if (parsed.check) return await checkLock(root, metas, bare, parsed.format === 'json')
77
- return await writeLock(root, metas, bare)
78
- }
79
-
80
- /** Evaluate one config in the current env, returning its lock entry. */
81
- async function evaluateEntry(root: string, meta: ConfiguredMeta): Promise<LockfileEntry> {
82
- const bytes = await Bun.file(meta.configPath).bytes()
83
- // `fresh: true` — the lock freezes this environment's evaluation, never
84
- // a stored one, whatever options a later edit passes beside it.
85
- const config = await loadProjectConfig(meta.configPath, { fresh: true })
86
- return {
87
- configPath: relPosix(root, meta.configPath),
88
- configHash: xxh3hex(bytes),
89
- // Stored through `writeLockfile`'s JSON.stringify, which drops a field
90
- // left `undefined` — the form `--check` compares a fresh evaluation in.
91
- config,
92
- }
93
- }
94
-
95
- function bareNote(bare: number): string {
96
- return bare === 0
97
- ? ''
98
- : ` (${bare} project${bare === 1 ? ' has' : 's have'} no vx.config; their tasks are never frozen)`
99
- }
100
-
101
- async function writeLock(root: string, metas: ConfiguredMeta[], bare: number): Promise<number> {
102
- const entries = await Promise.all(metas.map((m) => evaluateEntry(root, m)))
103
- // Sorted by name above — stable lockfile diffs. Defined,
104
- // not assigned: a project named `__proto__` set the map's prototype (D-152).
105
- const projects: Record<string, LockfileEntry> = Object.fromEntries(
106
- metas.map((m, i) => [m.name, entries[i]!]),
80
+ const root = await findWorkspaceRoot(process.cwd())
81
+ if (parsed.check) return await checkLock(root, parsed.format === 'json')
82
+ return await lockWorkspace(root)
83
+ }
84
+
85
+ /**
86
+ * Projects with tasks and no vx.config: a plugin's tasks, which load live
87
+ * in every run, so both verbs name them — `locked 0 project configs`
88
+ * on a turbo() repo read like an audit of something (2026-09-16). One with
89
+ * no task is left out: solidjs/solid's solid-ssr, which has no script Turbo
90
+ * runs, read as a project whose tasks come live from the plugin `project` stage.
91
+ */
92
+ function bareProjects(all: readonly ProjectMeta[], tasked: ReadonlySet<string>): string[] {
93
+ return all.filter((m) => m.configPath === null && tasked.has(m.name)).map((m) => m.name)
94
+ }
95
+
96
+ /**
97
+ * `vx lock`: find and evaluate every config, check the graph rules over
98
+ * every task, and write the lock: the only writer of it.
99
+ */
100
+ export async function lockWorkspace(
101
+ root: string,
102
+ warn: (message: string) => void = warnToStderr,
103
+ ): Promise<number> {
104
+ const check = await audit(root, warn)
105
+ if (check.broken !== null) {
106
+ process.stderr.write(`vx lock: ${LOCKFILE_NAME} not written: ${framed(check.broken)}\n`)
107
+ return 1
108
+ }
109
+ return await writeLock(root, check)
110
+ }
111
+
112
+ /**
113
+ * The lock as `vx lock` would write it, and the graph rules (`run.rules`)
114
+ * checked over every project and task the workspace declares: a run trusts
115
+ * the lock and does not check them (owner, 2026-10-13). Every config is
116
+ * found (`findProjectConfigs`) and evaluated once; the projects, names and
117
+ * graph are the ones a run builds from that lock, the `discover` and
118
+ * `graph` stages included.
119
+ */
120
+ async function audit(root: string, warn: (message: string) => void): Promise<GraphCheck> {
121
+ const tasked = new Set<string>()
122
+ const resolved = new Map<string, ProjectConfig>()
123
+ const out: GraphCheck = {
124
+ broken: null,
125
+ fresh: { version: LOCKFILE_VERSION, projects: {} },
126
+ all: [],
127
+ tasked,
128
+ resolved,
129
+ reviewFiles: true,
130
+ }
131
+ try {
132
+ const { workspaceConfig, plugins, cacheDir } = await loadCliWorkspace(root)
133
+ out.reviewFiles = resolveSetting('lock.reviewFiles', workspaceConfig).value !== false
134
+ const found = await findProjectConfigs(root)
135
+ // The one load that evaluates: every reader reads the lock this writes.
136
+ const configs = await loadProjectConfigs(found.map((f) => f.configPath))
137
+ // In the lock's form: a field left `undefined` is a key the stored JSON
138
+ // never has. Entries, not assignment: a directory named `__proto__` is
139
+ // a key of its own (D-152).
140
+ out.fresh.projects = Object.fromEntries(
141
+ found.map((f, i) => [
142
+ lockKey(root, f.dir),
143
+ {
144
+ file: path.basename(f.configPath),
145
+ config: JSON.parse(JSON.stringify(configs[i])) as ProjectConfig,
146
+ },
147
+ ]),
148
+ )
149
+ // The stages edit what they are handed; the lock keeps what the configs say.
150
+ const view = structuredClone(out.fresh)
151
+ const lock = async (): Promise<Lockfile> => view
152
+ out.all = await discoverProjects(root, lock, plugins, cacheDir, warn)
153
+ const { projects } = await loadProjects({
154
+ workspaceRoot: root,
155
+ cacheDir,
156
+ plugins,
157
+ projectMetas: out.all,
158
+ seeds: 'all',
159
+ closure: false,
160
+ configs: lock,
161
+ warn,
162
+ })
163
+ for (const entry of projects.values()) resolved.set(entry.name, entry.config)
164
+ const names = new Set<string>()
165
+ for (const entry of projects.values()) {
166
+ const tasks = Object.entries(entry.config.tasks ?? {})
167
+ // Every project has a `build`; core's default one is nothing to freeze.
168
+ if (tasks.some(([, t]) => !isDefaultBuild(t))) tasked.add(entry.name)
169
+ for (const [t] of tasks) names.add(t)
170
+ }
171
+ const dependencyRules = workspaceConfig?.run?.rules?.dependencies ?? []
172
+ if (dependencyRules.length > 0)
173
+ checkDependencyRules(
174
+ dependencyRules,
175
+ [...projects.keys()],
176
+ new Map([...projects.values()].map((p) => [p.name, p.config.tags ?? []])),
177
+ buildPackageGraph(out.all, taskEdgesFrom(projects)),
178
+ )
179
+ const rules = runRules(workspaceConfig)
180
+ const nodes = buildTaskGraph({
181
+ projects,
182
+ packageGraph: buildPackageGraph(out.all),
183
+ requested: expandRequested([...names], [...projects.keys()], projects, isDefaultBuild),
184
+ workspaceRoot: root,
185
+ rules,
186
+ })
187
+ if (hasHook(plugins, 'graph'))
188
+ await applyGraphStage(plugins, nodes, {
189
+ workspaceRoot: root,
190
+ cacheDir,
191
+ projectMetas: out.all,
192
+ warn,
193
+ rules,
194
+ })
195
+ } catch (err) {
196
+ if (isUserError(err)) return { ...out, broken: err.message }
197
+ throw err
198
+ }
199
+ return out
200
+ }
201
+
202
+ interface GraphCheck {
203
+ /** A graph rule's refusal, or a config that does not load; null when sound. */
204
+ broken: string | null
205
+ /** The lock the configs evaluate to here. */
206
+ fresh: Lockfile
207
+ /** Every project, as a run discovers them from `fresh`. */
208
+ all: ProjectMeta[]
209
+ /** The projects that have a task. */
210
+ tasked: Set<string>
211
+ /** Each project's config as a run resolves it, plugins applied. */
212
+ resolved: Map<string, ProjectConfig>
213
+ /** `lock.reviewFiles`. */
214
+ reviewFiles: boolean
215
+ }
216
+
217
+ function configured(all: readonly ProjectMeta[]): ConfiguredMeta[] {
218
+ return all.filter((m): m is ConfiguredMeta => m.configPath !== null)
219
+ }
220
+
221
+ /**
222
+ * `vx init`'s last step: the lock, as `vx lock` writes it. A refusal (a
223
+ * graph rule, a config that does not load) is printed and leaves the
224
+ * written configs in place.
225
+ */
226
+ export async function lockAfterMigration(root: string): Promise<number> {
227
+ try {
228
+ return await lockWorkspace(root)
229
+ } catch (err) {
230
+ if (!isUserError(err)) throw err
231
+ process.stderr.write(`vx lock: ${LOCKFILE_NAME} not written: ${framed(err.message)}\n`)
232
+ return 1
233
+ }
234
+ }
235
+
236
+ /** A config refusal as a run used to print it: path:line:col and the source line. */
237
+ function framed(message: string): string {
238
+ return configErrorFrame(message, process.cwd()) ?? message
239
+ }
240
+
241
+ /** Names shown per note: a turbo() repo can have hundreds of bare projects. */
242
+ const BARE_SHOWN = 5
243
+
244
+ function bareNames(bare: readonly string[]): string {
245
+ const more = bare.length - BARE_SHOWN
246
+ return more > 0 ? `${bare.slice(0, BARE_SHOWN).join(', ')} and ${more} more` : bare.join(', ')
247
+ }
248
+
249
+ function bareNote(bare: readonly string[]): string {
250
+ return bare.length === 0 ? '' : ` (no vx.config, so not locked: ${bareNames(bare)})`
251
+ }
252
+
253
+ /** The name `vx lock` writes each project's review copy under. */
254
+ const REVIEW_FILE = 'vx.lock.json'
255
+
256
+ interface Review {
257
+ name: string
258
+ file: string
259
+ config: ProjectConfig
260
+ text: string
261
+ }
262
+
263
+ /** Each configured project's review copy: its resolved config, pretty. */
264
+ function sameJson(text: string, config: ProjectConfig): boolean {
265
+ try {
266
+ return Bun.deepEquals(JSON.parse(text), JSON.parse(JSON.stringify(config)), true)
267
+ } catch {
268
+ return false
269
+ }
270
+ }
271
+
272
+ function reviewsOf(metas: ConfiguredMeta[], resolved: Map<string, ProjectConfig>): Review[] {
273
+ return metas.flatMap((m) => {
274
+ const config = resolved.get(m.name)
275
+ if (config === undefined) return []
276
+ return [
277
+ {
278
+ name: m.name,
279
+ file: path.join(path.dirname(m.configPath), REVIEW_FILE),
280
+ config,
281
+ text: `${JSON.stringify(config, null, 2)}\n`,
282
+ },
283
+ ]
284
+ })
285
+ }
286
+
287
+ /** Writes `text` to `file` unless it exists: one a user edited stays theirs. */
288
+ async function writeIfMissing(file: string, text: string): Promise<void> {
289
+ try {
290
+ const fh = await open(file, 'wx')
291
+ await fh.writeFile(text)
292
+ await fh.close()
293
+ } catch (err) {
294
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
295
+ }
296
+ }
297
+
298
+ async function writeLock(root: string, check: GraphCheck): Promise<number> {
299
+ const metas = configured(check.all)
300
+ const bare = bareProjects(check.all, check.tasked)
301
+ // By project name, as the refusal names them.
302
+ const projects: Record<string, ProjectConfig> = Object.fromEntries(
303
+ metas.map((m) => [m.name, check.fresh.projects[lockKey(root, m.dir)]!.config]),
107
304
  )
305
+ const reviews = check.reviewFiles ? reviewsOf(metas, check.resolved) : []
306
+ // The review copies are committed too; a plugin's `project` stage can add
307
+ // a value the bare config lacks. A place both hold is named once.
108
308
  const leaks = secretsIn(projects)
309
+ const named = new Set(leaks.map((l) => l.at))
310
+ for (const l of secretsIn(Object.fromEntries(reviews.map((r) => [r.name, r.config]))))
311
+ if (!named.has(l.at)) leaks.push(l)
109
312
  if (leaks.length > 0) {
110
313
  process.stderr.write(
111
314
  `vx lock: ${LOCKFILE_NAME} is committed, and these configs evaluated to a secret value:\n` +
@@ -114,8 +317,11 @@ async function writeLock(root: string, metas: ConfiguredMeta[], bare: number): P
114
317
  )
115
318
  return 1
116
319
  }
117
- const lock: Lockfile = { version: LOCKFILE_VERSION, projects }
118
- await writeLockfile(root, lock)
320
+ await writeLockfile(root, check.fresh)
321
+ await Promise.all([
322
+ ...reviews.map((r) => Bun.write(r.file, r.text)),
323
+ writeIfMissing(path.join(root, '.vx', '.gitattributes'), DOT_VX_GITATTRIBUTES),
324
+ ])
119
325
  const n = metas.length
120
326
  process.stdout.write(
121
327
  `vx lock: locked ${n} project config${n === 1 ? '' : 's'} → ${LOCKFILE_NAME}${bareNote(bare)}\n`,
@@ -128,12 +334,12 @@ async function writeLock(root: string, metas: ConfiguredMeta[], bare: number): P
128
334
  * environment, by the rule output masking uses (a secret-named variable,
129
335
  * or one a task lists in `exec.env.secret`). The lock is committed, so
130
336
  * such a value would be published, and masking it would freeze a `***`
131
- * that `--frozen` runs (L-42).
337
+ * that every run runs (L-42).
132
338
  */
133
- function secretsIn(projects: Record<string, LockfileEntry>): Array<{ at: string; name: string }> {
339
+ function secretsIn(projects: Record<string, ProjectConfig>): Array<{ at: string; name: string }> {
134
340
  const named = new Set<string>()
135
- for (const entry of Object.values(projects)) {
136
- for (const task of Object.values(entry.config.tasks ?? {}))
341
+ for (const config of Object.values(projects)) {
342
+ for (const task of Object.values(config.tasks ?? {}))
137
343
  for (const n of task?.exec?.env?.secret ?? []) named.add(n)
138
344
  }
139
345
  const secrets: [value: string, name: string][] = []
@@ -153,30 +359,27 @@ function secretsIn(projects: Record<string, LockfileEntry>): Array<{ at: string;
153
359
  for (const [k, x] of Object.entries(v)) walk(project, at === '' ? k : `${at}.${k}`, x)
154
360
  }
155
361
  }
156
- for (const [project, entry] of Object.entries(projects)) walk(project, '', entry.config)
362
+ for (const [project, config] of Object.entries(projects)) walk(project, '', config)
157
363
  return found
158
364
  }
159
365
 
160
366
  /**
161
- * Audit the lock. Two layers, strictly stronger than what runs do:
162
- * 1. The same hash check runs perform (file bytes vs lock hash).
163
- * 2. A full re-evaluation of every config in the CURRENT environment,
164
- * deep-compared against the lock's stored resolved object. This
165
- * catches eval-time env-var drift that file hashes cannot see —
166
- * file bytes unchanged, resolved value changed.
367
+ * Audit the lock: find and re-evaluate every config in the CURRENT
368
+ * environment and deep-compare it with the locked object. That sees an
369
+ * edit to the config, to a file it imports, and to the environment it
370
+ * reads alike, and a config added or removed since.
167
371
  */
168
- async function checkLock(
169
- root: string,
170
- metas: ConfiguredMeta[],
171
- bare: number,
172
- json: boolean,
173
- ): Promise<number> {
372
+ async function checkLock(root: string, json: boolean): Promise<number> {
373
+ const check = await audit(root, warnToStderr)
374
+ const { broken } = check
375
+ const metas = configured(check.all)
376
+ const bare = bareProjects(check.all, check.tasked)
174
377
  const answer = (problems: string[]): number => {
175
378
  if (json) {
176
379
  const out = {
177
380
  upToDate: problems.length === 0,
178
381
  audited: metas.length,
179
- notAudited: bare,
382
+ notAudited: bare.length,
180
383
  problems,
181
384
  }
182
385
  process.stdout.write(`${JSON.stringify(out)}\n`)
@@ -189,42 +392,61 @@ async function checkLock(
189
392
  if (!json) process.stderr.write(`vx lock --check: ${problem}\n`)
190
393
  return answer([problem])
191
394
  }
192
- const results = await Promise.all(
193
- metas.map(async (m): Promise<string | null> => {
194
- const rel = relPosix(root, m.configPath)
195
- const entry = lock.projects[m.name]
196
- if (!entry || entry.configPath !== rel) {
197
- return `"${m.name}" (${rel}) is not in the lock — run 'vx lock'`
198
- }
199
- const hash = xxh3hex(await Bun.file(m.configPath).bytes())
200
- if (hash !== entry.configHash) {
201
- return `config file changed since lock (${m.name}: ${rel}) — run 'vx lock'`
202
- }
203
- // The lock's form: a first load is the module's live export, where a
204
- // field left `undefined` is a key the stored JSON never has.
205
- const fresh = JSON.parse(
206
- JSON.stringify(await loadProjectConfig(m.configPath, { fresh: true })),
207
- ) as ProjectConfig
208
- if (!Bun.deepEquals(fresh, entry.config, true)) {
209
- // Bytes unchanged, so the drift is in what the evaluation read: a
210
- // file the config imports, or the environment. Blaming only the
211
- // environment sent an edited preset's author looking for env reads
212
- // the config does not have (item 1044).
213
- return (
214
- `lock differs from fresh evaluation in this environment (${m.name}) — ` +
215
- `a file the config imports changed, or the config reads the environment; run 'vx lock' here`
216
- )
217
- }
218
- return null
219
- }),
220
- )
221
- const failures = results.filter((r): r is string => r !== null)
222
- const inWorkspace = new Set(metas.map((m) => m.name))
223
- for (const name of Object.keys(lock.projects)) {
224
- if (!inWorkspace.has(name)) {
225
- failures.push(
226
- `locked project "${name}" no longer has a config in the workspace — run 'vx lock'`,
227
- )
395
+ const failures: string[] = []
396
+ const drifted = new Set<string>()
397
+ for (const [dir, fresh] of Object.entries(check.fresh.projects)) {
398
+ const rel = dir === '.' ? fresh.file : `${dir}/${fresh.file}`
399
+ const locked = Object.hasOwn(lock.projects, dir) ? lock.projects[dir] : undefined
400
+ const problem =
401
+ locked === undefined
402
+ ? `${rel} is not in the lock — run 'vx lock'`
403
+ : locked.file !== fresh.file
404
+ ? `${rel} is not the ${locked.file} the lock holds for ${dir} — run 'vx lock'`
405
+ : !Bun.deepEquals(fresh.config, locked.config, true)
406
+ ? `${rel} evaluates differently from the lock here — its config, a file ` +
407
+ `it imports, or the environment it reads changed; run 'vx lock'`
408
+ : null
409
+ if (problem === null) continue
410
+ failures.push(problem)
411
+ drifted.add(dir)
412
+ }
413
+ if (broken !== null) failures.push(broken)
414
+ if (check.reviewFiles && broken === null) {
415
+ // A project the lock already fails is named once.
416
+ const stale = await Promise.all(
417
+ reviewsOf(
418
+ metas.filter((m) => !drifted.has(lockKey(root, m.dir))),
419
+ check.resolved,
420
+ ).map(async (r) => {
421
+ const text = await Bun.file(r.file)
422
+ .text()
423
+ .catch(() => null)
424
+ // Compared as JSON: a formatter may lay the copy out its own way.
425
+ if (text !== null && sameJson(text, r.config)) return null
426
+ const rel = relPosix(root, r.file)
427
+ return text === null
428
+ ? `"${r.name}" has no ${rel} — run 'vx lock'`
429
+ : `"${r.name}" (${rel}) is not what its config resolves to here — run 'vx lock'`
430
+ }),
431
+ )
432
+ for (const f of stale) if (f !== null) failures.push(f)
433
+ // A copy left beside a config since deleted shows reviewers tasks
434
+ // nothing runs. vx never deletes a committed file; it names it.
435
+ const orphans = await Promise.all(
436
+ check.all
437
+ .filter((m) => m.configPath === null)
438
+ .map(async (m) => {
439
+ const file = path.join(m.dir, REVIEW_FILE)
440
+ return (await Bun.file(file).exists())
441
+ ? `${relPosix(root, file)} has no vx.config beside it — delete it`
442
+ : null
443
+ }),
444
+ )
445
+ for (const f of orphans) if (f !== null) failures.push(f)
446
+ }
447
+ for (const dir of Object.keys(lock.projects)) {
448
+ if (!Object.hasOwn(check.fresh.projects, dir)) {
449
+ failures.push(`the lock holds ${dir}, which has no vx.config now — run 'vx lock'`)
228
450
  }
229
451
  }
230
452
  if (json) return answer(failures)
@@ -234,7 +456,7 @@ async function checkLock(
234
456
  }
235
457
  const n = metas.length
236
458
  const audited = `${n} project${n === 1 ? '' : 's'}`
237
- const skipped = bare === 0 ? '' : `; ${bare} without a vx.config not audited`
459
+ const skipped = bare.length === 0 ? '' : `; not audited, no vx.config: ${bareNames(bare)}`
238
460
  process.stdout.write(`vx lock --check: up to date (${audited}${skipped})\n`)
239
461
  return 0
240
462
  }
@@ -76,7 +76,7 @@ export function formatPlanText(plan: RunPlan): string {
76
76
  lines.push('')
77
77
  lines.push(summary.join(', ') + '.')
78
78
 
79
- // `--download` is invisible without this: the eligibility gate silently
79
+ // `--cache-download` is invisible without this: the eligibility gate silently
80
80
  // keeps producers eager, so a user who asked for `none` and got no
81
81
  // deferral has nothing to read. Say what WOULD stay remote, and name the
82
82
  // refusals when nothing would.
@@ -169,7 +169,7 @@ export function formatPlanJson(plan: RunPlan, affectedBase?: string): string {
169
169
  }),
170
170
  ...(plan.predicted !== undefined ? { predicted: plan.predicted } : {}),
171
171
  // The gate's refusals belong on the SCRIPTING surface too: a CI job
172
- // asking "did --download=none actually defer anything, and if not
172
+ // asking "did --cache-download=none actually defer anything, and if not
173
173
  // why" reads this, not the human table. (This object enumerates its
174
174
  // fields deliberately — the plan's internal shape is not the wire —
175
175
  // which is exactly why a new PlannedTask field does not appear here
@@ -8,10 +8,9 @@
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 { existsSync } from 'node:fs'
12
- import path from 'node:path'
13
- import { findWorkspaceRoot, WORKSPACE_CONFIG_FILENAMES } from '../workspace/index.js'
11
+ import { findWorkspaceRoot } from '../workspace/index.js'
14
12
  import { machineParallelism, maskedLine } from '../util/index.js'
13
+ import { resolveSetting } from '../settings.js'
15
14
 
16
15
  export interface ResolvedPluginCommand {
17
16
  plugin: VxPlugin
@@ -53,11 +52,6 @@ async function workspacePlugins(
53
52
  } catch {
54
53
  return null
55
54
  }
56
- // Plugins are declared only in a workspace file. Without one, the load
57
- // below is the orchestrator's import graph (~50 ms) for an empty list,
58
- // paid by `vx help` and by the `vx completions` a shell sources at start.
59
- if (!WORKSPACE_CONFIG_FILENAMES.some((f) => existsSync(path.join(workspaceRoot, f))))
60
- return { workspaceRoot, cacheDir: '', concurrency: 0, plugins: [] }
61
55
  let ws
62
56
  try {
63
57
  const { loadCliWorkspace } = await import('./workspace-config.js')
@@ -69,7 +63,9 @@ async function workspacePlugins(
69
63
  workspaceRoot,
70
64
  cacheDir: ws.cacheDir,
71
65
  // What run.ts takes when no --concurrency is given.
72
- concurrency: ws.workspaceConfig?.concurrency ?? machineParallelism(),
66
+ concurrency:
67
+ (resolveSetting('run.concurrency', ws.workspaceConfig).value as number | undefined) ??
68
+ machineParallelism(),
73
69
  plugins: ws.plugins,
74
70
  }
75
71
  }