@vzn/vx 0.0.645 → 0.0.648

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 (186) hide show
  1. package/README.md +39 -50
  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 +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  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 +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -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/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -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,
@@ -14,6 +14,7 @@ import {
14
14
  } from './config-eval.js'
15
15
  import { configImports, hasEsmExport, unprovidedBareImports } from './config-imports.js'
16
16
  import { readOnce, unreadable } from './load-reads.js'
17
+ import { buildCompiled, loadCompiled, loadedModules } from './config-bytecode.js'
17
18
 
18
19
  // The validator lives in config-schema.ts; re-exported so a reader that
19
20
  // reaches the loader for it (the tests do) keeps working.
@@ -23,7 +24,7 @@ export { validateProjectConfig }
23
24
  import { WORKSPACE_CONFIG_FILENAMES, workspaceFile, workspaceRootOf } from '../handoff.js'
24
25
  export { WORKSPACE_CONFIG_FILENAMES }
25
26
 
26
- /** What a function default export is told; the playground says the same. */
27
+ /** What a function default export is told. */
27
28
  const EXPORTED_A_FUNCTION =
28
29
  'it exports a function, and vx reads the object itself — export what the function returns'
29
30
 
@@ -91,19 +92,72 @@ function serveHeldSources(): void {
91
92
  contents: heldSources.get(HELD_QUERY.exec(args.path)![1]!)!.source,
92
93
  loader: /\.[cm]?ts\?/.test(args.path) ? 'ts' : 'js',
93
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
+ })
94
114
  },
95
115
  })
96
116
  }
97
117
 
98
- function holdSource(hash: string, source: string): void {
118
+ function holdSource(hash: string, source: string, plain: Held | null, configPath: string): void {
99
119
  const held = heldSources.get(hash)
100
120
  if (held === undefined) heldSources.set(hash, { source, uses: 1 })
101
121
  else held.uses++
122
+ if (plain !== null) heldPaths.set(configPath, plain)
102
123
  }
103
124
 
104
- function releaseSource(hash: string): void {
125
+ function releaseSource(hash: string, plain: Held | null, configPath: string): void {
105
126
  const held = heldSources.get(hash)!
106
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
107
161
  }
108
162
 
109
163
  const utf8 = new TextDecoder('utf-8', { fatal: true })
@@ -142,8 +196,13 @@ async function loadDefaultExport(
142
196
  configPath: string,
143
197
  kind: string,
144
198
  bytes: Uint8Array,
199
+ /** The workspace root, where the load may run the config's bytecode bundle (config-bytecode.ts). */
200
+ compiledRoot?: string,
201
+ /** File texts read for the refusal, kept across one load's configs. */
202
+ reads = new Map<string, Promise<string>>(),
145
203
  ): Promise<unknown> {
146
- refuseUnprovidedImports(bytes, configPath, kind)
204
+ const text = new TextDecoder().decode(bytes)
205
+ refuseUnprovided(text, configPath, configPath, kind)
147
206
  // No random bust for `vx lock`: a project config reaches this import
148
207
  // only on its FIRST load in the process, so nothing is cached under the
149
208
  // URL yet, and a repeat load (the one that could replay an evaluation
@@ -160,21 +219,43 @@ async function loadDefaultExport(
160
219
  )
161
220
  }
162
221
  const hash = xxh3hex(bytes)
222
+ const plain: Held | null =
223
+ source !== null && !literal && PLAIN_CONFIG.test(configPath) ? { hash, served: false } : null
163
224
  const specifier = `${configPath}?vx-${literal ? 'literal' : source !== null ? 'held' : 'bust'}=${hash}`
164
225
  if (source !== null) {
165
226
  serveHeldSources()
166
- holdSource(hash, source)
227
+ holdSource(hash, source, plain, configPath)
167
228
  }
168
229
  let ns: { default?: unknown }
230
+ let compiled: { default?: unknown } | null | undefined
231
+ let loaded: Set<string> | undefined
169
232
  const unguard = guardExit()
170
233
  let deadline: ReturnType<typeof setTimeout> | undefined
171
234
  try {
235
+ // A bundle that throws is evaluated from source below, so the error
236
+ // the user sees is the source's.
237
+ compiled =
238
+ literal || compiledRoot === undefined ? null : tryCompiled(compiledRoot, configPath, bytes)
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)
243
+ if (compiled === undefined) loaded = loadedModules(compiledRoot!)
172
244
  // The worker's budget, here too: a top-level await that never settled
173
245
  // while a timer kept the loop alive hung `vx run` for good, silently
174
246
  // (D-66).
247
+ await refuseUnprovidedClosure(text, configPath, kind, reads)
175
248
  const budget = evalBudgetMs()
176
249
  ns = (await Promise.race([
177
- 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
+ ),
178
259
  new Promise<never>((_, reject) => {
179
260
  deadline = setTimeout(
180
261
  () =>
@@ -189,20 +270,48 @@ async function loadDefaultExport(
189
270
  ])) as { default?: unknown }
190
271
  } catch (err) {
191
272
  // A served module's frames name its specifier; the user wrote the path.
192
- if (err instanceof Error && err.stack !== undefined) {
193
- 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, '')
194
278
  }
195
279
  throw loadFailure(err, configPath, kind)
196
280
  } finally {
197
281
  clearTimeout(deadline)
198
282
  unguard()
199
- if (source !== null) releaseSource(hash)
283
+ if (source !== null) releaseSource(hash, plain, configPath)
284
+ }
285
+ const mod = defaultOf(ns, kind, configPath)
286
+ if (compiledRoot !== undefined && loaded !== undefined) {
287
+ await buildCompiled(compiledRoot, configPath, bytes, loaded, ns)
288
+ }
289
+ try {
290
+ return await mod
291
+ } catch (err) {
292
+ throw loadFailure(err, configPath, kind)
200
293
  }
294
+ }
295
+
296
+ function defaultOf(ns: { default?: unknown }, kind: string, configPath: string): unknown {
201
297
  const mod = ns?.default
202
298
  assertDefaultObject(mod, kind, configPath)
203
299
  return mod
204
300
  }
205
301
 
302
+ /** The bundle's namespace; `undefined` to build one after this load, `null` when none can serve. */
303
+ function tryCompiled(
304
+ root: string,
305
+ configPath: string,
306
+ bytes: Uint8Array,
307
+ ): { default?: unknown } | null | undefined {
308
+ try {
309
+ return loadCompiled(root, configPath, bytes)
310
+ } catch {
311
+ return null
312
+ }
313
+ }
314
+
206
315
  /**
207
316
  * A config's `process.exit` ended vx mid-load: `exit(0)` was a green run
208
317
  * that ran nothing and printed nothing (D-65). While any config evaluates
@@ -238,29 +347,60 @@ function pnpAbove(dir: string): boolean {
238
347
  * evaluation: left to Bun, a workspace with no `node_modules` would have
239
348
  * the package auto-installed from the registry first (config-imports.ts),
240
349
  * and a config must never download. The message keeps the shape Bun's own
241
- * refusal has, with the remedy.
350
+ * refusal has, with the remedy. `source` is `file`'s: the config's own,
351
+ * or one it reaches.
242
352
  */
243
- function refuseUnprovidedImports(bytes: Uint8Array, configPath: string, kind: string): void {
244
- const loader = /\.[cm]?ts$/.test(configPath) ? 'ts' : 'js'
245
- const missing = unprovidedBareImports(
246
- new TextDecoder().decode(bytes),
247
- path.dirname(configPath),
248
- loader,
249
- )
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)
250
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`
251
361
  // Under Yarn Plug'n'Play the dependencies ARE installed, into a
252
362
  // `.pnp.cjs` Bun does not read: "install them first" sent the user to a
253
363
  // `yarn install` that changed nothing (D-109).
254
- if (pnpAbove(path.dirname(configPath))) {
364
+ if (pnpAbove(path.dirname(file))) {
255
365
  throw new UserError(
256
- `${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\``,
257
367
  )
258
368
  }
259
369
  throw new UserError(
260
- `${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`,
261
371
  )
262
372
  }
263
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
+
264
404
  /**
265
405
  * Where a transpile error was found. Bun's `BuildMessage` carries it as
266
406
  * `position`; the config worker forwards the same three fields (see
@@ -342,16 +482,87 @@ export function configLoadError(err: unknown, configPath: string, kind: string):
342
482
  return null
343
483
  }
344
484
 
345
- /** 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
+ */
346
491
  function loadFailure(err: unknown, configPath: string, kind: string): unknown {
347
492
  const user = configLoadError(err, configPath, kind)
348
493
  if (user !== null) return user
494
+ if (isUserError(err)) return err
349
495
  if (err !== null && typeof err === 'object' && typeof (err as Error).message === 'string') {
350
- 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
351
503
  }
352
504
  return new UserError(thrownValueMessage(kind, configPath, Bun.inspect(err, { compact: true })))
353
505
  }
354
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
+
355
566
  /**
356
567
  * Evaluate many configs at once: every file's bytes in parallel, then each
357
568
  * one evaluated (in the order given, so a failure names the first broken
@@ -377,6 +588,7 @@ export async function loadProjectConfigs(configPaths: readonly string[]): Promis
377
588
  // More than one evaluation in flight: a change seen after one load may
378
589
  // be another's.
379
590
  let overlapping = false
591
+ const reads = new Map<string, Promise<string>>()
380
592
  const loadOne = async (entry: (typeof prepared)[number]): Promise<ProjectConfig> => {
381
593
  const { configPath, bytes } = entry
382
594
  // A REPEAT load in this process re-evaluates in a worker, because the
@@ -384,12 +596,16 @@ export async function loadProjectConfigs(configPaths: readonly string[]): Promis
384
596
  // config-eval.ts. A FIRST load keeps the in-process import.
385
597
  const repeat = loadedConfigs.has(configPath)
386
598
  loadedConfigs.add(configPath)
387
- 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
+ }
388
604
  const mod = repeat
389
605
  ? await evaluateConfigFresh(configPath).catch((err: unknown) => {
390
606
  throw loadFailure(err, configPath, 'Project')
391
607
  })
392
- : await loadDefaultExport(configPath, 'Project', bytes)
608
+ : await loadDefaultExport(configPath, 'Project', bytes, undefined, reads)
393
609
  // Before anything reads through them: a replaced `Array.prototype.includes`
394
610
  // turned the JSON-data walk's own check into "a cyclic reference".
395
611
  const changed = repeat
@@ -481,7 +697,14 @@ export async function loadProjectConfigs(configPaths: readonly string[]): Promis
481
697
  }
482
698
  throw builtinsChanged(changedInRound)
483
699
  }
484
- 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
+ }
485
708
  // One load in the round, in the worker (a repeat load), is the one
486
709
  // that moved it: the worker no longer reads the umask to say so.
487
710
  if (changed.length > 0)
@@ -509,12 +732,52 @@ export async function loadProjectConfigs(configPaths: readonly string[]): Promis
509
732
  let redirectHolders = 0
510
733
  let undoRedirect = (): void => {}
511
734
  /**
512
- * Bun's own `Console`, taken at load: the console a round finds may be a
513
- * constructed one (a plugin's verb may install its own), which has no `Console`.
514
- * The global's, not `node:console`'s: the playground bundles this module
515
- * for the browser, where that specifier is a stub.
735
+ * Bun's own console, taken at load: the console a round finds may be a
736
+ * constructed one (a plugin's verb may install its own), which has no
737
+ * `Console`.
516
738
  */
517
- const BunConsole = (globalThis.console as { Console?: typeof console.Console }).Console
739
+ const bunConsole = globalThis.console as typeof console & { Console?: typeof console.Console }
740
+ let stderrConsole: typeof console | undefined
741
+ const BUILT_ON_READ: ReadonlySet<PropertyKey> = new Set(['Console', '_stdout', '_stderr'])
742
+
743
+ /**
744
+ * A console that writes to stderr, built on the first call: reading
745
+ * `Console` loads `node:console`, ~4 ms of a warm run, and the workspace
746
+ * config each run loads in a redirect seldom prints.
747
+ */
748
+ function redirectedConsole(): typeof console {
749
+ const real = (): Record<PropertyKey, (...a: unknown[]) => unknown> =>
750
+ (stderrConsole ??= Object.assign(new bunConsole.Console!(process.stderr, process.stderr), {
751
+ // Bun's console adds `write`, which a constructed Console lacks.
752
+ write: (...data: string[]) => {
753
+ const text = data.join('')
754
+ process.stderr.write(text)
755
+ return text.length
756
+ },
757
+ })) as unknown as Record<PropertyKey, (...a: unknown[]) => unknown>
758
+ const facade = {}
759
+ for (const key of [...Reflect.ownKeys(bunConsole), 'write']) {
760
+ // Read through the real one when read: even their descriptors build a
761
+ // module or a stream (~17 ms together, Bun 1.4.2).
762
+ if (BUILT_ON_READ.has(key)) {
763
+ Object.defineProperty(facade, key, { get: () => real()[key], configurable: true })
764
+ continue
765
+ }
766
+ const own = Object.getOwnPropertyDescriptor(bunConsole, key)
767
+ Object.defineProperty(
768
+ facade,
769
+ key,
770
+ own === undefined || typeof own.value === 'function'
771
+ ? {
772
+ value: (...args: unknown[]) => real()[key]!(...args),
773
+ writable: true,
774
+ configurable: true,
775
+ }
776
+ : own,
777
+ )
778
+ }
779
+ return facade as typeof console
780
+ }
518
781
 
519
782
  function stdoutToStderr(): () => void {
520
783
  if (redirectHolders++ === 0) undoRedirect = installRedirect()
@@ -540,14 +803,7 @@ function installRedirect(): () => void {
540
803
  ;(Bun.stdout as { writer: typeof Bun.stdout.writer }).writer = ((
541
804
  ...args: Parameters<typeof Bun.stderr.writer>
542
805
  ) => Bun.stderr.writer(...args)) as typeof Bun.stdout.writer
543
- // Bun's console adds `write`, which a constructed Console lacks.
544
- globalThis.console = Object.assign(new BunConsole!(process.stderr, process.stderr), {
545
- write: (...data: string[]) => {
546
- const text = data.join('')
547
- process.stderr.write(text)
548
- return text.length
549
- },
550
- })
806
+ globalThis.console = redirectedConsole()
551
807
  return () => {
552
808
  if (ownWrite === undefined) delete (out as { write?: unknown }).write
553
809
  else Object.defineProperty(out, 'write', ownWrite)
@@ -568,9 +824,7 @@ function installRedirect(): () => void {
568
824
  */
569
825
  // `Bun` and `Bun.hash` are what vx itself runs on: `Bun.hash.xxHash3 = ()
570
826
  // => 7n` gave every task the key 00000000, and a changed command replayed
571
- // the old output (D-75). `Bun` is read by its identifier, not through
572
- // globalThis: the docs playground bundles this file with the identifier
573
- // rewritten to its shim, and a browser has no global `Bun`.
827
+ // the old output (D-75).
574
828
  const WATCHED_BUILTINS: ReadonlyArray<readonly [string, object]> = WATCHED_BUILTIN_NAMES.map(
575
829
  (name) =>
576
830
  [
@@ -622,25 +876,33 @@ interface BuiltinSnapshot {
622
876
  */
623
877
  const BUN_MEMBERS_VX_READS: readonly PropertyKey[] = [
624
878
  'Archive',
879
+ 'BuildMetafile',
625
880
  'BunFile',
626
881
  'CryptoHasher',
627
882
  'Glob',
628
883
  'JSONC',
629
884
  'Subprocess',
885
+ 'TOML',
630
886
  'Transpiler',
887
+ 'build',
631
888
  'color',
889
+ 'connect',
632
890
  'deepEquals',
633
891
  'env',
634
892
  'file',
635
893
  'gunzipSync',
636
894
  'hash',
637
895
  'inspect',
896
+ 'listen',
638
897
  'main',
639
898
  'nanoseconds',
899
+ 'pathToFileURL',
640
900
  'plugin',
641
901
  'randomUUIDv7',
642
902
  'resolveSync',
903
+ 'revision',
643
904
  'secrets',
905
+ 'semver',
644
906
  'serve',
645
907
  'sleep',
646
908
  'sleepSync',
@@ -832,8 +1094,6 @@ function restoreEnv(before: Readonly<Record<string, string | undefined>>): strin
832
1094
  * choice (D-120). Put back, and named as the change.
833
1095
  */
834
1096
  function restoreCwd(before: string): string[] {
835
- // Through globalThis: the playground bundles this module, and its
836
- // browser shim carries no free process global.
837
1097
  const proc = globalThis.process
838
1098
  if (before === '' || proc.cwd() === before) return []
839
1099
  proc.chdir(before)
@@ -846,7 +1106,6 @@ function restoreCwd(before: string): string[] {
846
1106
  * a user other than root could not read back (D-125). Put back, and named.
847
1107
  */
848
1108
  function currentUmask(): number {
849
- // Through globalThis, as restoreCwd: the playground's shim has no umask.
850
1109
  const proc = globalThis.process as { umask?: (mask?: number) => number }
851
1110
  if (typeof proc.umask !== 'function') return -1
852
1111
  // Bun reads it by setting 0 and putting it back (probed on 1.4.2: four
@@ -959,7 +1218,15 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
959
1218
  const umask = currentUmask()
960
1219
  let mod: WorkspaceConfig
961
1220
  try {
962
- mod = (await loadDefaultExport(configPath, 'Workspace', bytes)) as WorkspaceConfig
1221
+ mod = (await loadDefaultExport(
1222
+ configPath,
1223
+ 'Workspace',
1224
+ bytes,
1225
+ // Every load, not only the first: a repeat load (the run after the
1226
+ // CLI's selection pass) must get the same plugin instances, and a
1227
+ // required bundle is the cached module.
1228
+ path.resolve(root),
1229
+ )) as WorkspaceConfig
963
1230
  } finally {
964
1231
  const changed = [
965
1232
  ...restoreBuiltins(builtins),
@@ -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