@vzn/vx 0.0.646 → 0.0.649

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 (184) hide show
  1. package/README.md +29 -22
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
package/src/cli/lock.ts CHANGED
@@ -7,35 +7,41 @@ import { open } from 'node:fs/promises'
7
7
  import path from 'node:path'
8
8
  import type { ProjectConfig } from '../config.js'
9
9
  import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
10
- import { buildTaskGraph, expandRequested } from '../graph/index.js'
11
- import {
12
- applyGraphStage,
13
- discoverProjects,
14
- hasHook,
15
- isDefaultBuild,
16
- loadProjects,
17
- } from '../orchestrator/index.js'
10
+ import { buildTaskGraph, expandRequested, isDefaultBuild } from '../graph/index.js'
11
+ import { applyGraphStage, discoverProjects, hasHook, loadProjects } from '../orchestrator/index.js'
18
12
  import { resolveSetting, runRules } from '../settings.js'
19
13
  import {
20
14
  refusalNotice,
21
15
  DOT_VX_GITATTRIBUTES,
16
+ fsRefusalHint,
17
+ isFsRefusal,
18
+ isOutOfFds,
22
19
  isUserError,
20
+ maskedLine,
23
21
  relPosix,
24
22
  type RefusalParts,
23
+ type UserError,
25
24
  secretMask,
25
+ type SecretMask,
26
26
  ui,
27
27
  sayNotice,
28
+ refuseSymlink,
29
+ writeNoFollow,
28
30
  } from '../util/index.js'
29
31
  import {
32
+ ConfigsRefused,
30
33
  findProjectConfigs,
31
34
  findWorkspaceRoot,
35
+ REVIEW_FILE,
32
36
  loadProjectConfigs,
33
37
  LOCKFILE_NAME,
34
38
  LOCKFILE_VERSION,
35
39
  lockKey,
36
40
  readLockfile,
37
41
  buildPackageGraph,
42
+ checkBracketLiterals,
38
43
  checkDependencyRules,
44
+ computeNestedProjectDirs,
39
45
  writeLockfile,
40
46
  type Lockfile,
41
47
  type ProjectMeta,
@@ -107,7 +113,7 @@ export async function lockWorkspace(
107
113
  ): Promise<number> {
108
114
  const check = await audit(root, warn)
109
115
  if (check.broken !== null) {
110
- notWritten(check.broken, check.brokenParts)
116
+ notWritten(check.broken, check.brokenParts, check.alsoBroken)
111
117
  return 1
112
118
  }
113
119
  return await writeLock(root, check, say)
@@ -116,7 +122,7 @@ export async function lockWorkspace(
116
122
  /**
117
123
  * The lock as `vx lock` would write it, and the graph rules (`run.rules`)
118
124
  * 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
125
+ * the lock and does not check them (2026-10-13). Every config is
120
126
  * found (`findProjectConfigs`) and evaluated once; the projects, names and
121
127
  * graph are the ones a run builds from that lock, the `discover` and
122
128
  * `graph` stages included.
@@ -126,16 +132,21 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
126
132
  const resolved = new Map<string, ProjectConfig>()
127
133
  const out: GraphCheck = {
128
134
  broken: null,
135
+ loaded: false,
129
136
  fresh: { version: LOCKFILE_VERSION, projects: {} },
130
137
  all: [],
131
138
  tasked,
132
139
  resolved,
133
140
  reviewFiles: true,
141
+ strayReviews: [],
142
+ shadowed: [],
134
143
  }
135
144
  try {
136
145
  const { workspaceConfig, plugins, cacheDir } = await loadCliWorkspace(root)
137
146
  out.reviewFiles = resolveSetting('lock.reviewFiles', workspaceConfig).value !== false
138
- const found = await findProjectConfigs(root)
147
+ const { configs: found, strayReviews, shadowed } = await findProjectConfigs(root)
148
+ out.strayReviews = strayReviews
149
+ out.shadowed = shadowed
139
150
  // The one load that evaluates: every reader reads the lock this writes.
140
151
  const configs = await loadProjectConfigs(found.map((f) => f.configPath))
141
152
  // In the lock's form: a field left `undefined` is a key the stored JSON
@@ -166,6 +177,8 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
166
177
  lock: true,
167
178
  })
168
179
  for (const entry of projects.values()) resolved.set(entry.name, entry.config)
180
+ out.loaded = true
181
+ checkBracketLiterals(root, projects.values())
169
182
  const names = new Set<string>()
170
183
  for (const entry of projects.values()) {
171
184
  const tasks = Object.entries(entry.config.tasks ?? {})
@@ -182,12 +195,19 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
182
195
  buildPackageGraph(out.all, taskEdgesFrom(projects)),
183
196
  )
184
197
  const rules = runRules(workspaceConfig)
198
+ const nameOf = new Map(out.all.map((m) => [m.dir, m.name]))
185
199
  const nodes = buildTaskGraph({
186
200
  projects,
187
201
  packageGraph: buildPackageGraph(out.all),
188
202
  requested: expandRequested([...names], [...projects.keys()], projects, isDefaultBuild),
189
203
  workspaceRoot: root,
190
204
  rules,
205
+ nestedProjects: new Map(
206
+ [...computeNestedProjectDirs(out.all)].map(([name, dirs]) => [
207
+ name,
208
+ dirs.map((dir) => ({ name: nameOf.get(dir)!, dir })),
209
+ ]),
210
+ ),
191
211
  })
192
212
  if (hasHook(plugins, 'graph'))
193
213
  await applyGraphStage(plugins, nodes, {
@@ -199,7 +219,12 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
199
219
  })
200
220
  } catch (err) {
201
221
  if (isUserError(err))
202
- return { ...out, broken: err.message, ...(err.parts ? { brokenParts: err.parts } : {}) }
222
+ return {
223
+ ...out,
224
+ broken: err.message,
225
+ ...(err.parts ? { brokenParts: err.parts } : {}),
226
+ ...(err instanceof ConfigsRefused ? { alsoBroken: err.all.slice(1) } : {}),
227
+ }
203
228
  throw err
204
229
  }
205
230
  return out
@@ -209,6 +234,10 @@ interface GraphCheck {
209
234
  /** A graph rule's refusal, or a config that does not load; null when sound. */
210
235
  broken: string | null
211
236
  brokenParts?: RefusalParts
237
+ /** Every config loaded and resolved: `broken`, if set, is a graph rule's refusal. */
238
+ loaded: boolean
239
+ /** The other configs that do not load, past `broken`. */
240
+ alsoBroken?: readonly UserError[]
212
241
  /** The lock the configs evaluate to here. */
213
242
  fresh: Lockfile
214
243
  /** Every project, as a run discovers them from `fresh`. */
@@ -219,6 +248,10 @@ interface GraphCheck {
219
248
  resolved: Map<string, ProjectConfig>
220
249
  /** `lock.reviewFiles`. */
221
250
  reviewFiles: boolean
251
+ /** Review copies in a directory no config is in (`findProjectConfigs`). */
252
+ strayReviews: string[]
253
+ /** Config files a higher-precedence one beside them hides (`findProjectConfigs`). */
254
+ shadowed: { file: string; read: string }[]
222
255
  }
223
256
 
224
257
  function configured(all: readonly ProjectMeta[]): ConfiguredMeta[] {
@@ -237,14 +270,38 @@ export async function lockAfterMigration(root: string): Promise<{ code: number;
237
270
  return { code, said }
238
271
  } catch (err) {
239
272
  if (!isUserError(err)) throw err
240
- notWritten(err.message, err.parts)
273
+ notWritten(err.message, err.parts, err instanceof ConfigsRefused ? err.all.slice(1) : [])
241
274
  return { code: 1, said }
242
275
  }
243
276
  }
244
277
 
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' }))
278
+ /** Refusals shown in full past the first; the rest are counted. */
279
+ const ALSO_SHOWN = 4
280
+
281
+ /**
282
+ * The lock refused: the refusal's own parts, or its config line and frame,
283
+ * or its message; then each other config that does not load, so one run
284
+ * names what a repo must fix.
285
+ */
286
+ function notWritten(
287
+ message: string,
288
+ parts: RefusalParts | undefined,
289
+ also: readonly UserError[] = [],
290
+ ): void {
291
+ // Masked: a config's own throw is its author's text, and quotes what
292
+ // it read (L-11). bin.ts masks what reaches it; this is printed here.
293
+ ui.print(
294
+ 'err',
295
+ refusalNotice('vx lock', maskedLine(message), parts, { headline: 'lock not written' }),
296
+ )
297
+ for (const e of also.slice(0, ALSO_SHOWN))
298
+ ui.print(
299
+ 'err',
300
+ refusalNotice('vx lock', maskedLine(e.message), e.parts, { headline: 'also refused' }),
301
+ )
302
+ const more = also.length - ALSO_SHOWN
303
+ if (more > 0)
304
+ sayNotice('error', 'vx lock', `${more} more configs refused; fix these, then run vx lock again`)
248
305
  }
249
306
 
250
307
  /** Names shown per note: a turbo() repo can have hundreds of bare projects. */
@@ -259,9 +316,6 @@ function bareNote(bare: readonly string[]): string {
259
316
  return bare.length === 0 ? '' : ` (no vx.config, so not locked: ${bareNames(bare)})`
260
317
  }
261
318
 
262
- /** The name `vx lock` writes each project's review copy under. */
263
- const REVIEW_FILE = 'vx.lock.json'
264
-
265
319
  interface Review {
266
320
  name: string
267
321
  file: string
@@ -331,16 +385,69 @@ async function writeLock(
331
385
  })
332
386
  return 1
333
387
  }
388
+ // The copies first: one that cannot be written leaves the lock as it was,
389
+ // not a new lock beside stale copies and a stack.
390
+ for (const r of reviews) refuseSymlink(r.file)
391
+ const blocked = (
392
+ await Promise.all(
393
+ reviews.map((r) =>
394
+ writeNoFollow(r.file, r.text).then(
395
+ () => null,
396
+ (err: unknown): { file: string; err: NodeJS.ErrnoException } => {
397
+ if (typeof (err as NodeJS.ErrnoException).code !== 'string' || isOutOfFds(err))
398
+ throw err
399
+ return { file: r.file, err: err as NodeJS.ErrnoException }
400
+ },
401
+ ),
402
+ ),
403
+ )
404
+ ).filter((b) => b !== null)
405
+ if (blocked.length > 0) {
406
+ const first = blocked[0]!
407
+ notWritten('', {
408
+ headline: `${blocked.length === 1 ? 'a review copy' : 'review copies'} could not be written`,
409
+ // The code and the copy's path: the message names the temp the write renames.
410
+ what: blocked.map((b) => `${b.err.code}: ${relPosix(root, b.file)}`),
411
+ fix: isFsRefusal(first.err)
412
+ ? fsRefusalHint(first.err)
413
+ : `remove what stands at ${relPosix(root, first.file)}, then run vx lock again`,
414
+ })
415
+ return 1
416
+ }
334
417
  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
- ])
418
+ await writeIfMissing(path.join(root, '.vx', '.gitattributes'), DOT_VX_GITATTRIBUTES)
339
419
  const n = metas.length
340
420
  say(`${n} project config${n === 1 ? '' : 's'} → ${LOCKFILE_NAME}${bareNote(bare)}`)
421
+ // A second config beside the one read was edited and nothing changed.
422
+ warnShadowed(root, check, 'vx lock')
423
+ // Named here too: a lock that wrote clean failed the `--check` after it.
424
+ if (check.reviewFiles)
425
+ for (const file of await strayCopies(check)) sayNotice('warn', 'vx lock', strayLine(root, file))
341
426
  return 0
342
427
  }
343
428
 
429
+ /**
430
+ * Review copies with no config beside them, sorted. One left beside a
431
+ * config since deleted shows reviewers tasks nothing runs, and keys the
432
+ * `**` of a project around it. vx never deletes a committed file; it
433
+ * names it. A project kept without a config (a plugin's) is checked on
434
+ * disk; any other directory is what discovery listed.
435
+ */
436
+ async function strayCopies(check: GraphCheck): Promise<string[]> {
437
+ const kept = await Promise.all(
438
+ check.all
439
+ .filter((m) => m.configPath === null)
440
+ .map(async (m) => {
441
+ const file = path.join(m.dir, REVIEW_FILE)
442
+ return (await Bun.file(file).exists()) ? file : null
443
+ }),
444
+ )
445
+ return [...new Set([...kept.filter((f) => f !== null), ...check.strayReviews])].sort()
446
+ }
447
+
448
+ const strayLine = (root: string, file: string): string =>
449
+ `${relPosix(root, file)} has no vx.config beside it — delete it`
450
+
344
451
  /**
345
452
  * Each place an evaluated config holds the value of a secret in this
346
453
  * environment, by the rule output masking uses (a secret-named variable,
@@ -354,17 +461,20 @@ function secretsIn(projects: Record<string, ProjectConfig>): Array<{ at: string;
354
461
  for (const task of Object.values(config.tasks ?? {}))
355
462
  for (const n of task?.exec?.env?.secret ?? []) named.add(n)
356
463
  }
357
- const secrets: [value: string, name: string][] = []
464
+ // Each variable's own mask: what the output masks of it, a URL's
465
+ // password and a key's lines too, not only the whole value.
466
+ const secrets: [mask: SecretMask, name: string][] = []
358
467
  for (const [name, value] of Object.entries(process.env)) {
359
- if (value !== undefined && secretMask([{ [name]: value }], named.has(name) ? [name] : []))
360
- secrets.push([value, name])
468
+ const mask =
469
+ value === undefined ? null : secretMask([{ [name]: value }], named.has(name) ? [name] : [])
470
+ if (mask !== null) secrets.push([mask, name])
361
471
  }
362
472
  if (secrets.length === 0) return []
363
473
  const found: Array<{ at: string; name: string }> = []
364
474
  const walk = (project: string, at: string, v: unknown): void => {
365
475
  if (typeof v === 'string') {
366
- for (const [value, name] of secrets)
367
- if (v.includes(value)) found.push({ at: `${project}: ${at} holds $${name}`, name })
476
+ for (const [mask, name] of secrets)
477
+ if (mask.spans(v).length > 0) found.push({ at: `${project}: ${at} holds $${name}`, name })
368
478
  } else if (Array.isArray(v)) {
369
479
  v.forEach((x, i) => walk(project, `${at}[${i}]`, x))
370
480
  } else if (v !== null && typeof v === 'object') {
@@ -375,6 +485,16 @@ function secretsIn(projects: Record<string, ProjectConfig>): Array<{ at: string;
375
485
  return found
376
486
  }
377
487
 
488
+ /** A second config beside the one read: edited, it changes nothing. */
489
+ function warnShadowed(root: string, check: GraphCheck, verb: string): void {
490
+ for (const { file, read } of check.shadowed)
491
+ sayNotice(
492
+ 'warn',
493
+ verb,
494
+ `${relPosix(root, file)} is not read: ${path.basename(read)} beside it is — delete one`,
495
+ )
496
+ }
497
+
378
498
  /**
379
499
  * Audit the lock: find and re-evaluate every config in the CURRENT
380
500
  * environment and deep-compare it with the locked object. That sees an
@@ -383,6 +503,9 @@ function secretsIn(projects: Record<string, ProjectConfig>): Array<{ at: string;
383
503
  */
384
504
  async function checkLock(root: string, json: boolean): Promise<number> {
385
505
  const check = await audit(root, warnToStderr)
506
+ // A warning, as `vx lock` says it: the CI gate passed beside a hidden
507
+ // config whose edit changed nothing.
508
+ warnShadowed(root, check, 'vx lock --check')
386
509
  const { broken } = check
387
510
  const metas = configured(check.all)
388
511
  const bare = bareProjects(check.all, check.tasked)
@@ -398,7 +521,16 @@ async function checkLock(root: string, json: boolean): Promise<number> {
398
521
  }
399
522
  return problems.length === 0 ? 0 : 1
400
523
  }
401
- const lock = await readLockfile(root)
524
+ // A lock that does not read is drift `vx lock` fixes, as a missing one
525
+ // is: the audit answers it in its own shape, not as a refusal.
526
+ let lock: Lockfile | null
527
+ try {
528
+ lock = await readLockfile(root)
529
+ } catch (err) {
530
+ if (!isUserError(err)) throw err
531
+ if (!json) sayNotice('error', 'vx lock --check', err.message)
532
+ return answer([err.message])
533
+ }
402
534
  if (!lock) {
403
535
  const problem = `no ${LOCKFILE_NAME} at ${root} — run \`vx lock\` first`
404
536
  if (!json) sayNotice('error', 'vx lock --check', problem)
@@ -413,7 +545,7 @@ async function checkLock(root: string, json: boolean): Promise<number> {
413
545
  locked === undefined
414
546
  ? `${rel} is not in the lock — run 'vx lock'`
415
547
  : locked.file !== fresh.file
416
- ? `${rel} is not the ${locked.file} the lock holds for ${dir} — run 'vx lock'`
548
+ ? `${rel} is not the ${locked.file} the lock holds for ${dir === '.' ? 'the workspace root project' : dir} — run 'vx lock'`
417
549
  : !Bun.deepEquals(fresh.config, locked.config, true)
418
550
  ? `${rel} evaluates differently from the lock here — its config, a file ` +
419
551
  `it imports, or the environment it reads changed; run 'vx lock'`
@@ -422,47 +554,60 @@ async function checkLock(root: string, json: boolean): Promise<number> {
422
554
  failures.push(problem)
423
555
  drifted.add(dir)
424
556
  }
425
- if (broken !== null) failures.push(broken)
426
- if (check.reviewFiles && broken === null) {
557
+ if (broken !== null && check.loaded) failures.push(broken)
558
+ // Each config that does not load, as `vx lock` names them: a CI fixing
559
+ // one at a time re-ran the gate once per broken config.
560
+ const refused: { message: string; parts?: RefusalParts }[] =
561
+ broken === null || check.loaded
562
+ ? []
563
+ : [
564
+ { message: broken, ...(check.brokenParts ? { parts: check.brokenParts } : {}) },
565
+ ...(check.alsoBroken ?? []).map((e) => ({
566
+ message: e.message,
567
+ ...(e.parts ? { parts: e.parts } : {}),
568
+ })),
569
+ ]
570
+ if (check.reviewFiles && check.loaded) {
427
571
  // A project the lock already fails is named once.
428
572
  const stale = await Promise.all(
429
573
  reviewsOf(
430
574
  metas.filter((m) => !drifted.has(lockKey(root, m.dir))),
431
575
  check.resolved,
432
576
  ).map(async (r) => {
577
+ // Only a missing copy is "has no": a directory in its place, or one
578
+ // this user cannot read, read as missing too.
433
579
  const text = await Bun.file(r.file)
434
580
  .text()
435
- .catch(() => null)
581
+ .catch((err: NodeJS.ErrnoException) => (err.code === 'ENOENT' ? null : err))
436
582
  // Compared as JSON: a formatter may lay the copy out its own way.
437
- if (text !== null && sameJson(text, r.config)) return null
583
+ if (typeof text === 'string' && sameJson(text, r.config)) return null
438
584
  const rel = relPosix(root, r.file)
439
585
  return text === null
440
586
  ? `"${r.name}" has no ${rel} — run 'vx lock'`
441
- : `"${r.name}" (${rel}) is not what its config resolves to here — run 'vx lock'`
587
+ : typeof text !== 'string'
588
+ ? `"${r.name}" (${rel}) could not be read (${text.code ?? text.message}) — remove it and run 'vx lock'`
589
+ : `"${r.name}" (${rel}) is not what its config resolves to here — run 'vx lock'`
442
590
  }),
443
591
  )
444
592
  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)
593
+ for (const file of await strayCopies(check)) failures.push(strayLine(root, file))
458
594
  }
459
- for (const dir of Object.keys(lock.projects)) {
595
+ // A config that does not load evaluated none: every locked project
596
+ // read as gone, the healthy ones too.
597
+ for (const dir of check.loaded ? Object.keys(lock.projects) : []) {
460
598
  if (!Object.hasOwn(check.fresh.projects, dir)) {
461
- failures.push(`the lock holds ${dir}, which has no vx.config now — run 'vx lock'`)
599
+ const what = dir === '.' ? 'the workspace root project' : dir
600
+ failures.push(`the lock holds ${what}, which has no vx.config now — run 'vx lock'`)
462
601
  }
463
602
  }
464
- if (json) return answer(failures)
465
- if (failures.length > 0) {
603
+ if (json)
604
+ // Root-relative, as every other problem: the loader names the absolute path.
605
+ return answer([
606
+ ...refused.map((r) => r.message.split(`${root}${path.sep}`).join('')),
607
+ ...failures,
608
+ ])
609
+ if (failures.length + refused.length > 0) {
610
+ for (const r of refused) ui.print('err', refusalNotice('vx lock --check', r.message, r.parts))
466
611
  for (const f of failures) sayNotice('error', 'vx lock --check', f)
467
612
  return 1
468
613
  }