@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
|
@@ -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 {
|
|
@@ -58,8 +58,25 @@ export interface ConfigEvalStore {
|
|
|
58
58
|
* files through this in a single call when the store offers it.
|
|
59
59
|
*/
|
|
60
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
|
|
61
71
|
getConfigClosures?(configPaths: readonly string[]): Map<string, string[]>
|
|
62
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
|
|
63
80
|
getConfigEval(key: string): string | null
|
|
64
81
|
/**
|
|
65
82
|
* Many keys in one round-trip (optional; a store without it is asked per
|
|
@@ -68,6 +85,8 @@ export interface ConfigEvalStore {
|
|
|
68
85
|
*/
|
|
69
86
|
getConfigEvals?(keys: readonly string[]): Map<string, string>
|
|
70
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
|
|
71
90
|
}
|
|
72
91
|
|
|
73
92
|
/** What `configEvalKey` learned besides the key, for the store's closure index. */
|
|
@@ -81,6 +100,8 @@ export interface ConfigEvalKeyResult {
|
|
|
81
100
|
|
|
82
101
|
export interface ConfigEvalKeyArgs {
|
|
83
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
|
|
84
105
|
/**
|
|
85
106
|
* Per-file identity, the same function the store's warm path uses. Absent
|
|
86
107
|
* (tests, a store without one), the git blob id is computed from the
|
|
@@ -104,18 +125,98 @@ const MAX_CLOSURE_FILES = 32
|
|
|
104
125
|
// they were listed (2026-09-03). `constructor` reaches `Function` through a
|
|
105
126
|
// property name (`({}).constructor.constructor('return process')()`, the
|
|
106
127
|
// body hidden in a literal the strip removes) and `localeCompare` answers
|
|
107
|
-
// by the host locale; both listed 2026-09-09.
|
|
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.
|
|
108
136
|
const IMPURE_RE =
|
|
109
|
-
/\b(?:process|Bun|globalThis|global|self|fetch|Date|Temporal|Intl|crypto|performance|navigator|require|eval|Function|constructor|localeCompare|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*\(/
|
|
110
138
|
|
|
111
|
-
|
|
112
|
-
|
|
139
|
+
/** Literal text that names a way to `Function` when used as a computed key. */
|
|
140
|
+
const IMPURE_LITERAL_RE = /constructor|__proto__|prototype/
|
|
141
|
+
|
|
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.
|
|
113
148
|
const IMPORT_RE =
|
|
114
|
-
/(?:^|[\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
|
+
}
|
|
115
172
|
|
|
116
173
|
/** The one bare specifier a pure config may import: core's identity helpers and types. */
|
|
117
174
|
const PURE_PACKAGE = '@vzn/vx'
|
|
118
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
|
+
|
|
119
220
|
const decoder = new TextDecoder()
|
|
120
221
|
|
|
121
222
|
/**
|
|
@@ -125,9 +226,10 @@ const decoder = new TextDecoder()
|
|
|
125
226
|
* lexer can prove: a regex literal can contain a quote, and a lexer that
|
|
126
227
|
* misreads one would swallow real code as a string — a false SAFE, the one
|
|
127
228
|
* outcome this module must never produce. Configs do not need regexes;
|
|
128
|
-
* 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]`.
|
|
129
231
|
*/
|
|
130
|
-
export function stripLiterals(source: string): string | null {
|
|
232
|
+
export function stripLiterals(source: string, strings?: string[]): string | null {
|
|
131
233
|
let out = ''
|
|
132
234
|
let i = 0
|
|
133
235
|
const n = source.length
|
|
@@ -149,15 +251,16 @@ export function stripLiterals(source: string): string | null {
|
|
|
149
251
|
}
|
|
150
252
|
if (c === '/') return null
|
|
151
253
|
if (c === "'" || c === '"') {
|
|
152
|
-
i
|
|
254
|
+
const start = ++i
|
|
153
255
|
while (i < n && source[i] !== c) {
|
|
154
256
|
if (source[i] === '\\') i++
|
|
155
257
|
if (source[i] === '\n') return null
|
|
156
258
|
i++
|
|
157
259
|
}
|
|
158
260
|
if (i >= n) return null
|
|
261
|
+
if (strings === undefined) out += ' '
|
|
262
|
+
else out += `\0${strings.push(source.slice(start, i)) - 1}\0`
|
|
159
263
|
i++
|
|
160
|
-
out += ' '
|
|
161
264
|
continue
|
|
162
265
|
}
|
|
163
266
|
if (c === '`') {
|
|
@@ -221,11 +324,6 @@ export function stripLiterals(source: string): string | null {
|
|
|
221
324
|
return out
|
|
222
325
|
}
|
|
223
326
|
|
|
224
|
-
/**
|
|
225
|
-
* The cache key for evaluating `configPath`, or `null` when the config is
|
|
226
|
-
* not provably pure (or its closure cannot be read), in which case the
|
|
227
|
-
* caller evaluates live and stores nothing.
|
|
228
|
-
*/
|
|
229
327
|
/** `git hash-object` of `bytes` (sha1 domain), the identity `Cache.hashFile` returns for a sha1 repo. */
|
|
230
328
|
export function blobOidOf(bytes: Uint8Array): string {
|
|
231
329
|
const hasher = new Bun.CryptoHasher('sha1')
|
|
@@ -234,14 +332,72 @@ export function blobOidOf(bytes: Uint8Array): string {
|
|
|
234
332
|
return hasher.digest('hex')
|
|
235
333
|
}
|
|
236
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
|
+
|
|
237
367
|
function keySeed(workspaceFingerprint: string): bigint {
|
|
238
368
|
return xxh3(
|
|
239
|
-
`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`,
|
|
240
370
|
)
|
|
241
371
|
}
|
|
242
372
|
|
|
243
373
|
const EXPLICIT_EXT = /\.(?:m?[jt]s|cjs|cts)$/
|
|
244
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
|
+
*/
|
|
245
401
|
export async function configEvalKey(a: ConfigEvalKeyArgs): Promise<ConfigEvalKeyResult | null> {
|
|
246
402
|
let h = keySeed(a.workspaceFingerprint)
|
|
247
403
|
const hashOf = a.hashFile ?? (async (_file: string, bytes?: Uint8Array) => blobOidOf(bytes!))
|
|
@@ -251,30 +407,59 @@ export async function configEvalKey(a: ConfigEvalKeyArgs): Promise<ConfigEvalKey
|
|
|
251
407
|
const queue: Array<{ file: string; bytes: Uint8Array }> = [{ file: a.configPath, bytes: a.bytes }]
|
|
252
408
|
while (queue.length > 0) {
|
|
253
409
|
const { file, bytes } = queue.shift()!
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
256
416
|
// A backslash in code position is an identifier escape (`\u0070rocess`
|
|
257
417
|
// IS `process`) — the one spelling the deny-list cannot see. Refuse it.
|
|
258
|
-
if (code
|
|
259
|
-
|
|
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)
|
|
260
425
|
h = xxh3(`${file}\0${identity}`, h)
|
|
261
426
|
closure.push(file)
|
|
262
|
-
for (const
|
|
263
|
-
|
|
264
|
-
|
|
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
|
+
}
|
|
265
432
|
if (!spec.startsWith('./') && !spec.startsWith('../')) return null
|
|
266
|
-
|
|
267
|
-
|
|
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.
|
|
268
442
|
try {
|
|
269
|
-
|
|
270
|
-
// on one call and the real one on another (a tmp workspace under
|
|
271
|
-
// /var vs /private/var), and the key folds the path — a spelling
|
|
272
|
-
// that drifts between runs is a spurious miss and a duplicated
|
|
273
|
-
// memo row.
|
|
274
|
-
resolved = realpathSync(Bun.resolveSync(spec, path.dirname(file)))
|
|
443
|
+
dir ??= realDirOf(file)
|
|
275
444
|
} catch {
|
|
276
445
|
return null
|
|
277
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
|
+
}
|
|
278
463
|
if (visited.has(resolved)) continue
|
|
279
464
|
if (resolved.split(path.sep).includes('node_modules')) return null
|
|
280
465
|
visited.add(resolved)
|
|
@@ -311,3 +496,48 @@ export async function configEvalKeyFromClosure(a: {
|
|
|
311
496
|
for (let i = 0; i < a.closure.length; i++) h = xxh3(`${a.closure[i]}\0${identities[i]}`, h)
|
|
312
497
|
return h.toString(16).padStart(16, '0')
|
|
313
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({
|
|
@@ -67,6 +76,7 @@ interface WorkerReply {
|
|
|
67
76
|
id: number
|
|
68
77
|
ok: boolean
|
|
69
78
|
json: string | null
|
|
79
|
+
nonJson: NonJsonValue[]
|
|
70
80
|
name: string
|
|
71
81
|
message: string
|
|
72
82
|
stack: string | null
|
|
@@ -75,7 +85,7 @@ interface WorkerReply {
|
|
|
75
85
|
}
|
|
76
86
|
|
|
77
87
|
interface Pending {
|
|
78
|
-
resolve: (
|
|
88
|
+
resolve: (reply: WorkerReply) => void
|
|
79
89
|
reject: (err: Error) => void
|
|
80
90
|
}
|
|
81
91
|
|
|
@@ -139,7 +149,7 @@ function acquireWorker(): Worker {
|
|
|
139
149
|
if (p === undefined) return
|
|
140
150
|
pending.delete(msg.id)
|
|
141
151
|
if (msg.ok) {
|
|
142
|
-
p.resolve(msg
|
|
152
|
+
p.resolve(msg)
|
|
143
153
|
return
|
|
144
154
|
}
|
|
145
155
|
// Rebuild the error the config actually threw. Name, message, stack
|
|
@@ -164,11 +174,40 @@ function acquireWorker(): Worker {
|
|
|
164
174
|
return w
|
|
165
175
|
}
|
|
166
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
|
+
|
|
167
204
|
/**
|
|
168
205
|
* Evaluate `configPath` against a fresh module registry and return its
|
|
169
206
|
* default export, JSON round-tripped. `null` means the module had no
|
|
170
207
|
* object default export — the caller owns that error message so it
|
|
171
|
-
* 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.
|
|
172
211
|
*/
|
|
173
212
|
export async function evaluateConfigFresh(configPath: string): Promise<unknown> {
|
|
174
213
|
const abs = path.resolve(configPath)
|
|
@@ -186,7 +225,7 @@ export async function evaluateConfigFresh(configPath: string): Promise<unknown>
|
|
|
186
225
|
// run-level timeout. The budget is enormous next to the ~10 ms a real
|
|
187
226
|
// evaluation costs, so it can only fire on a genuine wedge.
|
|
188
227
|
const budget = workerTimeoutMs()
|
|
189
|
-
const
|
|
228
|
+
const reply = await new Promise<WorkerReply>((resolve, reject) => {
|
|
190
229
|
pending.set(id, { resolve, reject })
|
|
191
230
|
timer = setTimeout(() => {
|
|
192
231
|
rejectAll(new Error(`config worker did not answer within ${budget}ms`))
|
|
@@ -198,7 +237,9 @@ export async function evaluateConfigFresh(configPath: string): Promise<unknown>
|
|
|
198
237
|
timer.unref?.()
|
|
199
238
|
w.postMessage({ id, path: abs })
|
|
200
239
|
})
|
|
201
|
-
|
|
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)
|
|
202
243
|
} finally {
|
|
203
244
|
// In the `finally`, not after the await: a REJECTED evaluation — a config
|
|
204
245
|
// with a typo, the common case while editing — would otherwise skip the
|
|
@@ -208,10 +249,6 @@ export async function evaluateConfigFresh(configPath: string): Promise<unknown>
|
|
|
208
249
|
// message naming a budget nobody set for it.
|
|
209
250
|
clearTimeout(timer)
|
|
210
251
|
pending.delete(id)
|
|
211
|
-
|
|
212
|
-
if (inFlight === 0 && worker !== null) {
|
|
213
|
-
worker.terminate()
|
|
214
|
-
worker = null
|
|
215
|
-
}
|
|
252
|
+
retireIfIdle()
|
|
216
253
|
}
|
|
217
254
|
}
|