@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
@@ -1,7 +1,7 @@
1
- import { existsSync, realpathSync, statSync } from 'node:fs'
1
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import type { ProjectConfig, WorkspaceConfig } from '../config.js'
4
- import { UserError, xxh3hex } from '../util/index.js'
4
+ import { isUserError, UserError, xxh3hex } from '../util/index.js'
5
5
  import { isSkippedPlugin, validateProjectConfig, validateWorkspace } from './config-schema.js'
6
6
  import {
7
7
  beginEvalRound,
@@ -24,7 +24,7 @@ export { validateProjectConfig }
24
24
  import { WORKSPACE_CONFIG_FILENAMES, workspaceFile, workspaceRootOf } from '../handoff.js'
25
25
  export { WORKSPACE_CONFIG_FILENAMES }
26
26
 
27
- /** What a function default export is told; the playground says the same. */
27
+ /** What a function default export is told. */
28
28
  const EXPORTED_A_FUNCTION =
29
29
  'it exports a function, and vx reads the object itself — export what the function returns'
30
30
 
@@ -92,19 +92,72 @@ function serveHeldSources(): void {
92
92
  contents: heldSources.get(HELD_QUERY.exec(args.path)![1]!)!.source,
93
93
  loader: /\.[cm]?ts\?/.test(args.path) ? 'ts' : 'js',
94
94
  }))
95
+ build.onLoad({ filter: PLAIN_CONFIG }, (args) => {
96
+ const loader = /\.[cm]?ts$/.test(args.path) ? 'ts' : 'js'
97
+ const held = heldPaths.get(args.path) ?? heldByRealPath(args.path)
98
+ if (held !== undefined) {
99
+ held.served = true
100
+ return { contents: heldSources.get(held.hash)!.source, loader }
101
+ }
102
+ // A config no load holds now, imported by another module. `onLoad`
103
+ // cannot decline it, so it is served as Bun reads it, and what
104
+ // `onLoad` cannot carry (CommonJS, not UTF-8) is Bun's own load.
105
+ const bytes = readFileSync(args.path)
106
+ const source = servableSource(bytes, loader)
107
+ if (source !== null) return { contents: source, loader }
108
+ const native = JSON.stringify(`${args.path}?vx-bust=${xxh3hex(bytes)}`)
109
+ return {
110
+ contents: `import * as m from ${native}\nexport * from ${native}\nexport default m.default\n`,
111
+ loader: 'js',
112
+ }
113
+ })
95
114
  },
96
115
  })
97
116
  }
98
117
 
99
- function holdSource(hash: string, source: string): void {
118
+ function holdSource(hash: string, source: string, plain: Held | null, configPath: string): void {
100
119
  const held = heldSources.get(hash)
101
120
  if (held === undefined) heldSources.set(hash, { source, uses: 1 })
102
121
  else held.uses++
122
+ if (plain !== null) heldPaths.set(configPath, plain)
103
123
  }
104
124
 
105
- function releaseSource(hash: string): void {
125
+ function releaseSource(hash: string, plain: Held | null, configPath: string): void {
106
126
  const held = heldSources.get(hash)!
107
127
  if (--held.uses === 0) heldSources.delete(hash)
128
+ if (plain !== null) heldPaths.delete(configPath)
129
+ }
130
+
131
+ // Served under `?vx-held=`, a config was a second module beside the plain
132
+ // path an import cycle reaches back to, and its `import.meta.url` carried
133
+ // the query, unlike the worker's. So it is imported under its own path,
134
+ // and `served` says whether this load's bytes were what Bun evaluated:
135
+ // unset, the registry held the path already (a config another imported,
136
+ // maybe older bytes), and only a query evaluates afresh.
137
+ interface Held {
138
+ hash: string
139
+ served: boolean
140
+ /** Learned only when `onLoad` misses: Bun hands it the real path. */
141
+ real?: string | null
142
+ }
143
+ const heldPaths = new Map<string, Held>()
144
+
145
+ const PLAIN_CONFIG = /[\\/]vx\.config\.[cm]?[jt]s$/
146
+
147
+ // A realpath for every config was 7–10 ms on 1,000 (2026-10-28); only a
148
+ // path reached through a link misses, so only a miss pays it.
149
+ function heldByRealPath(real: string): Held | undefined {
150
+ for (const [configPath, held] of heldPaths) {
151
+ if (held.real === undefined) {
152
+ try {
153
+ held.real = realpathSync(configPath)
154
+ } catch {
155
+ held.real = null
156
+ }
157
+ }
158
+ if (held.real === real) return held
159
+ }
160
+ return undefined
108
161
  }
109
162
 
110
163
  const utf8 = new TextDecoder('utf-8', { fatal: true })
@@ -145,8 +198,11 @@ async function loadDefaultExport(
145
198
  bytes: Uint8Array,
146
199
  /** The workspace root, where the load may run the config's bytecode bundle (config-bytecode.ts). */
147
200
  compiledRoot?: string,
201
+ /** File texts read for the refusal, kept across one load's configs. */
202
+ reads = new Map<string, Promise<string>>(),
148
203
  ): Promise<unknown> {
149
- refuseUnprovidedImports(bytes, configPath, kind)
204
+ const text = new TextDecoder().decode(bytes)
205
+ refuseUnprovided(text, configPath, configPath, kind)
150
206
  // No random bust for `vx lock`: a project config reaches this import
151
207
  // only on its FIRST load in the process, so nothing is cached under the
152
208
  // URL yet, and a repeat load (the one that could replay an evaluation
@@ -163,10 +219,12 @@ async function loadDefaultExport(
163
219
  )
164
220
  }
165
221
  const hash = xxh3hex(bytes)
222
+ const plain: Held | null =
223
+ source !== null && !literal && PLAIN_CONFIG.test(configPath) ? { hash, served: false } : null
166
224
  const specifier = `${configPath}?vx-${literal ? 'literal' : source !== null ? 'held' : 'bust'}=${hash}`
167
225
  if (source !== null) {
168
226
  serveHeldSources()
169
- holdSource(hash, source)
227
+ holdSource(hash, source, plain, configPath)
170
228
  }
171
229
  let ns: { default?: unknown }
172
230
  let compiled: { default?: unknown } | null | undefined
@@ -178,14 +236,26 @@ async function loadDefaultExport(
178
236
  // the user sees is the source's.
179
237
  compiled =
180
238
  literal || compiledRoot === undefined ? null : tryCompiled(compiledRoot, configPath, bytes)
181
- if (compiled) return defaultOf(compiled, kind, configPath)
239
+ // Awaited here: a default that is a rejected Promise is the config's
240
+ // throw too, and passed out unawaited it reached bin.ts as a vx
241
+ // defect (2026-10-28).
242
+ if (compiled) return await defaultOf(compiled, kind, configPath)
182
243
  if (compiled === undefined) loaded = loadedModules(compiledRoot!)
183
244
  // The worker's budget, here too: a top-level await that never settled
184
245
  // while a timer kept the loop alive hung `vx run` for good, silently
185
246
  // (D-66).
247
+ await refuseUnprovidedClosure(text, configPath, kind, reads)
186
248
  const budget = evalBudgetMs()
187
249
  ns = (await Promise.race([
188
- import(specifier),
250
+ plain === null
251
+ ? import(specifier)
252
+ : import(configPath).then(
253
+ (ns: unknown) => (plain.served ? ns : import(specifier)),
254
+ (err: unknown) => {
255
+ if (plain.served) throw err
256
+ return import(specifier)
257
+ },
258
+ ),
189
259
  new Promise<never>((_, reject) => {
190
260
  deadline = setTimeout(
191
261
  () =>
@@ -200,20 +270,27 @@ async function loadDefaultExport(
200
270
  ])) as { default?: unknown }
201
271
  } catch (err) {
202
272
  // A served module's frames name its specifier; the user wrote the path.
203
- if (err instanceof Error && err.stack !== undefined) {
204
- err.stack = err.stack.replaceAll(specifier, configPath).replace(BUST_QUERY, '')
273
+ const seen = new Set<unknown>()
274
+ for (let e: unknown = err; e instanceof Error && !seen.has(e); e = e.cause) {
275
+ seen.add(e)
276
+ if (e.stack !== undefined)
277
+ e.stack = e.stack.replaceAll(specifier, configPath).replace(BUST_QUERY, '')
205
278
  }
206
279
  throw loadFailure(err, configPath, kind)
207
280
  } finally {
208
281
  clearTimeout(deadline)
209
282
  unguard()
210
- if (source !== null) releaseSource(hash)
283
+ if (source !== null) releaseSource(hash, plain, configPath)
211
284
  }
212
285
  const mod = defaultOf(ns, kind, configPath)
213
286
  if (compiledRoot !== undefined && loaded !== undefined) {
214
287
  await buildCompiled(compiledRoot, configPath, bytes, loaded, ns)
215
288
  }
216
- return mod
289
+ try {
290
+ return await mod
291
+ } catch (err) {
292
+ throw loadFailure(err, configPath, kind)
293
+ }
217
294
  }
218
295
 
219
296
  function defaultOf(ns: { default?: unknown }, kind: string, configPath: string): unknown {
@@ -270,29 +347,60 @@ function pnpAbove(dir: string): boolean {
270
347
  * evaluation: left to Bun, a workspace with no `node_modules` would have
271
348
  * the package auto-installed from the registry first (config-imports.ts),
272
349
  * and a config must never download. The message keeps the shape Bun's own
273
- * refusal has, with the remedy.
350
+ * refusal has, with the remedy. `source` is `file`'s: the config's own,
351
+ * or one it reaches.
274
352
  */
275
- function refuseUnprovidedImports(bytes: Uint8Array, configPath: string, kind: string): void {
276
- const loader = /\.[cm]?ts$/.test(configPath) ? 'ts' : 'js'
277
- const missing = unprovidedBareImports(
278
- new TextDecoder().decode(bytes),
279
- path.dirname(configPath),
280
- loader,
281
- )
353
+ function refuseUnprovided(source: string, file: string, configPath: string, kind: string): void {
354
+ const loader = /\.[cm]?tsx?$/.test(file) ? 'ts' : 'js'
355
+ const missing = unprovidedBareImports(source, path.dirname(file), loader)
282
356
  if (missing.length === 0) return
357
+ const own = file === configPath
358
+ const what = own
359
+ ? `cannot find '${missing[0]}'`
360
+ : `${file} imports '${missing[0]}', which no node_modules above it provides`
283
361
  // Under Yarn Plug'n'Play the dependencies ARE installed, into a
284
362
  // `.pnp.cjs` Bun does not read: "install them first" sent the user to a
285
363
  // `yarn install` that changed nothing (D-109).
286
- if (pnpAbove(path.dirname(configPath))) {
364
+ if (pnpAbove(path.dirname(file))) {
287
365
  throw new UserError(
288
- `${kind} config ${configPath}: cannot find '${missing[0]}' — Yarn Plug'n'Play installed the workspace's dependencies into .pnp.cjs, which Bun does not read; set \`nodeLinker: node-modules\` in .yarnrc.yml and run \`yarn install\``,
366
+ `${kind} config ${configPath}: ${what} — Yarn Plug'n'Play installed the workspace's dependencies into .pnp.cjs, which Bun does not read; set \`nodeLinker: node-modules\` in .yarnrc.yml and run \`yarn install\``,
289
367
  )
290
368
  }
291
369
  throw new UserError(
292
- `${kind} config ${configPath}: cannot find '${missing[0]}' — no node_modules above the config provides it; install the workspace's dependencies first`,
370
+ `${kind} config ${configPath}: ${what}${own ? ' — no node_modules above the config provides it' : ''}; install the workspace's dependencies first`,
293
371
  )
294
372
  }
295
373
 
374
+ const RELATIVE_IMPORT = /["']\.\.?\//
375
+ const SCRIPT = /\.[cm]?[jt]sx?$/
376
+
377
+ /**
378
+ * The same refusal for each script the config reaches by relative import:
379
+ * a preset's bare import was installed and run where the config's own was
380
+ * refused (L-22; `--no-install` holds only the shebang, not `bun src/bin.ts`
381
+ * or an embedder). Only a source evaluation pays: a fresh bundle resolved
382
+ * 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.
384
+ */
385
+ async function refuseUnprovidedClosure(
386
+ source: string,
387
+ configPath: string,
388
+ kind: string,
389
+ reads: Map<string, Promise<string>>,
390
+ ): Promise<void> {
391
+ if (!RELATIVE_IMPORT.test(source)) return
392
+ const read = (file: string): Promise<string> => {
393
+ let text = reads.get(file)
394
+ if (text === undefined) reads.set(file, (text = Bun.file(file).text()))
395
+ return text
396
+ }
397
+ for (const file of await configImports(configPath, source, read)) {
398
+ if (!SCRIPT.test(file)) continue
399
+ const text = await read(file).catch(() => null)
400
+ if (text !== null) refuseUnprovided(text, file, configPath, kind)
401
+ }
402
+ }
403
+
296
404
  /**
297
405
  * Where a transpile error was found. Bun's `BuildMessage` carries it as
298
406
  * `position`; the config worker forwards the same three fields (see
@@ -374,16 +482,87 @@ export function configLoadError(err: unknown, configPath: string, kind: string):
374
482
  return null
375
483
  }
376
484
 
377
- /** What a failed load throws: Bun's own errors and non-Error throws as user errors, the rest as thrown. */
485
+ /**
486
+ * What a failed load throws: always a user error. A config's own throw, or
487
+ * a plugin factory's it called, is the config's to fix, not a vx defect:
488
+ * passed through, it printed a stack under VX_E_INTERNAL, whose fix is to
489
+ * report a vx bug (2026-10-28). Its line stands in for the stack.
490
+ */
378
491
  function loadFailure(err: unknown, configPath: string, kind: string): unknown {
379
492
  const user = configLoadError(err, configPath, kind)
380
493
  if (user !== null) return user
494
+ if (isUserError(err)) return err
381
495
  if (err !== null && typeof err === 'object' && typeof (err as Error).message === 'string') {
382
- return err
496
+ const user = new UserError(
497
+ `${kind} config ${configPath} threw: ${thrownLine(err as Error, configPath)}${causeLines(err as Error, configPath)}`,
498
+ 'VX_E_CONFIG',
499
+ )
500
+ // The thrown error, stack and all, for an embedder of the loader.
501
+ user.cause = err
502
+ return user
383
503
  }
384
504
  return new UserError(thrownValueMessage(kind, configPath, Bun.inspect(err, { compact: true })))
385
505
  }
386
506
 
507
+ const VX_SRC = `${path.resolve(import.meta.dir, '..')}${path.sep}`
508
+
509
+ /** A thrown error's message and where it was thrown. */
510
+ function thrownLine(err: Error, configPath: string): string {
511
+ const stack = String(err.stack ?? '')
512
+ // A cause rebuilt from the config worker carries its stack alone.
513
+ const message =
514
+ err.message !== ''
515
+ ? err.message
516
+ : (/^[\w$.]+: (.*)$/.exec(stack.split('\n')[0] ?? '')?.[1] ?? stack.split('\n')[0] ?? '')
517
+ const at = throwSite(stack, configPath)
518
+ return `${message}${at === undefined ? '' : ` (at ${at})`}`
519
+ }
520
+
521
+ /**
522
+ * Each `cause` under a wrapped throw (`new Error(msg, { cause })`), which
523
+ * holds the reason the wrapper names only in its own words. Bounded, and a
524
+ * cause seen before ends the chain.
525
+ */
526
+ function causeLines(err: Error, configPath: string): string {
527
+ let out = ''
528
+ const seen = new Set<unknown>([err])
529
+ for (
530
+ let c = err.cause, depth = 0;
531
+ c !== undefined && c !== null && !seen.has(c) && depth < 8;
532
+ c = c instanceof Error ? c.cause : undefined, depth++
533
+ ) {
534
+ seen.add(c)
535
+ out += `; caused by: ${c instanceof Error ? thrownLine(c, configPath) : Bun.inspect(c, { compact: true })}`
536
+ }
537
+ return out
538
+ }
539
+
540
+ /**
541
+ * Where a throw came from: its first frame that names a file, and the
542
+ * config's own line that led there when that is another.
543
+ */
544
+ function throwSite(stack: string, configPath: string): string | undefined {
545
+ // vx's own frames (the process.exit guard) are not where the config went wrong.
546
+ const sites = [...stack.matchAll(/^\s+at (?:.*\()?(\/[^()\n]+:\d+:\d+)\)?$/gm)]
547
+ .map((m) => m[1]!.replace(BUST_QUERY, ''))
548
+ .filter((s) => !s.startsWith(VX_SRC))
549
+ const own = sites.find((s) => s.startsWith(`${configPath}:`))
550
+ if (sites[0] === undefined || own === undefined || own === sites[0]) return sites[0] ?? own
551
+ return `${sites[0]}, from ${own}`
552
+ }
553
+
554
+ /**
555
+ * More than one config refused in one load: the first is the error, and
556
+ * `all` names each. A rename after an upgrade refuses every config at once,
557
+ * and naming one a run made a 200-config repo take 200 runs.
558
+ */
559
+ export class ConfigsRefused extends UserError {
560
+ constructor(readonly all: readonly UserError[]) {
561
+ super(all[0]!.message, all[0]!.code)
562
+ if (all[0]!.parts !== undefined) this.parts = all[0]!.parts
563
+ }
564
+ }
565
+
387
566
  /**
388
567
  * Evaluate many configs at once: every file's bytes in parallel, then each
389
568
  * one evaluated (in the order given, so a failure names the first broken
@@ -409,6 +588,7 @@ export async function loadProjectConfigs(configPaths: readonly string[]): Promis
409
588
  // More than one evaluation in flight: a change seen after one load may
410
589
  // be another's.
411
590
  let overlapping = false
591
+ const reads = new Map<string, Promise<string>>()
412
592
  const loadOne = async (entry: (typeof prepared)[number]): Promise<ProjectConfig> => {
413
593
  const { configPath, bytes } = entry
414
594
  // A REPEAT load in this process re-evaluates in a worker, because the
@@ -416,12 +596,16 @@ export async function loadProjectConfigs(configPaths: readonly string[]): Promis
416
596
  // config-eval.ts. A FIRST load keeps the in-process import.
417
597
  const repeat = loadedConfigs.has(configPath)
418
598
  loadedConfigs.add(configPath)
419
- if (repeat) refuseUnprovidedImports(bytes, configPath, 'Project')
599
+ if (repeat) {
600
+ const text = new TextDecoder().decode(bytes)
601
+ refuseUnprovided(text, configPath, configPath, 'Project')
602
+ await refuseUnprovidedClosure(text, configPath, 'Project', reads)
603
+ }
420
604
  const mod = repeat
421
605
  ? await evaluateConfigFresh(configPath).catch((err: unknown) => {
422
606
  throw loadFailure(err, configPath, 'Project')
423
607
  })
424
- : await loadDefaultExport(configPath, 'Project', bytes)
608
+ : await loadDefaultExport(configPath, 'Project', bytes, undefined, reads)
425
609
  // Before anything reads through them: a replaced `Array.prototype.includes`
426
610
  // turned the JSON-data walk's own check into "a cyclic reference".
427
611
  const changed = repeat
@@ -513,7 +697,14 @@ export async function loadProjectConfigs(configPaths: readonly string[]): Promis
513
697
  }
514
698
  throw builtinsChanged(changedInRound)
515
699
  }
516
- if (first !== undefined) throw first.failed
700
+ if (first !== undefined) {
701
+ const refused = results.flatMap((r) =>
702
+ 'failed' in r && isUserError(r.failed) ? [r.failed] : [],
703
+ )
704
+ throw refused.length > 1 && refused[0] === first.failed
705
+ ? new ConfigsRefused(refused)
706
+ : first.failed
707
+ }
517
708
  // One load in the round, in the worker (a repeat load), is the one
518
709
  // that moved it: the worker no longer reads the umask to say so.
519
710
  if (changed.length > 0)
@@ -543,8 +734,7 @@ let undoRedirect = (): void => {}
543
734
  /**
544
735
  * Bun's own console, taken at load: the console a round finds may be a
545
736
  * constructed one (a plugin's verb may install its own), which has no
546
- * `Console`. The global's, not `node:console`'s: the playground bundles
547
- * this module for the browser, where that specifier is a stub.
737
+ * `Console`.
548
738
  */
549
739
  const bunConsole = globalThis.console as typeof console & { Console?: typeof console.Console }
550
740
  let stderrConsole: typeof console | undefined
@@ -634,9 +824,7 @@ function installRedirect(): () => void {
634
824
  */
635
825
  // `Bun` and `Bun.hash` are what vx itself runs on: `Bun.hash.xxHash3 = ()
636
826
  // => 7n` gave every task the key 00000000, and a changed command replayed
637
- // the old output (D-75). `Bun` is read by its identifier, not through
638
- // globalThis: the docs playground bundles this file with the identifier
639
- // rewritten to its shim, and a browser has no global `Bun`.
827
+ // the old output (D-75).
640
828
  const WATCHED_BUILTINS: ReadonlyArray<readonly [string, object]> = WATCHED_BUILTIN_NAMES.map(
641
829
  (name) =>
642
830
  [
@@ -694,15 +882,18 @@ const BUN_MEMBERS_VX_READS: readonly PropertyKey[] = [
694
882
  'Glob',
695
883
  'JSONC',
696
884
  'Subprocess',
885
+ 'TOML',
697
886
  'Transpiler',
698
887
  'build',
699
888
  'color',
889
+ 'connect',
700
890
  'deepEquals',
701
891
  'env',
702
892
  'file',
703
893
  'gunzipSync',
704
894
  'hash',
705
895
  'inspect',
896
+ 'listen',
706
897
  'main',
707
898
  'nanoseconds',
708
899
  'pathToFileURL',
@@ -711,6 +902,7 @@ const BUN_MEMBERS_VX_READS: readonly PropertyKey[] = [
711
902
  'resolveSync',
712
903
  'revision',
713
904
  'secrets',
905
+ 'semver',
714
906
  'serve',
715
907
  'sleep',
716
908
  'sleepSync',
@@ -902,8 +1094,6 @@ function restoreEnv(before: Readonly<Record<string, string | undefined>>): strin
902
1094
  * choice (D-120). Put back, and named as the change.
903
1095
  */
904
1096
  function restoreCwd(before: string): string[] {
905
- // Through globalThis: the playground bundles this module, and its
906
- // browser shim carries no free process global.
907
1097
  const proc = globalThis.process
908
1098
  if (before === '' || proc.cwd() === before) return []
909
1099
  proc.chdir(before)
@@ -916,7 +1106,6 @@ function restoreCwd(before: string): string[] {
916
1106
  * a user other than root could not read back (D-125). Put back, and named.
917
1107
  */
918
1108
  function currentUmask(): number {
919
- // Through globalThis, as restoreCwd: the playground's shim has no umask.
920
1109
  const proc = globalThis.process as { umask?: (mask?: number) => number }
921
1110
  if (typeof proc.umask !== 'function') return -1
922
1111
  // Bun reads it by setting 0 and putting it back (probed on 1.4.2: four
@@ -1,7 +1,7 @@
1
1
  // Which repository a workspace is, so its checkouts share one store under
2
2
  // `~/.vx/<id>` and other repositories keep theirs: Nx 23.2's
3
3
  // `~/.nx/<id>` (`utils/workspace-id.js`, `utils/git-utils.js`), ported
4
- // rule for rule (owner, 2026-10-06: "do exactly like nx"), minus the Nx
4
+ // rule for rule (2026-10-06: "do exactly like nx"), minus the Nx
5
5
  // Cloud id. Read from the `.git` files; git is asked only when they cannot
6
6
  // settle it (no parseable remote, an `include`, an `insteadOf`).
7
7