@vzn/vx 0.0.18 → 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/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -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/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- package/src/plugins/schedule-history/index.ts +0 -109
|
@@ -28,13 +28,13 @@
|
|
|
28
28
|
// `vx lock` both go through `JSON.stringify` — so a cached config derives
|
|
29
29
|
// the same cache key as a live evaluation of the same bytes.
|
|
30
30
|
|
|
31
|
-
import { realpathSync } from 'node:fs'
|
|
31
|
+
import { lstatSync, readFileSync, realpathSync, statSync } from 'node:fs'
|
|
32
32
|
import path from 'node:path'
|
|
33
33
|
import { xxh3 } from '../util/index.js'
|
|
34
34
|
import { VERSION } from '../version.js'
|
|
35
35
|
|
|
36
36
|
/** Bump when the key derivation or the stored shape changes. */
|
|
37
|
-
export const CONFIG_EVAL_VERSION =
|
|
37
|
+
export const CONFIG_EVAL_VERSION = 3
|
|
38
38
|
|
|
39
39
|
/** Where cached evaluations live; `Cache` implements it over `cache.db`. */
|
|
40
40
|
export interface ConfigEvalStore {
|
|
@@ -52,8 +52,31 @@ export interface ConfigEvalStore {
|
|
|
52
52
|
* shadow, is never indexed.
|
|
53
53
|
*/
|
|
54
54
|
hashFile?(file: string): Promise<string>
|
|
55
|
+
/**
|
|
56
|
+
* `hashFile` over many paths with one memo query; a path that cannot be
|
|
57
|
+
* stat'ed is absent. The warm load identifies every indexed closure's
|
|
58
|
+
* files through this in a single call when the store offers it.
|
|
59
|
+
*/
|
|
60
|
+
hashFiles?(files: readonly string[]): Promise<Map<string, string>>
|
|
61
|
+
/**
|
|
62
|
+
* The identity `hashFile` would return for a file holding `bytes`, from
|
|
63
|
+
* the bytes alone — no stat, no memo row. The slow path has every
|
|
64
|
+
* closure file's bytes in hand (it reads them to scan for imports), and
|
|
65
|
+
* keying through `hashFile` there cost a memo upsert per file, each its
|
|
66
|
+
* own autocommit transaction (1,000 configs: 50 ms, item 615). The first
|
|
67
|
+
* warm load after it reads those files once for the memo, in one
|
|
68
|
+
* transaction. `nearPath` says which repo's object format applies.
|
|
69
|
+
*/
|
|
70
|
+
hashBytes?(bytes: Uint8Array, nearPath: string): string
|
|
55
71
|
getConfigClosures?(configPaths: readonly string[]): Map<string, string[]>
|
|
56
72
|
putConfigClosure?(configPath: string, files: readonly string[]): void
|
|
73
|
+
/**
|
|
74
|
+
* A round's closures in one write (optional; a store without it is
|
|
75
|
+
* given them one by one). One transaction where the per-config put was
|
|
76
|
+
* one each: 1,000 autocommit inserts measured 180–240 ms against 2.5 in
|
|
77
|
+
* one transaction (item 615).
|
|
78
|
+
*/
|
|
79
|
+
putConfigClosures?(entries: ReadonlyArray<readonly [string, readonly string[]]>): void
|
|
57
80
|
getConfigEval(key: string): string | null
|
|
58
81
|
/**
|
|
59
82
|
* Many keys in one round-trip (optional; a store without it is asked per
|
|
@@ -62,6 +85,8 @@ export interface ConfigEvalStore {
|
|
|
62
85
|
*/
|
|
63
86
|
getConfigEvals?(keys: readonly string[]): Map<string, string>
|
|
64
87
|
putConfigEval(key: string, json: string): void
|
|
88
|
+
/** A round's evaluations in one write, as `putConfigClosures` (optional). */
|
|
89
|
+
putConfigEvals?(entries: ReadonlyArray<readonly [string, string]>): void
|
|
65
90
|
}
|
|
66
91
|
|
|
67
92
|
/** What `configEvalKey` learned besides the key, for the store's closure index. */
|
|
@@ -75,6 +100,8 @@ export interface ConfigEvalKeyResult {
|
|
|
75
100
|
|
|
76
101
|
export interface ConfigEvalKeyArgs {
|
|
77
102
|
configPath: string
|
|
103
|
+
/** The identity of a file from its bytes; preferred over `hashFile`, which stats and memoises. */
|
|
104
|
+
hashBytes?: (bytes: Uint8Array, nearPath: string) => string
|
|
78
105
|
/**
|
|
79
106
|
* Per-file identity, the same function the store's warm path uses. Absent
|
|
80
107
|
* (tests, a store without one), the git blob id is computed from the
|
|
@@ -95,18 +122,101 @@ const MAX_CLOSURE_FILES = 32
|
|
|
95
122
|
// `global` and `self` are live objects in Bun (aliases of `globalThis`), so
|
|
96
123
|
// a computed `global['proc' + 'ess']` reaches process without ever
|
|
97
124
|
// spelling it; `Temporal` is a clock. All three were CACHED AS PURE before
|
|
98
|
-
// they were listed (2026-09-03).
|
|
125
|
+
// they were listed (2026-09-03). `constructor` reaches `Function` through a
|
|
126
|
+
// property name (`({}).constructor.constructor('return process')()`, the
|
|
127
|
+
// body hidden in a literal the strip removes) and `localeCompare` answers
|
|
128
|
+
// by the host locale; both listed 2026-09-09. The reflective primitives
|
|
129
|
+
// reach `Function` with no denied word anywhere — a prototype's property
|
|
130
|
+
// names hold `constructor` without the code spelling it
|
|
131
|
+
// (`Object.getOwnPropertyNames(Object.getPrototypeOf(() => 0))`), and a
|
|
132
|
+
// literal naming it was stripped before the test ran; listed 2026-09-27
|
|
133
|
+
// (item 957). A key assembled at run time (`'constru' + 'ctor'`) still
|
|
134
|
+
// passes: the gate stops accidental impurity, not a config written to
|
|
135
|
+
// defeat it.
|
|
99
136
|
const IMPURE_RE =
|
|
100
|
-
/\b(?:process|Bun|globalThis|global|self|fetch|Date|Temporal|Intl|crypto|performance|navigator|require|eval|Function|await|toLocale\w
|
|
137
|
+
/\b(?:process|Bun|globalThis|global|self|fetch|Date|Temporal|Intl|crypto|performance|navigator|require|eval|Function|constructor|localeCompare|await|toLocale\w*|Reflect|getPrototypeOf|setPrototypeOf|getOwnPropertyNames|getOwnPropertyDescriptor|getOwnPropertyDescriptors|__proto__|prototype|__defineGetter__|__defineSetter__|__lookupGetter__|__lookupSetter__)\b|import\s*\.\s*meta|Math\s*\.\s*random|\bimport\s*\(/
|
|
138
|
+
|
|
139
|
+
/** Literal text that names a way to `Function` when used as a computed key. */
|
|
140
|
+
const IMPURE_LITERAL_RE = /constructor|__proto__|prototype/
|
|
101
141
|
|
|
102
|
-
// Static `import … from '…'` / `export … from '…'` / `import '…'` forms
|
|
103
|
-
// `
|
|
142
|
+
// Static `import … from '…'` / `export … from '…'` / `import '…'` forms,
|
|
143
|
+
// matched on `stripLiterals` output with its strings kept as placeholders
|
|
144
|
+
// (`\0<n>\0`): a comment is gone, so an import after `/* … */` on its line
|
|
145
|
+
// is seen, and a string-named binding (`import { 'a-b' as x }`) is a
|
|
146
|
+
// placeholder like any other. `[^;]*?` spans newlines, so multi-line
|
|
147
|
+
// specifier lists match.
|
|
104
148
|
const IMPORT_RE =
|
|
105
|
-
/(?:^|[\n;])\s*(?:import|export)\b[^;
|
|
149
|
+
/(?:^|[\n;])\s*(?:import|export)\b[^;]*?\bfrom\s*\0(\d+)\0|(?:^|[\n;])\s*import\s*\0(\d+)\0/g
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Every static import in `source` as its specifier and statement, or `null`
|
|
153
|
+
* when the source cannot be lexed or holds an `import` no form above
|
|
154
|
+
* accounts for — a spelling this scan misses must fail closed: an unseen
|
|
155
|
+
* import is neither keyed nor gated for purity (item 952).
|
|
156
|
+
*/
|
|
157
|
+
function staticImports(
|
|
158
|
+
source: string,
|
|
159
|
+
): { code: string; strings: string[]; imports: Array<{ spec: string; statement: string }> } | null {
|
|
160
|
+
const strings: string[] = []
|
|
161
|
+
const code = stripLiterals(source, strings)
|
|
162
|
+
if (code === null) return null
|
|
163
|
+
const imports: Array<{ spec: string; statement: string }> = []
|
|
164
|
+
let seen = 0
|
|
165
|
+
for (const m of code.matchAll(IMPORT_RE)) {
|
|
166
|
+
imports.push({ spec: strings[Number(m[1] ?? m[2])]!, statement: m[0] })
|
|
167
|
+
seen += m[0].match(/\bimport\b/g)?.length ?? 0
|
|
168
|
+
}
|
|
169
|
+
if (seen !== (code.match(/\bimport\b/g)?.length ?? 0)) return null
|
|
170
|
+
return { code, strings, imports }
|
|
171
|
+
}
|
|
106
172
|
|
|
107
173
|
/** The one bare specifier a pure config may import: core's identity helpers and types. */
|
|
108
174
|
const PURE_PACKAGE = '@vzn/vx'
|
|
109
175
|
|
|
176
|
+
/**
|
|
177
|
+
* The `@vzn/vx` values a pure config may import. Core exports far more, and
|
|
178
|
+
* some of it reads the machine: a config calling `machineParallelism()` was
|
|
179
|
+
* replayed from the store on a box with another core count, and its tasks
|
|
180
|
+
* kept the key the first box's answer gave them (item 888). Types are
|
|
181
|
+
* always fine; a name outside this list, a namespace or default import, or
|
|
182
|
+
* an `export *` of the package evaluates live.
|
|
183
|
+
*/
|
|
184
|
+
export const PURE_CORE_EXPORTS: ReadonlySet<string> = new Set([
|
|
185
|
+
'defineProject',
|
|
186
|
+
'defineWorkspace',
|
|
187
|
+
'splitTaskId',
|
|
188
|
+
'normalizeGlob',
|
|
189
|
+
'isLiteralPattern',
|
|
190
|
+
'PLUGIN_HOOKS',
|
|
191
|
+
'TASK_STATUSES',
|
|
192
|
+
'PERSISTENT_TASK_NAMES',
|
|
193
|
+
])
|
|
194
|
+
|
|
195
|
+
/** Whether an `import`/`export … from '@vzn/vx'` statement takes only pure values. */
|
|
196
|
+
function importsOnlyPure(statement: string): boolean {
|
|
197
|
+
const clause = statement
|
|
198
|
+
.replace(/^[\s;]*(?:import|export)\s+/, '')
|
|
199
|
+
.replace(/\bfrom\s*\0\d+\0\s*$/, '')
|
|
200
|
+
if (/^type\b/.test(clause)) return true
|
|
201
|
+
if (clause.includes('*')) return false
|
|
202
|
+
const braces = /\{([^}]*)\}/.exec(clause)
|
|
203
|
+
// Anything outside the braces is a default import.
|
|
204
|
+
if (
|
|
205
|
+
clause
|
|
206
|
+
.replace(/\{[^}]*\}/, '')
|
|
207
|
+
.replace(/,/g, '')
|
|
208
|
+
.trim() !== ''
|
|
209
|
+
)
|
|
210
|
+
return false
|
|
211
|
+
for (const part of (braces?.[1] ?? '').split(',')) {
|
|
212
|
+
const spec = part.trim()
|
|
213
|
+
if (spec === '' || /^type\s/.test(spec)) continue
|
|
214
|
+
const name = spec.split(/\s+as\s+/)[0]!.trim()
|
|
215
|
+
if (!PURE_CORE_EXPORTS.has(name)) return false
|
|
216
|
+
}
|
|
217
|
+
return true
|
|
218
|
+
}
|
|
219
|
+
|
|
110
220
|
const decoder = new TextDecoder()
|
|
111
221
|
|
|
112
222
|
/**
|
|
@@ -116,9 +226,10 @@ const decoder = new TextDecoder()
|
|
|
116
226
|
* lexer can prove: a regex literal can contain a quote, and a lexer that
|
|
117
227
|
* misreads one would swallow real code as a string — a false SAFE, the one
|
|
118
228
|
* outcome this module must never produce. Configs do not need regexes;
|
|
119
|
-
* such a config evaluates live.
|
|
229
|
+
* such a config evaluates live. Given `strings`, a quoted string becomes
|
|
230
|
+
* the placeholder `\0<n>\0` and its text `strings[n]`.
|
|
120
231
|
*/
|
|
121
|
-
export function stripLiterals(source: string): string | null {
|
|
232
|
+
export function stripLiterals(source: string, strings?: string[]): string | null {
|
|
122
233
|
let out = ''
|
|
123
234
|
let i = 0
|
|
124
235
|
const n = source.length
|
|
@@ -140,15 +251,16 @@ export function stripLiterals(source: string): string | null {
|
|
|
140
251
|
}
|
|
141
252
|
if (c === '/') return null
|
|
142
253
|
if (c === "'" || c === '"') {
|
|
143
|
-
i
|
|
254
|
+
const start = ++i
|
|
144
255
|
while (i < n && source[i] !== c) {
|
|
145
256
|
if (source[i] === '\\') i++
|
|
146
257
|
if (source[i] === '\n') return null
|
|
147
258
|
i++
|
|
148
259
|
}
|
|
149
260
|
if (i >= n) return null
|
|
261
|
+
if (strings === undefined) out += ' '
|
|
262
|
+
else out += `\0${strings.push(source.slice(start, i)) - 1}\0`
|
|
150
263
|
i++
|
|
151
|
-
out += ' '
|
|
152
264
|
continue
|
|
153
265
|
}
|
|
154
266
|
if (c === '`') {
|
|
@@ -212,11 +324,6 @@ export function stripLiterals(source: string): string | null {
|
|
|
212
324
|
return out
|
|
213
325
|
}
|
|
214
326
|
|
|
215
|
-
/**
|
|
216
|
-
* The cache key for evaluating `configPath`, or `null` when the config is
|
|
217
|
-
* not provably pure (or its closure cannot be read), in which case the
|
|
218
|
-
* caller evaluates live and stores nothing.
|
|
219
|
-
*/
|
|
220
327
|
/** `git hash-object` of `bytes` (sha1 domain), the identity `Cache.hashFile` returns for a sha1 repo. */
|
|
221
328
|
export function blobOidOf(bytes: Uint8Array): string {
|
|
222
329
|
const hasher = new Bun.CryptoHasher('sha1')
|
|
@@ -225,14 +332,72 @@ export function blobOidOf(bytes: Uint8Array): string {
|
|
|
225
332
|
return hasher.digest('hex')
|
|
226
333
|
}
|
|
227
334
|
|
|
335
|
+
let transpileMemo: string | undefined
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* What this process's transpiler was told besides the file bytes: the
|
|
339
|
+
* `bunfig.toml` Bun loaded at startup (the cwd's, and the global one) and
|
|
340
|
+
* the flags it ran with (`--define`, `--preload`, directly or through
|
|
341
|
+
* `BUN_OPTIONS`). A `[define]` value is a bare identifier to the config, so
|
|
342
|
+
* no deny word sees it, and flipping `BUILD_MODE` from `dev` to `prod`
|
|
343
|
+
* replayed the `dev` evaluation (item 956). Read once per process: a
|
|
344
|
+
* running Bun does not reload them either. A bunfig the process never
|
|
345
|
+
* loaded (a compiled binary's) only costs a miss when it changes.
|
|
346
|
+
*/
|
|
347
|
+
export function transpileInputs(): string {
|
|
348
|
+
if (transpileMemo !== undefined) return transpileMemo
|
|
349
|
+
const home = process.env['HOME']
|
|
350
|
+
const xdg = process.env['XDG_CONFIG_HOME']
|
|
351
|
+
const files = [
|
|
352
|
+
path.join(process.cwd(), 'bunfig.toml'),
|
|
353
|
+
...(xdg !== undefined && xdg !== '' ? [path.join(xdg, '.bunfig.toml')] : []),
|
|
354
|
+
...(home !== undefined && home !== '' ? [path.join(home, '.bunfig.toml')] : []),
|
|
355
|
+
]
|
|
356
|
+
const parts = [process.execArgv.join('\0'), process.env['BUN_OPTIONS'] ?? '']
|
|
357
|
+
for (const file of files) {
|
|
358
|
+
try {
|
|
359
|
+
parts.push(`${file}\0${blobOidOf(readFileSync(file))}`)
|
|
360
|
+
} catch {
|
|
361
|
+
// Absent: nothing loaded from it.
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return (transpileMemo = xxh3(parts.join('\0')).toString(16))
|
|
365
|
+
}
|
|
366
|
+
|
|
228
367
|
function keySeed(workspaceFingerprint: string): bigint {
|
|
229
368
|
return xxh3(
|
|
230
|
-
`vx-config-eval-v${CONFIG_EVAL_VERSION}\0${VERSION}\0${Bun.version}\0${workspaceFingerprint}\0`,
|
|
369
|
+
`vx-config-eval-v${CONFIG_EVAL_VERSION}\0${VERSION}\0${Bun.version}\0${workspaceFingerprint}\0${transpileInputs()}\0`,
|
|
231
370
|
)
|
|
232
371
|
}
|
|
233
372
|
|
|
234
373
|
const EXPLICIT_EXT = /\.(?:m?[jt]s|cjs|cts)$/
|
|
235
374
|
|
|
375
|
+
/**
|
|
376
|
+
* The canonical directory Bun resolves `file`'s imports from: its REAL
|
|
377
|
+
* path's, so a config linked in from elsewhere imports its neighbours
|
|
378
|
+
* there, not beside the link (item 950). The directory is real-pathed
|
|
379
|
+
* rather than the file, whose realpath opens it: a config is opened once
|
|
380
|
+
* per run (`read-once.unsafe.test.ts`).
|
|
381
|
+
*/
|
|
382
|
+
function realDirOf(file: string): string {
|
|
383
|
+
if (lstatSync(file).isSymbolicLink()) return path.dirname(realpathSync(file))
|
|
384
|
+
return realpathSync(path.dirname(file))
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** A regular file reached through no symlink: the path IS the file. */
|
|
388
|
+
function isCanonicalFile(file: string): boolean {
|
|
389
|
+
try {
|
|
390
|
+
return realpathSync(file) === file && statSync(file).isFile()
|
|
391
|
+
} catch {
|
|
392
|
+
return false
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The cache key for evaluating `configPath`, or `null` when the config is
|
|
398
|
+
* not provably pure (or its closure cannot be read), in which case the
|
|
399
|
+
* caller evaluates live and stores nothing.
|
|
400
|
+
*/
|
|
236
401
|
export async function configEvalKey(a: ConfigEvalKeyArgs): Promise<ConfigEvalKeyResult | null> {
|
|
237
402
|
let h = keySeed(a.workspaceFingerprint)
|
|
238
403
|
const hashOf = a.hashFile ?? (async (_file: string, bytes?: Uint8Array) => blobOidOf(bytes!))
|
|
@@ -242,30 +407,59 @@ export async function configEvalKey(a: ConfigEvalKeyArgs): Promise<ConfigEvalKey
|
|
|
242
407
|
const queue: Array<{ file: string; bytes: Uint8Array }> = [{ file: a.configPath, bytes: a.bytes }]
|
|
243
408
|
while (queue.length > 0) {
|
|
244
409
|
const { file, bytes } = queue.shift()!
|
|
245
|
-
|
|
246
|
-
|
|
410
|
+
// Asked of the first relative import only: a config with none costs no
|
|
411
|
+
// syscall for it.
|
|
412
|
+
let dir: string | undefined
|
|
413
|
+
const scanned = staticImports(decoder.decode(bytes))
|
|
414
|
+
if (scanned === null) return null
|
|
415
|
+
const { code } = scanned
|
|
247
416
|
// A backslash in code position is an identifier escape (`\u0070rocess`
|
|
248
417
|
// IS `process`) — the one spelling the deny-list cannot see. Refuse it.
|
|
249
|
-
if (code
|
|
250
|
-
|
|
418
|
+
if (code.includes('\\') || IMPURE_RE.test(code)) return null
|
|
419
|
+
if (scanned.strings.some((t) => IMPURE_LITERAL_RE.test(t))) return null
|
|
420
|
+
const identity = a.hashBytes
|
|
421
|
+
? a.hashBytes(bytes, file)
|
|
422
|
+
: a.hashFile
|
|
423
|
+
? await a.hashFile(file)
|
|
424
|
+
: await hashOf(file, bytes)
|
|
251
425
|
h = xxh3(`${file}\0${identity}`, h)
|
|
252
426
|
closure.push(file)
|
|
253
|
-
for (const
|
|
254
|
-
|
|
255
|
-
|
|
427
|
+
for (const { spec, statement } of scanned.imports) {
|
|
428
|
+
if (spec === PURE_PACKAGE) {
|
|
429
|
+
if (/\bfrom\s*\0\d+\0\s*$/.test(statement) && !importsOnlyPure(statement)) return null
|
|
430
|
+
continue
|
|
431
|
+
}
|
|
256
432
|
if (!spec.startsWith('./') && !spec.startsWith('../')) return null
|
|
257
|
-
|
|
258
|
-
|
|
433
|
+
// The warm path re-hashes the files this resolution found and never
|
|
434
|
+
// resolves again, so it holds only when the specifier names its file
|
|
435
|
+
// outright: an explicit extension, no symlink on the way. A link
|
|
436
|
+
// retargeted without touching a listed file, or Bun's `.js` → `.ts`
|
|
437
|
+
// fallback (a `preset.js` created beside `preset.ts` takes over),
|
|
438
|
+
// moves the answer; such a closure is keyed but never indexed
|
|
439
|
+
// (item 950). The named file is taken without `Bun.resolveSync`,
|
|
440
|
+
// whose directory cache answers a retargeted link with its old
|
|
441
|
+
// target for the rest of the process.
|
|
259
442
|
try {
|
|
260
|
-
|
|
261
|
-
// on one call and the real one on another (a tmp workspace under
|
|
262
|
-
// /var vs /private/var), and the key folds the path — a spelling
|
|
263
|
-
// that drifts between runs is a spurious miss and a duplicated
|
|
264
|
-
// memo row.
|
|
265
|
-
resolved = realpathSync(Bun.resolveSync(spec, path.dirname(file)))
|
|
443
|
+
dir ??= realDirOf(file)
|
|
266
444
|
} catch {
|
|
267
445
|
return null
|
|
268
446
|
}
|
|
447
|
+
const named = path.resolve(dir, spec)
|
|
448
|
+
let resolved: string
|
|
449
|
+
if (EXPLICIT_EXT.test(spec) && isCanonicalFile(named)) resolved = named
|
|
450
|
+
else {
|
|
451
|
+
indexable = false
|
|
452
|
+
try {
|
|
453
|
+
// Real-pathed: `Bun.resolveSync` answers with the symlinked
|
|
454
|
+
// spelling on one call and the real one on another (a tmp
|
|
455
|
+
// workspace under /var vs /private/var), and the key folds the
|
|
456
|
+
// path — a spelling that drifts between runs is a spurious miss
|
|
457
|
+
// and a duplicated memo row.
|
|
458
|
+
resolved = realpathSync(Bun.resolveSync(spec, dir))
|
|
459
|
+
} catch {
|
|
460
|
+
return null
|
|
461
|
+
}
|
|
462
|
+
}
|
|
269
463
|
if (visited.has(resolved)) continue
|
|
270
464
|
if (resolved.split(path.sep).includes('node_modules')) return null
|
|
271
465
|
visited.add(resolved)
|
|
@@ -302,3 +496,48 @@ export async function configEvalKeyFromClosure(a: {
|
|
|
302
496
|
for (let i = 0; i < a.closure.length; i++) h = xxh3(`${a.closure[i]}\0${identities[i]}`, h)
|
|
303
497
|
return h.toString(16).padStart(16, '0')
|
|
304
498
|
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Every file a config imports by relative specifier, transitively, outside
|
|
502
|
+
* `node_modules`, the config itself excluded. `vx watch` watches these: a
|
|
503
|
+
* shared preset outside the project (`../../shared/preset.mjs`, the way
|
|
504
|
+
* configs compose) changed what a run evaluates and no watcher saw it
|
|
505
|
+
* (item 949). A file that cannot be read or resolved ends its branch.
|
|
506
|
+
*/
|
|
507
|
+
export async function configImports(configPath: string): Promise<string[]> {
|
|
508
|
+
const seen = new Set<string>([configPath])
|
|
509
|
+
const out: string[] = []
|
|
510
|
+
const queue = [configPath]
|
|
511
|
+
while (queue.length > 0) {
|
|
512
|
+
const file = queue.shift()!
|
|
513
|
+
let source: string
|
|
514
|
+
try {
|
|
515
|
+
source = await Bun.file(file).text()
|
|
516
|
+
} catch {
|
|
517
|
+
continue
|
|
518
|
+
}
|
|
519
|
+
let dir: string | undefined
|
|
520
|
+
// A source the scan cannot read keeps the imports it can see: watching
|
|
521
|
+
// too few files is what this list exists to prevent.
|
|
522
|
+
const specs =
|
|
523
|
+
staticImports(source)?.imports.map((i) => i.spec) ??
|
|
524
|
+
[...source.matchAll(/\bfrom\s*['"]([^'"]+)['"]|\bimport\s*['"]([^'"]+)['"]/g)].map(
|
|
525
|
+
(m) => (m[1] ?? m[2])!,
|
|
526
|
+
)
|
|
527
|
+
for (const spec of specs) {
|
|
528
|
+
if (!spec.startsWith('./') && !spec.startsWith('../')) continue
|
|
529
|
+
let resolved: string
|
|
530
|
+
try {
|
|
531
|
+
dir ??= realDirOf(file)
|
|
532
|
+
resolved = realpathSync(Bun.resolveSync(spec, dir))
|
|
533
|
+
} catch {
|
|
534
|
+
continue
|
|
535
|
+
}
|
|
536
|
+
if (seen.has(resolved) || resolved.split(path.sep).includes('node_modules')) continue
|
|
537
|
+
seen.add(resolved)
|
|
538
|
+
out.push(resolved)
|
|
539
|
+
queue.push(resolved)
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
return out
|
|
543
|
+
}
|
|
@@ -30,20 +30,29 @@
|
|
|
30
30
|
// locks or runs. `JSON.stringify(JSON.parse(s)) === s`, so a config
|
|
31
31
|
// re-read through a worker derives the SAME cache key as the
|
|
32
32
|
// in-process first load — which is why this needs no CACHE_VERSION bump.
|
|
33
|
+
// What JSON would drop or rewrite never makes the trip: the worker runs
|
|
34
|
+
// `nonJsonPaths` (json-data.ts) on the live object first, embedded by its
|
|
35
|
+
// source so the rule has one copy, and the parent refuses what it names
|
|
36
|
+
// with the message the first load gives (item 701).
|
|
33
37
|
|
|
34
38
|
import path from 'node:path'
|
|
35
|
-
import { MAX_TIMEOUT_MS } from '../util/index.js'
|
|
39
|
+
import { MAX_TIMEOUT_MS, UserError } from '../util/index.js'
|
|
40
|
+
import { nonJsonMessage, nonJsonPaths, type NonJsonValue } from './json-data.js'
|
|
36
41
|
|
|
37
42
|
const WORKER_SRC = `
|
|
43
|
+
const nonJsonPaths = ${nonJsonPaths.toString()}
|
|
38
44
|
self.onmessage = async (e) => {
|
|
39
45
|
const { id, path } = e.data
|
|
40
46
|
try {
|
|
41
47
|
const ns = await import(path)
|
|
42
48
|
const mod = ns?.default
|
|
49
|
+
const isObject = mod !== null && typeof mod === 'object'
|
|
50
|
+
const nonJson = isObject ? nonJsonPaths(mod) : []
|
|
43
51
|
postMessage({
|
|
44
52
|
id,
|
|
45
53
|
ok: true,
|
|
46
|
-
|
|
54
|
+
nonJson,
|
|
55
|
+
json: isObject && nonJson.length === 0 ? JSON.stringify(mod) : null,
|
|
47
56
|
})
|
|
48
57
|
} catch (err) {
|
|
49
58
|
postMessage({
|
|
@@ -52,6 +61,10 @@ self.onmessage = async (e) => {
|
|
|
52
61
|
name: err?.name ?? 'Error',
|
|
53
62
|
message: err?.message ?? String(err),
|
|
54
63
|
stack: err?.stack ?? null,
|
|
64
|
+
position:
|
|
65
|
+
err?.position && typeof err.position === 'object'
|
|
66
|
+
? { file: err.position.file, line: err.position.line, column: err.position.column }
|
|
67
|
+
: null,
|
|
55
68
|
})
|
|
56
69
|
}
|
|
57
70
|
}
|
|
@@ -63,13 +76,16 @@ interface WorkerReply {
|
|
|
63
76
|
id: number
|
|
64
77
|
ok: boolean
|
|
65
78
|
json: string | null
|
|
79
|
+
nonJson: NonJsonValue[]
|
|
66
80
|
name: string
|
|
67
81
|
message: string
|
|
68
82
|
stack: string | null
|
|
83
|
+
/** A transpile error's location — `BuildMessage.position`, trimmed to what the loader reads. */
|
|
84
|
+
position: { file?: string; line?: number; column?: number } | null
|
|
69
85
|
}
|
|
70
86
|
|
|
71
87
|
interface Pending {
|
|
72
|
-
resolve: (
|
|
88
|
+
resolve: (reply: WorkerReply) => void
|
|
73
89
|
reject: (err: Error) => void
|
|
74
90
|
}
|
|
75
91
|
|
|
@@ -133,15 +149,16 @@ function acquireWorker(): Worker {
|
|
|
133
149
|
if (p === undefined) return
|
|
134
150
|
pending.delete(msg.id)
|
|
135
151
|
if (msg.ok) {
|
|
136
|
-
p.resolve(msg
|
|
152
|
+
p.resolve(msg)
|
|
137
153
|
return
|
|
138
154
|
}
|
|
139
|
-
// Rebuild the error the config actually threw. Name, message
|
|
140
|
-
//
|
|
141
|
-
// text it would from an in-process import.
|
|
142
|
-
const err = new Error(msg.message)
|
|
155
|
+
// Rebuild the error the config actually threw. Name, message, stack
|
|
156
|
+
// and a transpile error's position all survive the hop, so a broken
|
|
157
|
+
// config reports the same text it would from an in-process import.
|
|
158
|
+
const err = new Error(msg.message) as Error & { position?: WorkerReply['position'] }
|
|
143
159
|
err.name = msg.name
|
|
144
160
|
if (msg.stack !== null) err.stack = msg.stack
|
|
161
|
+
if (msg.position !== null) err.position = msg.position
|
|
145
162
|
p.reject(err)
|
|
146
163
|
}
|
|
147
164
|
w.onerror = (event: ErrorEvent): void => {
|
|
@@ -157,11 +174,40 @@ function acquireWorker(): Worker {
|
|
|
157
174
|
return w
|
|
158
175
|
}
|
|
159
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Hold the round open across evaluations the caller runs one after another.
|
|
179
|
+
* `loadProjectConfigs` awaits each repeat load in turn, so without this the
|
|
180
|
+
* in-flight count reached zero after every config and each one paid for a
|
|
181
|
+
* worker of its own: a round of 5 configs made 5 workers (item 694). The
|
|
182
|
+
* worker is still created lazily, so a round that evaluates nothing costs
|
|
183
|
+
* nothing, and it is retired when the round ends, so the next round starts
|
|
184
|
+
* from an empty registry.
|
|
185
|
+
*/
|
|
186
|
+
export function beginEvalRound(): () => void {
|
|
187
|
+
inFlight++
|
|
188
|
+
let ended = false
|
|
189
|
+
return () => {
|
|
190
|
+
if (ended) return
|
|
191
|
+
ended = true
|
|
192
|
+
retireIfIdle()
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function retireIfIdle(): void {
|
|
197
|
+
inFlight--
|
|
198
|
+
if (inFlight === 0 && worker !== null) {
|
|
199
|
+
worker.terminate()
|
|
200
|
+
worker = null
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
160
204
|
/**
|
|
161
205
|
* Evaluate `configPath` against a fresh module registry and return its
|
|
162
206
|
* default export, JSON round-tripped. `null` means the module had no
|
|
163
207
|
* object default export — the caller owns that error message so it
|
|
164
|
-
* reads identically whichever path produced it.
|
|
208
|
+
* reads identically whichever path produced it. A default export holding
|
|
209
|
+
* a value JSON cannot carry is refused here, with `validateProjectConfig`'s
|
|
210
|
+
* message, since the round trip would have dropped the evidence.
|
|
165
211
|
*/
|
|
166
212
|
export async function evaluateConfigFresh(configPath: string): Promise<unknown> {
|
|
167
213
|
const abs = path.resolve(configPath)
|
|
@@ -179,7 +225,7 @@ export async function evaluateConfigFresh(configPath: string): Promise<unknown>
|
|
|
179
225
|
// run-level timeout. The budget is enormous next to the ~10 ms a real
|
|
180
226
|
// evaluation costs, so it can only fire on a genuine wedge.
|
|
181
227
|
const budget = workerTimeoutMs()
|
|
182
|
-
const
|
|
228
|
+
const reply = await new Promise<WorkerReply>((resolve, reject) => {
|
|
183
229
|
pending.set(id, { resolve, reject })
|
|
184
230
|
timer = setTimeout(() => {
|
|
185
231
|
rejectAll(new Error(`config worker did not answer within ${budget}ms`))
|
|
@@ -191,7 +237,9 @@ export async function evaluateConfigFresh(configPath: string): Promise<unknown>
|
|
|
191
237
|
timer.unref?.()
|
|
192
238
|
w.postMessage({ id, path: abs })
|
|
193
239
|
})
|
|
194
|
-
|
|
240
|
+
const [nonJson] = reply.nonJson
|
|
241
|
+
if (nonJson !== undefined) throw new UserError(nonJsonMessage(configPath, nonJson))
|
|
242
|
+
return reply.json === null ? null : (JSON.parse(reply.json) as unknown)
|
|
195
243
|
} finally {
|
|
196
244
|
// In the `finally`, not after the await: a REJECTED evaluation — a config
|
|
197
245
|
// with a typo, the common case while editing — would otherwise skip the
|
|
@@ -201,10 +249,6 @@ export async function evaluateConfigFresh(configPath: string): Promise<unknown>
|
|
|
201
249
|
// message naming a budget nobody set for it.
|
|
202
250
|
clearTimeout(timer)
|
|
203
251
|
pending.delete(id)
|
|
204
|
-
|
|
205
|
-
if (inFlight === 0 && worker !== null) {
|
|
206
|
-
worker.terminate()
|
|
207
|
-
worker = null
|
|
208
|
-
}
|
|
252
|
+
retireIfIdle()
|
|
209
253
|
}
|
|
210
254
|
}
|