@vzn/vx 0.0.636 → 0.0.638

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