@vzn/vx 0.0.21 → 0.0.22
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.
- package/README.md +21 -18
- package/package.json +6 -6
- package/src/bin.ts +52 -3
- package/src/cache/archive.ts +170 -21
- package/src/cache/cache.ts +667 -288
- package/src/cache/chained-cache.ts +14 -2
- package/src/cache/config-evals.ts +29 -6
- package/src/cache/file-hashes.ts +63 -35
- package/src/cache/git-inputs.ts +398 -69
- package/src/cache/index.ts +8 -1
- package/src/cache/inputs.ts +403 -102
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +81 -11
- package/src/cache/layered-cache.ts +199 -32
- package/src/cache/output-index.ts +109 -10
- package/src/cache/run-history.ts +8 -3
- package/src/cache/zstd.ts +53 -30
- package/src/cli/cache.ts +51 -21
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +35 -9
- package/src/cli/info.ts +49 -7
- package/src/cli/init.ts +17 -5
- package/src/cli/last.ts +96 -21
- package/src/cli/lock.ts +34 -17
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/plugin-commands.ts +20 -5
- package/src/cli/run.ts +55 -12
- package/src/cli/select.ts +23 -13
- package/src/cli/show.ts +9 -4
- package/src/cli/upgrade.ts +134 -14
- package/src/cli/watch.ts +611 -102
- package/src/cli/why.ts +8 -16
- package/src/cli/workspace-config.ts +21 -9
- package/src/config.ts +46 -9
- package/src/exec/executor.ts +51 -5
- package/src/exec/index.ts +10 -0
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/runner.ts +327 -82
- package/src/exec/sandbox-binds.ts +34 -5
- package/src/exec/sandbox-paths.ts +22 -2
- package/src/exec/sandbox-runtime.ts +627 -102
- package/src/exec/sandbox-violations.ts +36 -23
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +12 -2
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +182 -134
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +16 -2
- package/src/orchestrator/admission.ts +41 -17
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +22 -7
- package/src/orchestrator/doctor.ts +122 -14
- package/src/orchestrator/events.ts +71 -2
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +331 -37
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +81 -14
- package/src/orchestrator/history.ts +6 -7
- package/src/orchestrator/hit-restore.ts +82 -18
- package/src/orchestrator/index.ts +7 -1
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +152 -32
- package/src/orchestrator/logger.ts +116 -20
- package/src/orchestrator/metrics.ts +156 -60
- package/src/orchestrator/miss-save.ts +106 -43
- package/src/orchestrator/options.ts +30 -4
- package/src/orchestrator/persistent.ts +41 -21
- package/src/orchestrator/placement.ts +55 -25
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +17 -23
- package/src/orchestrator/plugin.ts +31 -8
- package/src/orchestrator/prepare.ts +99 -13
- package/src/orchestrator/projects.ts +19 -8
- package/src/orchestrator/remote-prefetch.ts +21 -1
- package/src/orchestrator/run-artifacts.ts +9 -0
- package/src/orchestrator/run-context.ts +19 -4
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +6 -23
- package/src/orchestrator/run-report.ts +4 -2
- package/src/orchestrator/run.ts +264 -57
- package/src/orchestrator/sandbox-request.ts +388 -62
- package/src/orchestrator/save-lane.ts +4 -7
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +86 -30
- package/src/orchestrator/stable-keys.ts +202 -10
- package/src/orchestrator/status-line.ts +33 -1
- package/src/orchestrator/summary.ts +49 -2
- package/src/orchestrator/task-hash.ts +87 -7
- package/src/orchestrator/task-log-buffer.ts +7 -7
- package/src/orchestrator/telemetry-host.ts +16 -7
- package/src/orchestrator/telemetry.ts +69 -28
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +32 -3
- package/src/util/paths.ts +147 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +4 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +272 -64
- package/src/workspace/config-cache.ts +262 -32
- package/src/workspace/config-eval.ts +49 -12
- package/src/workspace/config-imports.ts +126 -5
- package/src/workspace/config-schema.ts +237 -41
- package/src/workspace/filter.ts +33 -18
- package/src/workspace/fingerprint.ts +29 -11
- package/src/workspace/index.ts +7 -0
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +33 -11
- package/src/workspace/migrate-scripts.ts +60 -7
- package/src/workspace/migration.ts +40 -1
- package/src/workspace/package-graph.ts +154 -16
- package/src/workspace/project-loader.ts +227 -77
- 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
|
-
|
|
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.
|
|
46
|
-
//
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
) => {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
bytes?: Uint8Array,
|
|
116
|
+
bytes: Uint8Array,
|
|
42
117
|
): Promise<unknown> {
|
|
43
|
-
bytes
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
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(
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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 (
|
|
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}: ${
|
|
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
|
|
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
|
-
...
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
if (
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
283
|
-
const mod = (await loadDefaultExport(configPath, 'Workspace')) as WorkspaceConfig
|
|
284
|
-
validateWorkspace(mod, configPath)
|
|
285
|
-
return mod
|
|
435
|
+
return null
|
|
286
436
|
}
|