@vzn/vx 0.0.21 → 0.0.23

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 (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +622 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. package/src/workspace/workspace.ts +173 -23
@@ -1,3 +1,4 @@
1
+ import path from 'node:path'
1
2
  import type { ProjectMeta } from './workspace.js'
2
3
 
3
4
  export interface PackageGraph {
@@ -23,7 +24,12 @@ export function buildPackageGraph(
23
24
  taskEdges?: ReadonlyMap<string, readonly string[]>,
24
25
  ): PackageGraph {
25
26
  const byName = new Map<string, ProjectMeta>()
26
- for (const p of projects) byName.set(p.name, p)
27
+ const byDir = new Map<string, ProjectMeta>()
28
+ for (const p of projects) {
29
+ byName.set(p.name, p)
30
+ byDir.set(path.resolve(p.dir), p)
31
+ }
32
+ const linked = linkedTarget(byName, byDir)
27
33
 
28
34
  // Two adjacencies. ORDER (`directDeps`, the `^task` walk) is what the
29
35
  // package imports at build time: dependencies, devDependencies,
@@ -42,8 +48,17 @@ export function buildPackageGraph(
42
48
  // reads no peers, runs it; an unconditional order edge made `^build`
43
49
  // a task cycle, 2026-09-11), so a peer edge that would close a cycle
44
50
  // through the order graph is reach only: the consumer above provides
45
- // that peer. Peers are tried in (package, peer) name order, so which
46
- // edge of a two-peer cycle stays is stable across runs.
51
+ // that peer.
52
+ //
53
+ // Which edge of a MUTUAL two-peer cycle stays is stable across runs,
54
+ // and the PACKAGE sort below is what makes it so: the first package
55
+ // in name order keeps its edge, whatever order the projects arrive
56
+ // in. The per-package peer sort provides none of that — measured
57
+ // (item 571), both manifest orders give identical graphs in every
58
+ // arrangement tried, because `reaches(peer, p)` walks edges INTO `p`
59
+ // and adding an edge OUT of `p` cannot change it. It stays as cheap
60
+ // insurance against a future rule that does depend on it; it is not
61
+ // what the stability rests on.
47
62
  const directDeps = new Map<string, string[]>()
48
63
  const reachDeps = new Map<string, string[]>()
49
64
  const order = new Map<string, Set<string>>()
@@ -54,22 +69,41 @@ export function buildPackageGraph(
54
69
  for (const name of taskEdges?.get(p.name) ?? []) {
55
70
  if (name !== p.name && byName.has(name)) own.add(name)
56
71
  }
57
- const add = (
58
- field: 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies',
59
- into: Set<string>,
60
- ) => {
61
- const obj = p.packageJson[field]
62
- if (!obj) return
63
- for (const name of Object.keys(obj)) {
64
- if (name !== p.name && byName.has(name)) into.add(name)
65
- }
72
+ const optional = bucket(p.packageJson.optionalDependencies)
73
+ const prod = bucket(p.packageJson.dependencies)
74
+ const dev = bucket(p.packageJson.devDependencies)
75
+ const add = (key: string, spec: unknown, installed: boolean, into: Set<string>): void => {
76
+ if (typeof spec !== 'string') return
77
+ const target = linked(key, spec.trim(), p.dir, installed)
78
+ if (target !== undefined && target !== p.name) into.add(target)
66
79
  }
67
- add('dependencies', own)
68
- add('devDependencies', own)
69
- add('optionalDependencies', own)
80
+ // Which entry is installed when a key sits in more than one field,
81
+ // measured with bun 1.4, npm 10, yarn 1 and pnpm 12 (2026-09-24): bun
82
+ // and npm take devDependencies, then optionalDependencies, then
83
+ // dependencies; yarn and pnpm take optionalDependencies, then
84
+ // dependencies, then devDependencies. vx cannot tell which manager
85
+ // installed the tree, so either winner linking is an edge: a spare
86
+ // edge costs order, a missing one a stale hit. A dev or optional
87
+ // entry wins under one order or the other; a prod entry loses to an
88
+ // optional one under both.
89
+ for (const key in dev) add(key, dev[key], true, own)
90
+ for (const key in optional) add(key, optional[key], true, own)
91
+ for (const key in prod) if (!Object.hasOwn(optional, key)) add(key, prod[key], true, own)
70
92
  order.set(p.name, own)
93
+ // A peer is not installed by the package that declares it: when an
94
+ // installed entry names the same key, that entry is what resolves
95
+ // (turborepo#12640: a registry `buffer@^6` dev dependency beside a
96
+ // `workspace:*` peer on it installs the registry copy in all four).
97
+ // Alone, a peer on a sibling resolves to the sibling whatever its
98
+ // range, since bun and yarn hoist the workspace copy (npm refuses an
99
+ // unmet one; pnpm 12 fetched it from the registry).
71
100
  const peer = new Set<string>()
72
- add('peerDependencies', peer)
101
+ const peerDeps = bucket(p.packageJson.peerDependencies)
102
+ for (const key in peerDeps) {
103
+ if (!Object.hasOwn(optional, key) && !Object.hasOwn(prod, key) && !Object.hasOwn(dev, key)) {
104
+ add(key, peerDeps[key], false, peer)
105
+ }
106
+ }
73
107
  peers.set(p.name, peer)
74
108
  }
75
109
  // `to` reaches `from` through the order edges so far ⇒ from → to
@@ -219,3 +253,107 @@ export function buildPackageGraph(
219
253
  transitiveDependents: makeAccessor(directDependents),
220
254
  }
221
255
  }
256
+
257
+ function bucket(field: unknown): Readonly<Record<string, unknown>> {
258
+ return typeof field === 'object' && field !== null ? (field as Record<string, unknown>) : {}
259
+ }
260
+
261
+ // The ranges a manifest carries, in the part of npm's grammar (node-semver)
262
+ // where `Bun.semver.satisfies` answers as npm does: `||`-joined sets, each
263
+ // a hyphen range, one comparator, or two or more `<`/`>` bounds, over
264
+ // versions whose wildcards trail (`1.x`, `1.2.*`). Bun 1.4.2 answers true
265
+ // for text that is no range at all (`latest`, `npm:foo@1`, `github:a/b`,
266
+ // `../x`), ORs bare comparators npm ANDs (`1 2`), and reads `>x` and a
267
+ // leading wildcard (`x.3.1`) its own way; a spec outside this grammar
268
+ // counts as unmet. One corner inside it still differs (item 831): a
269
+ // prerelease at a `<` bound over a partial (`<3.x`, `<3.1`) or at a hyphen
270
+ // range's partial end, beside a lower bound naming that prerelease's own
271
+ // version — `3.0.0-beta` against `>=3.0.0-alpha <3.x` — Bun admits and npm
272
+ // does not. bun installs by the first answer and the others by the second,
273
+ // so neither is right everywhere; the graph takes Bun's. The playground's
274
+ // port (packages/vx-docs, src/playground/shim/semver.ts) is held to Bun
275
+ // over this grammar.
276
+ const W = String.raw`[xX*]`
277
+ const PRE = String.raw`(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?`
278
+ const NUMERIC = String.raw`[vV]?\d+(?:\.${W}(?:\.${W})?|\.\d+(?:\.${W}|\.\d+${PRE})?)?`
279
+ const SINGLE = String.raw`(?:(?:[<>]=?|~>?|\^)\s*${NUMERIC}|=?\s*(?:${NUMERIC}|${W}(?:\.${W}){0,2}))`
280
+ const BOUND = String.raw`[<>]=?\s*${NUMERIC}`
281
+ const SET = String.raw`(?:${NUMERIC}\s+-\s+${NUMERIC}|${BOUND}(?:\s+${BOUND})+|${SINGLE})`
282
+ export const SEMVER_RANGE = new RegExp(String.raw`^(?:${SET}(?:\s*\|\|\s*${SET})*)?$`)
283
+ /** `<name>[@<range>]` after `npm:` or `workspace:`; the name may be scoped. */
284
+ const ALIAS = /^(@[^@/]+\/[^@/]+|[^@/.][^@/]*)(?:@(.*))?$/
285
+ const PATH_PROTOCOLS = ['file:', 'link:', 'portal:'] as const
286
+ const MAY_POINT_ELSEWHERE = /^(?:workspace:|npm:|file:|link:|portal:|\.\.?\/|\/)/
287
+
288
+ /**
289
+ * The workspace package a manifest entry resolves to, or undefined when
290
+ * the package manager installs it from elsewhere. `installed` is false
291
+ * for a peer, which the hoisted workspace copy under its key provides
292
+ * whatever the spec. The rule and its measurements are
293
+ * docs/modules/package-graph.md § Which entries are edges.
294
+ */
295
+ function linkedTarget(
296
+ byName: ReadonlyMap<string, ProjectMeta>,
297
+ byDir: ReadonlyMap<string, ProjectMeta>,
298
+ ): (key: string, spec: string, fromDir: string, installed: boolean) => string | undefined {
299
+ // A monorepo repeats a few ranges and versions thousands of times, and
300
+ // the grammar test and `Bun.semver` cost about a microsecond a call:
301
+ // unmemoised they tripled the graph build at 1000 projects × 30 deps.
302
+ const isRange = new Map<string, boolean>()
303
+ const range = (spec: string): boolean => {
304
+ let ok = isRange.get(spec)
305
+ if (ok === undefined) isRange.set(spec, (ok = SEMVER_RANGE.test(spec)))
306
+ return ok
307
+ }
308
+ const satisfiedBy = new Map<string, Map<string, boolean>>()
309
+ // A dist-tag (`latest`) is no range, and bun, npm, yarn and pnpm all
310
+ // installed it from the registry beside a matching workspace package.
311
+ const satisfies = (p: ProjectMeta, spec: string): boolean => {
312
+ if (spec === '*' || spec === '') return true
313
+ const version = p.packageJson.version
314
+ if (typeof version !== 'string' || !range(spec)) return false
315
+ let memo = satisfiedBy.get(version)
316
+ if (memo === undefined) satisfiedBy.set(version, (memo = new Map()))
317
+ let ok = memo.get(spec)
318
+ if (ok === undefined) memo.set(spec, (ok = Bun.semver.satisfies(version, spec)))
319
+ return ok
320
+ }
321
+ const named = (name: string, spec: string): string | undefined => {
322
+ const p = byName.get(name)
323
+ return p !== undefined && satisfies(p, spec) ? name : undefined
324
+ }
325
+ const atPath = (fromDir: string, rel: string): string | undefined =>
326
+ byDir.get(path.resolve(fromDir, rel))?.name
327
+ return (key, spec, fromDir, installed) => {
328
+ if (spec === 'workspace:*') return named(key, '*')
329
+ // Only a protocol or a path points a key at another package; every
330
+ // other spec (a range, a tag, a URL) is decided by the key.
331
+ if (!MAY_POINT_ELSEWHERE.test(spec)) {
332
+ const local = byName.get(key)
333
+ if (local === undefined) return undefined
334
+ // A catalog entry's range lives where the graph does not read
335
+ // (`pnpm-workspace.yaml`, bun's root `catalog`): it keeps the edge.
336
+ if (!installed || spec.startsWith('catalog:')) return key
337
+ return satisfies(local, spec) ? key : undefined
338
+ }
339
+ if (spec.startsWith('workspace:')) {
340
+ const rest = spec.slice('workspace:'.length)
341
+ if (rest === '^' || rest === '~') return named(key, '*')
342
+ if (range(rest)) return named(key, rest)
343
+ const alias = ALIAS.exec(rest)
344
+ if (alias !== null) return named(alias[1]!, alias[2] ?? '*')
345
+ return atPath(fromDir, rest)
346
+ }
347
+ if (spec.startsWith('npm:')) {
348
+ // bun links a satisfied alias; npm and yarn fetch it from the
349
+ // registry. The edge is the spare one, as above.
350
+ const alias = ALIAS.exec(spec.slice('npm:'.length))
351
+ return alias === null ? undefined : named(alias[1]!, alias[2] ?? '*')
352
+ }
353
+ for (const protocol of PATH_PROTOCOLS) {
354
+ if (spec.startsWith(protocol)) return atPath(fromDir, spec.slice(protocol.length))
355
+ }
356
+ // A bare `./`, `../` or `/` path is a directory to bun, npm and pnpm.
357
+ return atPath(fromDir, spec)
358
+ }
359
+ }
@@ -2,8 +2,10 @@ import path from 'node:path'
2
2
  import type { ProjectConfig, WorkspaceConfig } from '../config.js'
3
3
  import { UserError, xxh3hex } from '../util/index.js'
4
4
  import { validateProjectConfig, validateWorkspace } from './config-schema.js'
5
- import { evaluateConfigFresh } from './config-eval.js'
5
+ import { beginEvalRound, evaluateConfigFresh } from './config-eval.js'
6
+ import { hasEsmExport, unprovidedBareImports } from './config-imports.js'
6
7
  import { configEvalKey, configEvalKeyFromClosure, type ConfigEvalStore } from './config-cache.js'
8
+ import { readOnce } from './load-reads.js'
7
9
 
8
10
  // The validator lives in config-schema.ts; re-exported so a reader that
9
11
  // reaches the loader for it (the tests do) keeps working.
@@ -26,6 +28,80 @@ function assertDefaultObject(mod: unknown, kind: string, configPath: string): vo
26
28
  }
27
29
  }
28
30
 
31
+ /**
32
+ * The source a load already holds, by the specifier it imports it under.
33
+ * Bun's loader would open and read a project config a second time; the
34
+ * `vx-config-bytes` plugin hands it this instead, so the evaluation runs
35
+ * exactly the bytes the eval-cache key and the import gate saw. It is also
36
+ * the cheaper load: 1,000 configs imported this way took 80–100 ms against
37
+ * 180 ms read by Bun (measured 2026-09-24).
38
+ *
39
+ * Handed over as a string: `onLoad` reads a byte array as Latin-1, and a
40
+ * `ü` in a description came back as `ü`. So only UTF-8 is served, and
41
+ * only ESM (`hasEsmExport`): source handed to `onLoad` is always evaluated
42
+ * as a module, and a CommonJS config's `module.exports` would vanish. Any
43
+ * other config takes Bun's own path, which reads the file and decides its
44
+ * format as it always did. The two paths carry different queries because
45
+ * `onLoad` cannot decline a path it matched.
46
+ *
47
+ * The workspace config is never served. Proving it ESM is the parser's
48
+ * first use in a warm run, ~0.27 ms, and serving the one file saves less
49
+ * than that: `loadWorkspaceConfig` measured 0.3 ms slower served (40
50
+ * interleaved runs, 2026-09-24). Bun reads it a second time instead.
51
+ */
52
+ //
53
+ // Held by the bytes' hash, not the specifier: Bun hands `onLoad` the path
54
+ // it RESOLVED, which is the real path, so a config reached through a
55
+ // symlinked directory (macOS's `/var` → `/private/var`, every temp root
56
+ // there) came back under a key nobody set, and every such load failed.
57
+ // Equal hashes are equal sources, so two loads of the same bytes share an
58
+ // entry; `uses` keeps it until the last of them has imported.
59
+ const heldSources = new Map<string, { source: string; uses: number }>()
60
+ let serving = false
61
+
62
+ const HELD_QUERY = /\?vx-held=([0-9a-f]+)$/
63
+
64
+ function serveHeldSources(): void {
65
+ if (serving) return
66
+ serving = true
67
+ Bun.plugin({
68
+ name: 'vx-config-bytes',
69
+ setup(build) {
70
+ build.onLoad({ filter: HELD_QUERY }, (args) => ({
71
+ contents: heldSources.get(HELD_QUERY.exec(args.path)![1]!)!.source,
72
+ loader: /\.[cm]?ts\?/.test(args.path) ? 'ts' : 'js',
73
+ }))
74
+ },
75
+ })
76
+ }
77
+
78
+ function holdSource(hash: string, source: string): void {
79
+ const held = heldSources.get(hash)
80
+ if (held === undefined) heldSources.set(hash, { source, uses: 1 })
81
+ else held.uses++
82
+ }
83
+
84
+ function releaseSource(hash: string): void {
85
+ const held = heldSources.get(hash)!
86
+ if (--held.uses === 0) heldSources.delete(hash)
87
+ }
88
+
89
+ const utf8 = new TextDecoder('utf-8', { fatal: true })
90
+
91
+ /** `bytes` as the source `onLoad` may stand in for Bun's read with, or null (see `heldSources`). */
92
+ function servableSource(bytes: Uint8Array, loader: 'ts' | 'js'): string | null {
93
+ let source: string
94
+ try {
95
+ source = utf8.decode(bytes)
96
+ } catch {
97
+ return null
98
+ }
99
+ return hasEsmExport(source, loader) ? source : null
100
+ }
101
+
102
+ /** vx's module-cache query, which no user wrote: stripped from anything shown to them. */
103
+ const BUST_QUERY = /\?vx-(?:bust|held)=[^'"\s]*/g
104
+
29
105
  // Bun has native TS / ESM execution — no transpiler dep needed. We fold
30
106
  // a short content hash into the import URL as a cache-bust key so that:
31
107
  // same content → same URL → Bun's module cache hits (fast)
@@ -37,26 +113,59 @@ function assertDefaultObject(mod: unknown, kind: string, configPath: string): vo
37
113
  async function loadDefaultExport(
38
114
  configPath: string,
39
115
  kind: string,
40
- fresh = false,
41
- bytes?: Uint8Array,
116
+ bytes: Uint8Array,
42
117
  ): Promise<unknown> {
43
- bytes ??= await Bun.file(configPath).bytes()
44
- // `fresh` opts out of module-cache reuse entirely: `vx lock` and
45
- // `vx lock --check` must observe the CURRENT environment, and the
46
- // content-hash bust would replay an evaluation made under earlier
47
- // env values when the file bytes are unchanged in this process.
48
- const bust = fresh ? `${xxh3hex(bytes)}-${Bun.randomUUIDv7()}` : xxh3hex(bytes)
118
+ refuseUnprovidedImports(bytes, configPath, kind)
119
+ // No random bust for `vx lock`: a project config reaches this import
120
+ // only on its FIRST load in the process, so nothing is cached under the
121
+ // URL yet, and a repeat load (the one that could replay an evaluation
122
+ // made under earlier env values) re-evaluates in a worker instead
123
+ // (`loadedConfigs`, item 678).
124
+ const source =
125
+ kind === 'Project' ? servableSource(bytes, /\.[cm]?ts$/.test(configPath) ? 'ts' : 'js') : null
126
+ const hash = xxh3hex(bytes)
127
+ const specifier = `${configPath}?vx-${source !== null ? 'held' : 'bust'}=${hash}`
128
+ if (source !== null) {
129
+ serveHeldSources()
130
+ holdSource(hash, source)
131
+ }
49
132
  let ns: { default?: unknown }
50
133
  try {
51
- ns = (await import(`${configPath}?vx-bust=${bust}`)) as { default?: unknown }
134
+ ns = (await import(specifier)) as { default?: unknown }
52
135
  } catch (err) {
136
+ // A served module's frames name its specifier; the user wrote the path.
137
+ if (err instanceof Error && err.stack !== undefined) {
138
+ err.stack = err.stack.replaceAll(specifier, configPath).replace(BUST_QUERY, '')
139
+ }
53
140
  throw configLoadError(err, configPath, kind) ?? err
141
+ } finally {
142
+ if (source !== null) releaseSource(hash)
54
143
  }
55
144
  const mod = ns?.default
56
145
  assertDefaultObject(mod, kind, configPath)
57
146
  return mod
58
147
  }
59
148
 
149
+ /**
150
+ * A bare import nothing above the config provides is refused BEFORE the
151
+ * evaluation: left to Bun, a workspace with no `node_modules` would have
152
+ * the package auto-installed from the registry first (config-imports.ts),
153
+ * and a config must never download. The message keeps the shape Bun's own
154
+ * refusal has, with the remedy.
155
+ */
156
+ function refuseUnprovidedImports(bytes: Uint8Array, configPath: string, kind: string): void {
157
+ const loader = /\.[cm]?ts$/.test(configPath) ? 'ts' : 'js'
158
+ const missing = unprovidedBareImports(
159
+ new TextDecoder().decode(bytes),
160
+ path.dirname(configPath),
161
+ loader,
162
+ )
163
+ if (missing.length === 0) return
164
+ throw new UserError(
165
+ `${kind} config ${configPath}: cannot find '${missing[0]}' — no node_modules above the config provides it; install the workspace's dependencies first`,
166
+ )
167
+ }
168
+
60
169
  /**
61
170
  * Where a transpile error was found. Bun's `BuildMessage` carries it as
62
171
  * `position`; the config worker forwards the same three fields (see
@@ -85,18 +194,26 @@ interface BuildPosition {
85
194
  * fallback.
86
195
  */
87
196
  export function configLoadError(err: unknown, configPath: string, kind: string): UserError | null {
88
- if (!(err instanceof Error)) return null
89
- if (err.name === 'ResolveMessage') {
90
- const spec = /Cannot find (?:package|module) ['"]([^'"]+)['"]/.exec(err.message)?.[1]
91
- const what =
92
- spec === undefined ? err.message.replace(/\?vx-bust=\S+/g, '') : `cannot find '${spec}'`
197
+ // Matched on SHAPE, not `instanceof Error`. Bun's `BuildMessage` is not an
198
+ // Error subclass on every build — on 1.3.11 its prototype chain is
199
+ // `BuildMessage → Object` — and an `instanceof` guard there hands the user
200
+ // a raw transpile object for a missing brace in their own config, which is
201
+ // the defect `isFsRefusal` exists to prevent one layer down. The two names
202
+ // below are Bun's own and nothing else answers to them, so this is
203
+ // narrower than it looks: every other throw still passes through (2026-09-20).
204
+ if (err === null || typeof err !== 'object') return null
205
+ const { name, message } = err as { name?: unknown; message?: unknown }
206
+ if (typeof message !== 'string') return null
207
+ if (name === 'ResolveMessage') {
208
+ const spec = /Cannot find (?:package|module) ['"]([^'"]+)['"]/.exec(message)?.[1]
209
+ const what = spec === undefined ? message.replace(BUST_QUERY, '') : `cannot find '${spec}'`
93
210
  const hint =
94
211
  spec?.startsWith('@vzn/vx') === true
95
212
  ? `; install it in the workspace: bun add -d @vzn/vx`
96
213
  : ''
97
214
  return new UserError(`${kind} config ${configPath}: ${what}${hint}`)
98
215
  }
99
- if (err.name === 'BuildMessage') {
216
+ if (name === 'BuildMessage') {
100
217
  const pos = (err as { position?: BuildPosition | null }).position ?? {}
101
218
  const file = typeof pos.file === 'string' && pos.file.length > 0 ? pos.file : configPath
102
219
  const at =
@@ -104,13 +221,13 @@ export function configLoadError(err: unknown, configPath: string, kind: string):
104
221
  ? `:${pos.line}${typeof pos.column === 'number' ? `:${pos.column}` : ''}`
105
222
  : ''
106
223
  const where = file === configPath ? `${configPath}${at}` : `${configPath} (in ${file}${at})`
107
- return new UserError(`${kind} config ${where}: ${err.message}`)
224
+ return new UserError(`${kind} config ${where}: ${message}`)
108
225
  }
109
226
  return null
110
227
  }
111
228
 
112
229
  export interface LoadProjectConfigOptions {
113
- /** Observe the CURRENT environment: no module-cache reuse, no eval cache. */
230
+ /** Observe the CURRENT environment: no eval cache, even when `evalCache` is passed. */
114
231
  fresh?: boolean
115
232
  /**
116
233
  * Serve a provably-pure config from its cached evaluation (see
@@ -132,6 +249,11 @@ export async function loadProjectConfigs(
132
249
  const evalCache = opts?.fresh === true ? undefined : opts?.evalCache
133
250
  const store = evalCache?.store
134
251
  const hashFile = store?.hashFile?.bind(store)
252
+ const hashBytes = store?.hashBytes?.bind(store)
253
+ // The slow path keys from the bytes it already holds; `hashFile` there
254
+ // memoised every closure file one autocommit upsert at a time (item 615).
255
+ const slowKeyHash =
256
+ hashBytes !== undefined ? { hashBytes } : hashFile !== undefined ? { hashFile } : {}
135
257
  // The warm fast path: a config whose ordered closure the store remembers
136
258
  // is keyed from per-file identities (a stat each, no read, no scan); the
137
259
  // slow path below reads, gates and scans, and indexes the closure for
@@ -175,7 +297,7 @@ export async function loadProjectConfigs(
175
297
  configPath,
176
298
  bytes,
177
299
  workspaceFingerprint: evalCache.workspaceFingerprint,
178
- ...(hashFile !== undefined ? { hashFile } : {}),
300
+ ...slowKeyHash,
179
301
  })
180
302
  return {
181
303
  configPath,
@@ -201,58 +323,89 @@ export async function loadProjectConfigs(
201
323
  }
202
324
  }
203
325
  const out: ProjectConfig[] = []
204
- for (const entry of prepared) {
205
- const { configPath, cacheKey } = entry
206
- const hit = cacheKey === null ? undefined : hits.get(cacheKey)
207
- // Stored AFTER validation, so a hit needs none; the key covers every
208
- // byte the evaluation could have read.
209
- if (hit !== undefined) {
210
- out.push(JSON.parse(hit) as ProjectConfig)
211
- continue
212
- }
213
- // A fast key that missed: the closure is stale or the file changed.
214
- // Take the slow path for this one config, which re-indexes it.
215
- let bytes = entry.bytes
216
- let closure = entry.closure
217
- let key = cacheKey
218
- if (entry.indexed) {
219
- bytes = await Bun.file(configPath).bytes()
220
- const keyed = await configEvalKey({
221
- configPath,
222
- bytes,
223
- workspaceFingerprint: evalCache!.workspaceFingerprint,
224
- ...(hashFile !== undefined ? { hashFile } : {}),
225
- })
226
- key = keyed?.key ?? null
227
- closure = keyed !== null && keyed.indexable ? keyed.closure : undefined
228
- const slowHit = key === null ? null : (store!.getConfigEval(key) ?? null)
229
- if (slowHit !== null) {
230
- out.push(JSON.parse(slowHit) as ProjectConfig)
231
- if (closure !== undefined) store!.putConfigClosure?.(configPath, closure)
326
+ // What the round learned, written ONCE at the end: one transaction per
327
+ // table where each evaluation was its own (1,000 configs cold: 100 ms of
328
+ // autocommit inserts against 5, item 615). Written in `finally`, so a
329
+ // config that fails validation costs the next attempt only its own
330
+ // evaluation.
331
+ const evals: Array<readonly [string, string]> = []
332
+ const learnedClosures: Array<readonly [string, readonly string[]]> = []
333
+ // One worker for every repeat load in this round, however many there are.
334
+ const endRound = beginEvalRound()
335
+ try {
336
+ for (const entry of prepared) {
337
+ const { configPath, cacheKey } = entry
338
+ const hit = cacheKey === null ? undefined : hits.get(cacheKey)
339
+ // Stored AFTER validation, so a hit needs none; the key covers every
340
+ // byte the evaluation could have read.
341
+ if (hit !== undefined) {
342
+ out.push(JSON.parse(hit) as ProjectConfig)
232
343
  continue
233
344
  }
234
- }
235
- // A REPEAT load in this process re-evaluates in a worker, because the
236
- // bust above cannot reach the config's import closure — see
237
- // config-eval.ts. A FIRST load keeps the in-process import, so the
238
- // single `vx run` hot path never pays for a worker.
239
- const repeat = loadedConfigs.has(configPath)
240
- loadedConfigs.add(configPath)
241
- const mod = repeat
242
- ? await evaluateConfigFresh(configPath).catch((err: unknown) => {
243
- throw configLoadError(err, configPath, 'Project') ?? err
345
+ // A fast key that missed: the closure is stale or the file changed.
346
+ // Take the slow path for this one config, which re-indexes it.
347
+ let bytes = entry.bytes
348
+ let closure = entry.closure
349
+ let key = cacheKey
350
+ if (entry.indexed) {
351
+ bytes = await Bun.file(configPath).bytes()
352
+ const keyed = await configEvalKey({
353
+ configPath,
354
+ bytes,
355
+ workspaceFingerprint: evalCache!.workspaceFingerprint,
356
+ ...slowKeyHash,
244
357
  })
245
- : await loadDefaultExport(configPath, 'Project', opts?.fresh === true, bytes!)
246
- assertDefaultObject(mod, 'Project', configPath)
247
- // Validation runs HERE, on whichever object we ended up with, so a
248
- // malformed config reports the identical UserError whether it was
249
- // evaluated in-process or in a worker.
250
- validateProjectConfig(mod as ProjectConfig, configPath)
251
- if (key !== null) {
252
- evalCache!.store.putConfigEval(key, JSON.stringify(mod))
253
- if (closure !== undefined) evalCache!.store.putConfigClosure?.(configPath, closure)
358
+ key = keyed?.key ?? null
359
+ closure = keyed !== null && keyed.indexable ? keyed.closure : undefined
360
+ const slowHit = key === null ? null : (store!.getConfigEval(key) ?? null)
361
+ if (slowHit !== null) {
362
+ out.push(JSON.parse(slowHit) as ProjectConfig)
363
+ if (closure !== undefined) learnedClosures.push([configPath, closure])
364
+ continue
365
+ }
366
+ }
367
+ // A REPEAT load in this process re-evaluates in a worker, because the
368
+ // bust above cannot reach the config's import closure — see
369
+ // config-eval.ts. A FIRST load keeps the in-process import, so the
370
+ // single `vx run` hot path never pays for a worker.
371
+ const repeat = loadedConfigs.has(configPath)
372
+ loadedConfigs.add(configPath)
373
+ if (repeat) refuseUnprovidedImports(bytes!, configPath, 'Project')
374
+ const mod = repeat
375
+ ? await evaluateConfigFresh(configPath).catch((err: unknown) => {
376
+ throw configLoadError(err, configPath, 'Project') ?? err
377
+ })
378
+ : await loadDefaultExport(configPath, 'Project', bytes!)
379
+ assertDefaultObject(mod, 'Project', configPath)
380
+ // Validation runs HERE, on whichever object we ended up with, so a
381
+ // malformed config reports the identical UserError whether it was
382
+ // evaluated in-process or in a worker.
383
+ validateProjectConfig(mod as ProjectConfig, configPath)
384
+ const json = JSON.stringify(mod)
385
+ if (key !== null) {
386
+ evals.push([key, json])
387
+ if (closure !== undefined) learnedClosures.push([configPath, closure])
388
+ }
389
+ // A tree of its own, as a hit and the lock hand out. The module object
390
+ // shares what the config shares: one preset's task in two configs, one
391
+ // `exec` in two tasks. A `project` hook that edits in place then edited
392
+ // them all, so a cold run ran `echo P +plug +plug` where the warm run
393
+ // and `--frozen` ran `echo P +plug`, under another key (item 967). A
394
+ // config is JSON data (config-schema.ts), so the copy loses nothing.
395
+ out.push(JSON.parse(json) as ProjectConfig)
396
+ }
397
+ } finally {
398
+ endRound()
399
+ if (store !== undefined) {
400
+ if (evals.length > 0) {
401
+ if (store.putConfigEvals !== undefined) store.putConfigEvals(evals)
402
+ else for (const [k, json] of evals) store.putConfigEval(k, json)
403
+ }
404
+ if (learnedClosures.length > 0 && store.putConfigClosure !== undefined) {
405
+ if (store.putConfigClosures !== undefined) store.putConfigClosures(learnedClosures)
406
+ else for (const [p, files] of learnedClosures) store.putConfigClosure(p, files)
407
+ }
254
408
  }
255
- out.push(mod as ProjectConfig)
256
409
  }
257
410
  return out
258
411
  }
@@ -272,15 +425,12 @@ export async function loadProjectConfig(
272
425
  * a `UserError` on malformed input.
273
426
  */
274
427
  export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig | null> {
275
- let configPath: string | null = null
276
- for (const candidate of WORKSPACE_CONFIG_FILENAMES.map((f) => path.join(root, f))) {
277
- if (await Bun.file(candidate).exists()) {
278
- configPath = candidate
279
- break
280
- }
428
+ for (const configPath of WORKSPACE_CONFIG_FILENAMES.map((f) => path.join(root, f))) {
429
+ const bytes = await readOnce(undefined, configPath)
430
+ if (bytes === null) continue
431
+ const mod = (await loadDefaultExport(configPath, 'Workspace', bytes)) as WorkspaceConfig
432
+ validateWorkspace(mod, configPath)
433
+ return mod
281
434
  }
282
- if (!configPath) return null
283
- const mod = (await loadDefaultExport(configPath, 'Workspace')) as WorkspaceConfig
284
- validateWorkspace(mod, configPath)
285
- return mod
435
+ return null
286
436
  }