@vzn/vx 0.0.652 → 0.0.653

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 (167) hide show
  1. package/README.md +2 -2
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +1 -0
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +25 -3
  8. package/src/cache/archive.ts +196 -31
  9. package/src/cache/cache.ts +863 -225
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +22 -5
  16. package/src/cache/index.ts +10 -1
  17. package/src/cache/inputs.ts +573 -152
  18. package/src/cache/key-fold.ts +1 -1
  19. package/src/cache/layer.ts +23 -10
  20. package/src/cache/layered-cache.ts +28 -7
  21. package/src/cache/output-index.ts +106 -44
  22. package/src/cache/plugin-cache.ts +2 -2
  23. package/src/cache/policy.ts +6 -2
  24. package/src/cache/remote-lane.ts +3 -1
  25. package/src/cache/run-history.ts +50 -10
  26. package/src/cache/schema.ts +42 -9
  27. package/src/cli/cache.ts +147 -34
  28. package/src/cli/completions.ts +2 -2
  29. package/src/cli/core-alias.ts +9 -2
  30. package/src/cli/docs.ts +4 -2
  31. package/src/cli/help.ts +25 -2
  32. package/src/cli/index.ts +38 -7
  33. package/src/cli/info.ts +4 -3
  34. package/src/cli/init.ts +55 -20
  35. package/src/cli/last.ts +184 -41
  36. package/src/cli/lock.ts +187 -24
  37. package/src/cli/plan-format.ts +24 -21
  38. package/src/cli/plugin-commands.ts +6 -1
  39. package/src/cli/plugin-templates.ts +1 -1
  40. package/src/cli/run.ts +68 -34
  41. package/src/cli/select.ts +160 -64
  42. package/src/cli/show.ts +63 -18
  43. package/src/cli/task-verb.ts +12 -7
  44. package/src/cli/upgrade.ts +210 -76
  45. package/src/cli/watch-filter.ts +88 -15
  46. package/src/cli/watch-fs.ts +34 -47
  47. package/src/cli/watch-judge.ts +72 -8
  48. package/src/cli/watch-set.ts +6 -4
  49. package/src/cli/watch.ts +179 -25
  50. package/src/cli/why.ts +107 -33
  51. package/src/cli/workspace-config.ts +11 -1
  52. package/src/config.ts +9 -0
  53. package/src/exec/env.ts +3 -1
  54. package/src/exec/executor.ts +76 -15
  55. package/src/exec/kill-tree.ts +7 -7
  56. package/src/exec/runner.ts +270 -108
  57. package/src/exec/sandbox-binds.ts +109 -18
  58. package/src/exec/sandbox-runtime.ts +134 -37
  59. package/src/exec/sandbox-violations.ts +250 -46
  60. package/src/exec/spawn-off.ts +10 -5
  61. package/src/exec/warm-shell.ts +29 -7
  62. package/src/graph/folds.ts +89 -16
  63. package/src/graph/index.ts +8 -0
  64. package/src/graph/priorities.ts +8 -5
  65. package/src/graph/scheduler.ts +173 -38
  66. package/src/graph/stand-ins.ts +36 -0
  67. package/src/graph/task-graph.ts +327 -141
  68. package/src/graph/task-status.ts +5 -1
  69. package/src/handoff.ts +45 -10
  70. package/src/history/failure-mode.ts +15 -5
  71. package/src/history/history.ts +103 -30
  72. package/src/history/index.ts +1 -0
  73. package/src/history/metrics.ts +189 -69
  74. package/src/index.ts +2 -1
  75. package/src/orchestrator/admission.ts +13 -9
  76. package/src/orchestrator/affected-tasks.ts +35 -13
  77. package/src/orchestrator/command-windows.ts +8 -5
  78. package/src/orchestrator/deferred-outputs.ts +41 -12
  79. package/src/orchestrator/doctor.ts +21 -9
  80. package/src/orchestrator/download-policy.ts +3 -3
  81. package/src/orchestrator/excluded-keys.ts +14 -2
  82. package/src/orchestrator/execute-args.ts +9 -2
  83. package/src/orchestrator/execute-task.ts +564 -123
  84. package/src/orchestrator/graph-memo.ts +26 -4
  85. package/src/orchestrator/graph-stage.ts +27 -4
  86. package/src/orchestrator/held-servers.ts +36 -14
  87. package/src/orchestrator/hit-restore.ts +136 -87
  88. package/src/orchestrator/index.ts +4 -1
  89. package/src/orchestrator/keyed-projects.ts +25 -0
  90. package/src/orchestrator/local-floor.ts +21 -3
  91. package/src/orchestrator/lockfile-claim.ts +30 -4
  92. package/src/orchestrator/miss-save.ts +33 -53
  93. package/src/orchestrator/options.ts +13 -0
  94. package/src/orchestrator/output-host.ts +16 -4
  95. package/src/orchestrator/output-log.ts +1 -54
  96. package/src/orchestrator/persistent-task.ts +31 -17
  97. package/src/orchestrator/persistent.ts +152 -15
  98. package/src/orchestrator/placement.ts +81 -15
  99. package/src/orchestrator/plan.ts +37 -15
  100. package/src/orchestrator/plugin-host.ts +232 -108
  101. package/src/orchestrator/plugin.ts +53 -27
  102. package/src/orchestrator/prepare.ts +194 -38
  103. package/src/orchestrator/projects.ts +89 -10
  104. package/src/orchestrator/read-only.ts +115 -0
  105. package/src/orchestrator/run-context.ts +6 -2
  106. package/src/orchestrator/run-failures.ts +43 -14
  107. package/src/orchestrator/run-id.ts +13 -2
  108. package/src/orchestrator/run-keys.ts +87 -28
  109. package/src/orchestrator/run-live.ts +16 -13
  110. package/src/orchestrator/run-lock.ts +27 -4
  111. package/src/orchestrator/run-records.ts +12 -10
  112. package/src/orchestrator/run-report.ts +2 -1
  113. package/src/orchestrator/run-stop.ts +17 -9
  114. package/src/orchestrator/run.ts +443 -107
  115. package/src/orchestrator/sandbox-fix.ts +40 -12
  116. package/src/orchestrator/sandbox-request.ts +286 -59
  117. package/src/orchestrator/save-lane.ts +24 -13
  118. package/src/orchestrator/shell-verdict.ts +20 -1
  119. package/src/orchestrator/signals.ts +103 -8
  120. package/src/orchestrator/stable-keys.ts +17 -9
  121. package/src/orchestrator/task-artifacts.ts +15 -2
  122. package/src/orchestrator/task-hash.ts +187 -30
  123. package/src/orchestrator/telemetry-host.ts +25 -6
  124. package/src/orchestrator/telemetry.ts +80 -12
  125. package/src/orchestrator/upstream.ts +3 -1
  126. package/src/report/events.ts +20 -3
  127. package/src/report/forecast.ts +103 -37
  128. package/src/report/framed-output.ts +165 -157
  129. package/src/report/index.ts +2 -1
  130. package/src/report/logger.ts +107 -30
  131. package/src/report/plain-output.ts +13 -9
  132. package/src/report/status-line.ts +28 -55
  133. package/src/report/summary.ts +57 -14
  134. package/src/settings.ts +42 -6
  135. package/src/ui/components.ts +36 -42
  136. package/src/ui/index.ts +3 -1
  137. package/src/ui/live-region.ts +67 -8
  138. package/src/ui/terminal.ts +12 -0
  139. package/src/ui/theme.ts +16 -1
  140. package/src/util/config-frame.ts +17 -3
  141. package/src/util/edit-distance.ts +17 -1
  142. package/src/util/errors.ts +23 -7
  143. package/src/util/foreign-cache-dir.ts +60 -0
  144. package/src/util/git-ignored.ts +59 -5
  145. package/src/util/index.ts +15 -1
  146. package/src/util/paths.ts +50 -6
  147. package/src/util/plain-text.ts +19 -0
  148. package/src/util/secret-mask.ts +57 -1
  149. package/src/util/settle.ts +16 -0
  150. package/src/util/size.ts +12 -2
  151. package/src/util/write-file.ts +86 -9
  152. package/src/workspace/affected.ts +225 -27
  153. package/src/workspace/config-bytecode.ts +23 -4
  154. package/src/workspace/config-eval.ts +90 -16
  155. package/src/workspace/config-imports.ts +96 -41
  156. package/src/workspace/config-schema.ts +126 -25
  157. package/src/workspace/dependencies-lock.ts +7 -14
  158. package/src/workspace/filter.ts +10 -1
  159. package/src/workspace/index.ts +3 -1
  160. package/src/workspace/json-data.ts +34 -8
  161. package/src/workspace/lockfile.ts +86 -37
  162. package/src/workspace/migration.ts +46 -25
  163. package/src/workspace/module-state.ts +62 -0
  164. package/src/workspace/nested-dirs.ts +8 -0
  165. package/src/workspace/project-loader.ts +276 -32
  166. package/src/workspace/repo-id.ts +29 -6
  167. package/src/workspace/workspace.ts +190 -30
@@ -3,18 +3,22 @@ import path from 'node:path'
3
3
  import type { ProjectConfig, WorkspaceConfig } from '../config.js'
4
4
  import { isUserError, UserError, xxh3hex } from '../util/index.js'
5
5
  import { isSkippedPlugin, validateProjectConfig, validateWorkspace } from './config-schema.js'
6
+ import { nonJsonMessage, nonJsonPaths } from './json-data.js'
6
7
  import {
7
8
  beginEvalRound,
8
9
  builtinsChangedBy,
9
10
  CONFIG_EXIT,
10
11
  evalBudgetMs,
12
+ evalDeadline,
11
13
  evaluateConfigFresh,
14
+ modulesChangedBy,
12
15
  thrownValueMessage,
13
16
  WATCHED_BUILTIN_NAMES,
14
17
  } from './config-eval.js'
15
18
  import { configImports, hasEsmExport, unprovidedBareImports } from './config-imports.js'
16
19
  import { readOnce, unreadable } from './load-reads.js'
17
20
  import { buildCompiled, loadCompiled, loadedModules } from './config-bytecode.js'
21
+ import { changedExports, exportState, type ExportState } from './module-state.js'
18
22
 
19
23
  // The validator lives in config-schema.ts; re-exported so a reader that
20
24
  // reaches the loader for it (the tests do) keeps working.
@@ -37,7 +41,11 @@ function assertDefaultObject(mod: unknown, kind: string, configPath: string): vo
37
41
  // the bare refusal did not say what was there instead (D-110).
38
42
  throw new UserError(
39
43
  `${kind} config at ${configPath} did not export a default object` +
40
- (typeof mod === 'function' ? `: ${EXPORTED_A_FUNCTION}` : ''),
44
+ (typeof mod === 'function'
45
+ ? `: ${EXPORTED_A_FUNCTION}`
46
+ : mod === undefined
47
+ ? ': it has no `export default` — write `export default { … }`'
48
+ : ''),
41
49
  )
42
50
  }
43
51
  }
@@ -200,9 +208,11 @@ async function loadDefaultExport(
200
208
  compiledRoot?: string,
201
209
  /** File texts read for the refusal, kept across one load's configs. */
202
210
  reads = new Map<string, Promise<string>>(),
211
+ /** The round refused unprovided imports in the config and its closure already. */
212
+ checked = false,
203
213
  ): Promise<unknown> {
204
214
  const text = new TextDecoder().decode(bytes)
205
- refuseUnprovided(text, configPath, configPath, kind)
215
+ if (!checked) refuseUnprovided(text, configPath, configPath, kind)
206
216
  // No random bust for `vx lock`: a project config reaches this import
207
217
  // only on its FIRST load in the process, so nothing is cached under the
208
218
  // URL yet, and a repeat load (the one that could replay an evaluation
@@ -230,7 +240,6 @@ async function loadDefaultExport(
230
240
  let compiled: { default?: unknown } | null | undefined
231
241
  let loaded: Set<string> | undefined
232
242
  const unguard = guardExit()
233
- let deadline: ReturnType<typeof setTimeout> | undefined
234
243
  try {
235
244
  // A bundle that throws is evaluated from source below, so the error
236
245
  // the user sees is the source's.
@@ -239,14 +248,13 @@ async function loadDefaultExport(
239
248
  // Awaited here: a default that is a rejected Promise is the config's
240
249
  // throw too, and passed out unawaited it reached bin.ts as a vx
241
250
  // defect (2026-10-28).
242
- if (compiled) return await defaultOf(compiled, kind, configPath)
251
+ if (compiled) return await withinBudget(defaultOf(compiled, kind, configPath), kind, configPath)
243
252
  if (compiled === undefined) loaded = loadedModules(compiledRoot!)
244
253
  // The worker's budget, here too: a top-level await that never settled
245
254
  // while a timer kept the loop alive hung `vx run` for good, silently
246
255
  // (D-66).
247
- await refuseUnprovidedClosure(text, configPath, kind, reads)
248
- const budget = evalBudgetMs()
249
- ns = (await Promise.race([
256
+ if (!checked) await refuseUnprovidedClosure(text, configPath, kind, reads)
257
+ ns = (await withinBudget(
250
258
  plain === null
251
259
  ? import(specifier)
252
260
  : import(configPath).then(
@@ -256,18 +264,9 @@ async function loadDefaultExport(
256
264
  return import(specifier)
257
265
  },
258
266
  ),
259
- new Promise<never>((_, reject) => {
260
- deadline = setTimeout(
261
- () =>
262
- reject(
263
- new UserError(
264
- `${kind} config ${configPath} did not finish evaluating within ${budget}ms (VX_CONFIG_WORKER_TIMEOUT)`,
265
- ),
266
- ),
267
- budget,
268
- )
269
- }),
270
- ])) as { default?: unknown }
267
+ kind,
268
+ configPath,
269
+ )) as { default?: unknown }
271
270
  } catch (err) {
272
271
  // A served module's frames name its specifier; the user wrote the path.
273
272
  const seen = new Set<unknown>()
@@ -278,7 +277,6 @@ async function loadDefaultExport(
278
277
  }
279
278
  throw loadFailure(err, configPath, kind)
280
279
  } finally {
281
- clearTimeout(deadline)
282
280
  unguard()
283
281
  if (source !== null) releaseSource(hash, plain, configPath)
284
282
  }
@@ -287,12 +285,42 @@ async function loadDefaultExport(
287
285
  await buildCompiled(compiledRoot, configPath, bytes, loaded, ns)
288
286
  }
289
287
  try {
290
- return await mod
288
+ // The budget again: a default that is a Promise which never settles
289
+ // hung every verb once the import's deadline was cleared.
290
+ return await withinBudget(mod, kind, configPath)
291
291
  } catch (err) {
292
292
  throw loadFailure(err, configPath, kind)
293
293
  }
294
294
  }
295
295
 
296
+ /** `work` settled, or a UserError once the eval budget (VX_CONFIG_WORKER_TIMEOUT) passes. */
297
+ async function withinBudget<T>(work: T | Promise<T>, kind: string, configPath: string): Promise<T> {
298
+ if (!(work instanceof Promise)) return work
299
+ const budget = evalBudgetMs()
300
+ let clearDeadline: (() => void) | undefined
301
+ try {
302
+ return await Promise.race([
303
+ work,
304
+ new Promise<never>((_, reject) => {
305
+ clearDeadline = evalDeadline(
306
+ kind,
307
+ configPath,
308
+ budget,
309
+ () =>
310
+ reject(
311
+ new UserError(
312
+ `${kind} config ${configPath} did not finish evaluating within ${budget}ms (VX_CONFIG_WORKER_TIMEOUT)`,
313
+ ),
314
+ ),
315
+ true,
316
+ )
317
+ }),
318
+ ])
319
+ } finally {
320
+ clearDeadline?.()
321
+ }
322
+ }
323
+
296
324
  function defaultOf(ns: { default?: unknown }, kind: string, configPath: string): unknown {
297
325
  const mod = ns?.default
298
326
  assertDefaultObject(mod, kind, configPath)
@@ -308,7 +336,10 @@ function tryCompiled(
308
336
  try {
309
337
  return loadCompiled(root, configPath, bytes)
310
338
  } catch {
311
- return null
339
+ // A bundle that throws (a truncated one too) is built again after the
340
+ // source load; null left every later run loading the source. The
341
+ // config's own throw throws from source too and builds nothing.
342
+ return undefined
312
343
  }
313
344
  }
314
345
 
@@ -380,13 +411,15 @@ const SCRIPT = /\.[cm]?[jt]sx?$/
380
411
  * refused (L-22; `--no-install` holds only the shebang, not `bun src/bin.ts`
381
412
  * or an embedder). Only a source evaluation pays: a fresh bundle resolved
382
413
  * every import it holds. `reads` keeps each file's text for the load's other
383
- * configs: a preset a thousand configs share is read once.
414
+ * configs: a preset a thousand configs share is read once. `statics` gets
415
+ * the scripts reached by static import (`configImports`).
384
416
  */
385
417
  async function refuseUnprovidedClosure(
386
418
  source: string,
387
419
  configPath: string,
388
420
  kind: string,
389
421
  reads: Map<string, Promise<string>>,
422
+ statics?: Set<string>,
390
423
  ): Promise<void> {
391
424
  if (!RELATIVE_IMPORT.test(source)) return
392
425
  const read = (file: string): Promise<string> => {
@@ -394,7 +427,7 @@ async function refuseUnprovidedClosure(
394
427
  if (text === undefined) reads.set(file, (text = Bun.file(file).text()))
395
428
  return text
396
429
  }
397
- for (const file of await configImports(configPath, source, read)) {
430
+ for (const file of await configImports(configPath, source, read, statics)) {
398
431
  if (!SCRIPT.test(file)) continue
399
432
  const text = await read(file).catch(() => null)
400
433
  if (text !== null) refuseUnprovided(text, file, configPath, kind)
@@ -452,7 +485,12 @@ export function configLoadError(err: unknown, configPath: string, kind: string):
452
485
  // printed `vx: JSON Parse error: Expected '}'` and named nothing. A
453
486
  // config's own `JSON.parse` has its line in the stack and passes through.
454
487
  if (name === 'SyntaxError' && !NAMED_FRAME.test(String((err as { stack?: unknown }).stack))) {
455
- return new UserError(`${kind} config ${configPath}: an import does not parse: ${message}`)
488
+ // A missing named export is a SyntaxError too, though nothing failed to
489
+ // parse: "does not parse" sent the user looking for a typo.
490
+ const what = message.startsWith('Export named ')
491
+ ? 'an import names an export its module lacks'
492
+ : 'an import does not parse'
493
+ return new UserError(`${kind} config ${configPath}: ${what}: ${message}`)
456
494
  }
457
495
  if (name === 'ResolveMessage') {
458
496
  const spec = /Cannot find (?:package|module) ['"]([^'"]+)['"]/.exec(message)?.[1]
@@ -551,6 +589,108 @@ function throwSite(stack: string, configPath: string): string | undefined {
551
589
  return `${sites[0]}, from ${own}`
552
590
  }
553
591
 
592
+ // A module several configs import is evaluated once in the process, so a
593
+ // config that changed it handed the change to every config evaluated after
594
+ // it: b locked `echo a,b` beside a, and `echo b` alone or when a loaded
595
+ // later. So before any config evaluates, the round imports each local
596
+ // module that more than one first load reaches by static import, in the
597
+ // order the configs reach them, notes what each one's exports hold, and
598
+ // refuses a change to them once every config has loaded. Importing them
599
+ // first evaluates nothing a config would not: whatever could make a shared
600
+ // module evaluate differently by order is a global or another shared
601
+ // module, both refused when changed.
602
+ interface ModuleWatch {
603
+ /** Each shared module, its namespace, its state before any config ran, and its importers. */
604
+ modules: Array<{ file: string; ns: object; state: ExportState; owners: string[] }>
605
+ /** First loads whose unprovided imports, closure included, were refused here already. */
606
+ checked: Set<string>
607
+ }
608
+
609
+ async function watchSharedModules(
610
+ prepared: ReadonlyArray<{ configPath: string; bytes: Uint8Array }>,
611
+ reads: Map<string, Promise<string>>,
612
+ ): Promise<ModuleWatch> {
613
+ const statics = await Promise.all(
614
+ prepared.map(async ({ configPath, bytes }) => {
615
+ if (loadedConfigs.has(configPath)) return undefined
616
+ const text = new TextDecoder().decode(bytes)
617
+ // No relative import, no module to share: its own load checks it, as before.
618
+ if (!RELATIVE_IMPORT.test(text)) return undefined
619
+ const own = new Set<string>()
620
+ try {
621
+ refuseUnprovided(text, configPath, configPath, 'Project')
622
+ await refuseUnprovidedClosure(text, configPath, 'Project', reads, own)
623
+ } catch {
624
+ // Its own load throws the same refusal, in order.
625
+ return undefined
626
+ }
627
+ return own
628
+ }),
629
+ )
630
+ const checked = new Set<string>()
631
+ const owners = new Map<string, string[]>()
632
+ prepared.forEach(({ configPath }, i) => {
633
+ const own = statics[i]
634
+ if (own === undefined) return
635
+ checked.add(configPath)
636
+ for (const file of own) {
637
+ if (!SCRIPT.test(file)) continue
638
+ const list = owners.get(file)
639
+ if (list === undefined) owners.set(file, [configPath])
640
+ else list.push(configPath)
641
+ }
642
+ })
643
+ const modules: ModuleWatch['modules'] = []
644
+ const shared = [...owners].filter(([file, list]) => list.length > 1 && !/[\\?]/.test(file))
645
+ if (shared.length === 0) return { modules, checked }
646
+ const loaded: Array<[string, object, string[]]> = []
647
+ const unguard = guardExit()
648
+ const budget = evalBudgetMs()
649
+ try {
650
+ for (const [file, list] of shared) {
651
+ let timer: ReturnType<typeof setTimeout> | undefined
652
+ // A module that throws or never settles is left to the configs that
653
+ // import it, which report it as their own load does.
654
+ const ns = await Promise.race([
655
+ import(file).catch(() => null) as Promise<object | null>,
656
+ new Promise<undefined>((resolve) => {
657
+ timer = setTimeout(() => resolve(undefined), budget)
658
+ }),
659
+ ])
660
+ clearTimeout(timer)
661
+ if (ns === undefined) break
662
+ if (ns !== null) loaded.push([file, ns, list])
663
+ }
664
+ } finally {
665
+ unguard()
666
+ }
667
+ // Noted once all are imported: a shared module that changes another as
668
+ // it evaluates does so in every round alike.
669
+ for (const [file, ns, list] of loaded)
670
+ modules.push({ file, ns, state: exportState(ns), owners: list })
671
+ return { modules, checked }
672
+ }
673
+
674
+ function sharedModuleChanged(
675
+ watch: ModuleWatch,
676
+ ): { file: string; exports: string[]; owners: string[] } | undefined {
677
+ for (const { file, ns, state, owners } of watch.modules) {
678
+ const exports = changedExports(state, exportState(ns))
679
+ if (exports.length > 0) return { file, exports, owners }
680
+ }
681
+ return undefined
682
+ }
683
+
684
+ function sharedModuleRefused(
685
+ shared: { file: string; exports: readonly string[] },
686
+ configPath?: string,
687
+ ): UserError {
688
+ const what = shared.exports.map((e) => `export ${e}`).join(', ')
689
+ return new UserError(
690
+ `${configPath ?? 'a project config'} changed ${what} of ${shared.file} while it was evaluated, and another project config imports that module: configs share one evaluation of it, so what they read from it depended on which loaded first; build a config's own value (\`[...list, x]\`, \`{ ...preset }\`) instead of changing a shared one`,
691
+ )
692
+ }
693
+
554
694
  /**
555
695
  * More than one config refused in one load: the first is the error, and
556
696
  * `all` names each. A rename after an upgrade refuses every config at once,
@@ -568,10 +708,19 @@ export class ConfigsRefused extends UserError {
568
708
  * one evaluated (in the order given, so a failure names the first broken
569
709
  * file the way a one-by-one load did). A single-path load is the
570
710
  * one-element case. Only `vx lock` evaluates; every run reads the lock.
711
+ *
712
+ * Each evaluates with the workspace root, `root`, as `process.cwd()`: the
713
+ * invoking directory made the lock depend on where `vx lock` ran, and
714
+ * `--check` from a subdirectory failed a lock written at the root. Not the
715
+ * project's own directory: the cwd is the process's, the worker's too, and
716
+ * the loads overlap. Without `root`, the cwd is left as it is.
571
717
  */
572
718
  const LOAD_WIDTH = 128
573
719
 
574
- export async function loadProjectConfigs(configPaths: readonly string[]): Promise<ProjectConfig[]> {
720
+ export async function loadProjectConfigs(
721
+ configPaths: readonly string[],
722
+ root?: string,
723
+ ): Promise<ProjectConfig[]> {
575
724
  const prepared = await Promise.all(
576
725
  configPaths.map(async (configPath) => ({
577
726
  configPath,
@@ -581,10 +730,13 @@ export async function loadProjectConfigs(configPaths: readonly string[]): Promis
581
730
  })),
582
731
  )
583
732
  let builtins: BuiltinSnapshot | undefined
733
+ let watch: ModuleWatch | undefined
584
734
  let env: Readonly<Record<string, string | undefined>> = {}
585
735
  let cwd = ''
736
+ let invokedIn = ''
586
737
  let umask = -1
587
738
  let unredirect: (() => void) | undefined
739
+ let unredirectFd: (() => void) | undefined
588
740
  // More than one evaluation in flight: a change seen after one load may
589
741
  // be another's.
590
742
  let overlapping = false
@@ -605,7 +757,14 @@ export async function loadProjectConfigs(configPaths: readonly string[]): Promis
605
757
  ? await evaluateConfigFresh(configPath).catch((err: unknown) => {
606
758
  throw loadFailure(err, configPath, 'Project')
607
759
  })
608
- : await loadDefaultExport(configPath, 'Project', bytes, undefined, reads)
760
+ : await loadDefaultExport(
761
+ configPath,
762
+ 'Project',
763
+ bytes,
764
+ undefined,
765
+ reads,
766
+ watch?.checked.has(configPath),
767
+ )
609
768
  // Before anything reads through them: a replaced `Array.prototype.includes`
610
769
  // turned the JSON-data walk's own check into "a cyclic reference".
611
770
  const changed = repeat
@@ -629,16 +788,23 @@ export async function loadProjectConfigs(configPaths: readonly string[]): Promis
629
788
  throw overlapping ? new ChangedInRound(changed) : builtinsChanged(changed, configPath)
630
789
  }
631
790
  assertDefaultObject(mod, 'Project', configPath)
632
- // Validation runs HERE, on whichever object we ended up with, so a
633
- // malformed config reports the identical UserError whether it was
634
- // evaluated in-process or in a worker.
635
- validateProjectConfig(mod as ProjectConfig, configPath)
791
+ // What JSON cannot carry is refused on the live object: the copy below
792
+ // would drop it unseen.
793
+ const [nonJson] = nonJsonPaths(mod)
794
+ if (nonJson !== undefined) throw new UserError(nonJsonMessage(configPath, nonJson))
636
795
  // A tree of its own, as the lock hands out. The module object shares
637
796
  // what the config shares: one preset's task in two configs, one `exec`
638
797
  // in two tasks. A `project` hook that edits in place then edited them
639
798
  // all (item 967). A config is JSON data (config-schema.ts), so the copy
640
799
  // loses nothing.
641
- return JSON.parse(JSON.stringify(mod)) as ProjectConfig
800
+ const copy = JSON.parse(JSON.stringify(mod)) as ProjectConfig
801
+ // Validation runs on the copy, the object every reader gets, so a
802
+ // malformed config reports the identical UserError whether it was
803
+ // evaluated in-process or in a worker. On the live object, a getter in
804
+ // an array or a Proxy answered the check with one value and the copy
805
+ // with another: `outputs.files: ['../victim/**']` reached the lock.
806
+ validateProjectConfig(copy, configPath, configPath, true)
807
+ return copy
642
808
  }
643
809
  // One worker for every repeat load in this round, however many there are.
644
810
  const endRound = beginEvalRound()
@@ -652,11 +818,15 @@ export async function loadProjectConfigs(configPaths: readonly string[]): Promis
652
818
  const results: Array<ProjectConfig | { failed: unknown }> = []
653
819
  if (prepared.length > 0) {
654
820
  unredirect = stdoutToStderr()
821
+ unredirectFd = fdOneToStderr()
655
822
  builtins = builtinSnapshot()
656
823
  env = { ...process.env }
824
+ invokedIn = process.cwd()
825
+ if (root !== undefined && root !== invokedIn) process.chdir(root)
657
826
  cwd = process.cwd()
658
827
  umask = currentUmask()
659
828
  }
829
+ if (prepared.length > 1) watch = await watchSharedModules(prepared, reads)
660
830
  overlapping = prepared.length > 1
661
831
  let next = 0
662
832
  const lane = async (): Promise<void> => {
@@ -709,8 +879,20 @@ export async function loadProjectConfigs(configPaths: readonly string[]): Promis
709
879
  // that moved it: the worker no longer reads the umask to say so.
710
880
  if (changed.length > 0)
711
881
  throw builtinsChanged(changed, prepared.length === 1 ? prepared[0]!.configPath : undefined)
882
+ const shared = watch === undefined ? undefined : sharedModuleChanged(watch)
883
+ if (shared !== undefined) {
884
+ for (const { configPath } of prepared) {
885
+ if (!shared.owners.includes(configPath)) continue
886
+ const own = await modulesChangedBy(configPath, [shared.file])
887
+ restoreUmask(umask)
888
+ if (own.length > 0) throw sharedModuleRefused(shared, configPath)
889
+ }
890
+ throw sharedModuleRefused(shared)
891
+ }
712
892
  return results as ProjectConfig[]
713
893
  } finally {
894
+ if (invokedIn !== '' && process.cwd() !== invokedIn) process.chdir(invokedIn)
895
+ unredirectFd?.()
714
896
  unredirect?.()
715
897
  endRound()
716
898
  }
@@ -786,6 +968,67 @@ function stdoutToStderr(): () => void {
786
968
  }
787
969
  }
788
970
 
971
+ /**
972
+ * fd 1 itself made stderr's while a round of project configs evaluates;
973
+ * returns the release. The JS routes above cannot reach the rest: Bun's
974
+ * ESM `import { writeSync } from 'node:fs'` is a copy taken when vx first
975
+ * imported it, `writeFileSync('/dev/stdout')` opens the descriptor anew,
976
+ * and a child a config spawns inherits it. `vx lock --check --format json`
977
+ * printed all three ahead of its JSON. Only `vx lock` evaluates project
978
+ * configs, so a run never binds libc for this. The worker shares the
979
+ * descriptor table, so a repeat load is covered too. Counted, as the
980
+ * redirect is. Where libc will not bind, the JS routes alone.
981
+ */
982
+ let fdHolders = 0
983
+ let undoFdRedirect = (): void => {}
984
+
985
+ function fdOneToStderr(): () => void {
986
+ if (fdHolders++ === 0) undoFdRedirect = swapFdOne()
987
+ return () => {
988
+ if (--fdHolders === 0) undoFdRedirect()
989
+ }
990
+ }
991
+
992
+ interface FdCalls {
993
+ dup: (fd: number) => number
994
+ dup2: (from: number, to: number) => number
995
+ close: (fd: number) => number
996
+ }
997
+ let fdCalls: FdCalls | null | undefined
998
+
999
+ /** libc's `dup`, `dup2` and `close`, bound once (~0.7 ms); null where libc will not load. */
1000
+ function boundFdCalls(): FdCalls | null {
1001
+ if (fdCalls !== undefined) return fdCalls
1002
+ fdCalls = null
1003
+ try {
1004
+ // Loaded here, not with the module: every run loads this module.
1005
+ const { dlopen, FFIType } = require('bun:ffi') as typeof import('bun:ffi')
1006
+ const fd = { args: [FFIType.i32], returns: FFIType.i32 } as const
1007
+ // glibc's soname; musl's loader answers any `libc.` name with itself.
1008
+ fdCalls = dlopen(process.platform === 'darwin' ? '/usr/lib/libSystem.B.dylib' : 'libc.so.6', {
1009
+ dup: fd,
1010
+ close: fd,
1011
+ dup2: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
1012
+ }).symbols
1013
+ } catch {}
1014
+ return fdCalls
1015
+ }
1016
+
1017
+ function swapFdOne(): () => void {
1018
+ const calls = boundFdCalls()
1019
+ if (calls === null) return () => {}
1020
+ const saved = calls.dup(1)
1021
+ if (saved < 0) return () => {}
1022
+ if (calls.dup2(2, 1) < 0) {
1023
+ calls.close(saved)
1024
+ return () => {}
1025
+ }
1026
+ return () => {
1027
+ calls.dup2(saved, 1)
1028
+ calls.close(saved)
1029
+ }
1030
+ }
1031
+
789
1032
  function installRedirect(): () => void {
790
1033
  const out = process.stdout
791
1034
  const ownWrite = Object.getOwnPropertyDescriptor(out, 'write')
@@ -886,6 +1129,7 @@ const BUN_MEMBERS_VX_READS: readonly PropertyKey[] = [
886
1129
  'Transpiler',
887
1130
  'build',
888
1131
  'color',
1132
+ 'concatArrayBuffers',
889
1133
  'connect',
890
1134
  'deepEquals',
891
1135
  'env',
@@ -27,6 +27,30 @@ import { gitEnv } from '../util/index.js'
27
27
  * git: those share nothing.
28
28
  */
29
29
  export async function repoIdOf(root: string): Promise<string | null> {
30
+ const repo = locateRepo(root)
31
+ if (repo === null) return null
32
+ const identity =
33
+ (await remoteIdentity(root, repo.commonDir)) ?? (await firstCommit(repo.commonDir, root))
34
+ return identity === null ? null : idOf(identity, repo.rel)
35
+ }
36
+
37
+ /**
38
+ * Every id `repoIdOf` gives `root` now or gave it before a remote was
39
+ * added: the remote's and the root commit's. `vx cache clean` deletes a
40
+ * store its index recorded only under one of these; any other is another
41
+ * workspace's.
42
+ */
43
+ export async function repoIdsOf(root: string): Promise<string[]> {
44
+ const repo = locateRepo(root)
45
+ if (repo === null) return []
46
+ const identities = await Promise.all([
47
+ remoteIdentity(root, repo.commonDir),
48
+ firstCommit(repo.commonDir, root),
49
+ ])
50
+ return identities.flatMap((identity) => (identity === null ? [] : [idOf(identity, repo.rel)]))
51
+ }
52
+
53
+ function locateRepo(root: string): (GitDir & { rel: string }) | null {
30
54
  // Git walks the physical path; the logical one found no `.git` through a
31
55
  // symlink from outside the repository, and an alias inside it another id.
32
56
  let real: string
@@ -37,12 +61,11 @@ export async function repoIdOf(root: string): Promise<string | null> {
37
61
  }
38
62
  const located = locateGitDir(real)
39
63
  if (located === null) return null
40
- const identity =
41
- (await remoteIdentity(root, located.commonDir)) ?? (await firstCommit(located.commonDir, root))
42
- if (identity === null) return null
43
- const rel = path.relative(located.gitRoot, real).split(path.sep).join('/')
44
- const key = sha256(`${identity}#${rel}`)
45
- return sha256(key).slice(0, 16)
64
+ return { ...located, rel: path.relative(located.gitRoot, real).split(path.sep).join('/') }
65
+ }
66
+
67
+ function idOf(identity: string, rel: string): string {
68
+ return sha256(sha256(`${identity}#${rel}`)).slice(0, 16)
46
69
  }
47
70
 
48
71
  const sha256 = (s: string): string => new Bun.CryptoHasher('sha256').update(s).digest('hex')