@vzn/vx 0.0.636 → 0.0.638
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 +39 -11
- package/docs/caching.md +246 -213
- package/docs/cli.md +791 -705
- package/docs/execution.md +68 -76
- package/docs/features.md +47 -42
- package/docs/patterns.md +26 -25
- package/docs/schema.md +392 -205
- package/docs/security.md +76 -39
- package/launcher.sh +43 -0
- package/package.json +5 -5
- package/schemas/cache.json +41 -19
- package/schemas/info.json +23 -11
- package/schemas/last.json +48 -2
- package/schemas/lock.json +1 -1
- package/schemas/plan.json +4 -4
- package/schemas/show.json +5 -3
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +28 -13
- package/src/bin.ts +30 -9
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +124 -53
- package/src/cache/chained-cache.ts +4 -0
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +15 -1
- package/src/cache/inputs.ts +102 -36
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +15 -18
- package/src/cache/layered-cache.ts +20 -102
- package/src/cache/plugin-cache.ts +248 -0
- package/src/cache/policy.ts +32 -27
- package/src/cache/run-history.ts +104 -14
- package/src/cache/run-store.ts +77 -0
- package/src/cache/schema.ts +58 -31
- package/src/cache/scoped-cache.ts +237 -0
- package/src/cli/cache.ts +173 -34
- package/src/cli/completions.ts +11 -7
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +110 -70
- package/src/cli/index.ts +67 -24
- package/src/cli/info.ts +43 -27
- package/src/cli/init.ts +115 -407
- package/src/cli/last.ts +157 -9
- package/src/cli/lock.ts +356 -122
- package/src/cli/plan-format.ts +7 -5
- package/src/cli/plugin-commands.ts +81 -14
- package/src/cli/plugin-templates.ts +11 -11
- package/src/cli/run.ts +116 -160
- package/src/cli/select.ts +50 -47
- package/src/cli/show.ts +83 -59
- package/src/cli/task-verb.ts +6 -13
- package/src/cli/upgrade.ts +415 -149
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +39 -61
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +18 -4
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +227 -230
- package/src/cli/why.ts +38 -18
- package/src/cli/workspace-config.ts +33 -70
- package/src/config.ts +169 -70
- package/src/exec/env.ts +1 -31
- package/src/exec/executor.ts +1 -5
- package/src/exec/kill-tree.ts +2 -1
- package/src/exec/sandbox-runtime.ts +12 -1
- package/src/exec/sandbox-violations.ts +9 -1
- package/src/graph/dependency-spec.ts +1 -1
- package/src/graph/index.ts +1 -0
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +27 -7
- package/src/graph/task-graph.ts +76 -24
- package/src/handoff.ts +169 -0
- package/src/index.ts +35 -58
- package/src/orchestrator/affected-tasks.ts +15 -8
- package/src/orchestrator/command-windows.ts +44 -0
- package/src/orchestrator/deferred-outputs.ts +12 -10
- package/src/orchestrator/doctor.ts +98 -110
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/events.ts +19 -3
- package/src/orchestrator/excluded-keys.ts +0 -2
- package/src/orchestrator/execute-task.ts +147 -69
- package/src/orchestrator/failure-mode.ts +19 -10
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +9 -5
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +126 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +20 -5
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +80 -41
- package/src/orchestrator/metrics.ts +78 -9
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +42 -29
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +100 -66
- package/src/orchestrator/plugin.ts +173 -67
- package/src/orchestrator/prepare.ts +163 -78
- package/src/orchestrator/projects.ts +105 -99
- package/src/orchestrator/remote-prefetch.ts +11 -6
- package/src/orchestrator/run-context.ts +25 -6
- package/src/orchestrator/run-failures.ts +17 -8
- package/src/orchestrator/run-lock.ts +1 -1
- package/src/orchestrator/run-records.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +244 -119
- package/src/orchestrator/sandbox-fix.ts +39 -20
- package/src/orchestrator/sandbox-request.ts +84 -13
- package/src/orchestrator/signals.ts +1 -1
- package/src/orchestrator/stable-keys.ts +13 -11
- package/src/orchestrator/status-line.ts +49 -7
- package/src/orchestrator/summary.ts +115 -106
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +4 -4
- package/src/settings.ts +428 -0
- package/src/{orchestrator → util}/colors.ts +5 -0
- package/src/util/config-frame.ts +31 -2
- package/src/util/errors.ts +33 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +16 -3
- package/src/util/paths.ts +10 -0
- package/src/util/refusal-notice.ts +111 -0
- package/src/util/report.ts +31 -0
- package/src/util/settle.ts +49 -30
- package/src/util/size.ts +23 -4
- package/src/util/timing.ts +3 -2
- package/src/util/ui.ts +186 -0
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +84 -232
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +209 -268
- package/src/workspace/dependency-rules.ts +65 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +20 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +307 -215
- package/src/workspace/package-graph.ts +30 -216
- package/src/workspace/project-loader.ts +80 -285
- package/src/workspace/repo-id.ts +2 -1
- package/src/workspace/workspace.ts +363 -831
- package/src/cache/config-evals.ts +0 -130
- package/src/workspace/config-cache.ts +0 -661
- package/src/workspace/migrate-scripts.ts +0 -1121
|
@@ -1,661 +0,0 @@
|
|
|
1
|
-
// Config evaluation cache — skip re-evaluating a `vx.config` that cannot
|
|
2
|
-
// have changed.
|
|
3
|
-
//
|
|
4
|
-
// A config is a program, and evaluating a thousand of them is the largest
|
|
5
|
-
// fixed cost of a warm run (measured 2026-09-02: ~80 ms to import 1000
|
|
6
|
-
// synthetic configs, ~12 ms to read the same files as data). The cache
|
|
7
|
-
// stores the VALIDATED, JSON-serialised result keyed by everything the
|
|
8
|
-
// evaluation could have observed:
|
|
9
|
-
//
|
|
10
|
-
// - the config's bytes and the bytes of every file it transitively imports
|
|
11
|
-
// by RELATIVE specifier (the closure), paths included;
|
|
12
|
-
// - the workspace fingerprint (lockfiles), which covers package imports;
|
|
13
|
-
// - Bun's version, vx's version and this module's own version. A stored
|
|
14
|
-
// evaluation is served WITHOUT re-validation, so it must never outlive
|
|
15
|
-
// the validator that accepted it — a vx upgrade that tightens a rule
|
|
16
|
-
// re-evaluates every config once.
|
|
17
|
-
//
|
|
18
|
-
// It applies only to configs that are PROVABLY pure by a conservative static
|
|
19
|
-
// check: every import is relative or `@vzn/vx` (whose `defineProject` /
|
|
20
|
-
// `defineWorkspace` are identity functions), and no file in the closure
|
|
21
|
-
// mentions a global through which the environment can leak — `process`,
|
|
22
|
-
// `Bun`, `Date`, `fetch`, `import.meta`, `require`, a dynamic `import()`,
|
|
23
|
-
// `await`, … Anything else evaluates live, exactly as before. The check
|
|
24
|
-
// fails SAFE, never fast: a false negative costs one evaluation, a false
|
|
25
|
-
// positive would cost a stale key, so the deny-list is deliberately wide.
|
|
26
|
-
//
|
|
27
|
-
// JSON is already the contract for a config object — `hashTaskConfig` and
|
|
28
|
-
// `vx lock` both go through `JSON.stringify` — so a cached config derives
|
|
29
|
-
// the same cache key as a live evaluation of the same bytes.
|
|
30
|
-
|
|
31
|
-
import { lstatSync, readFileSync, statSync } from 'node:fs'
|
|
32
|
-
import path from 'node:path'
|
|
33
|
-
import { realPath, xxh3 } from '../util/index.js'
|
|
34
|
-
import { VERSION } from '../version.js'
|
|
35
|
-
|
|
36
|
-
/** Bump when the key derivation or the stored shape changes. */
|
|
37
|
-
export const CONFIG_EVAL_VERSION = 3
|
|
38
|
-
|
|
39
|
-
/** Where cached evaluations live; `Cache` implements it over `cache.db`. */
|
|
40
|
-
export interface ConfigEvalStore {
|
|
41
|
-
/**
|
|
42
|
-
* The warm fast path (all optional; a store without them keys by bytes).
|
|
43
|
-
* `hashFile` is the file's git blob id behind an mtime/size/ctime/inode
|
|
44
|
-
* memo — no read when unchanged; `getConfigClosures` / `putConfigClosure`
|
|
45
|
-
* keep each config's ORDERED closure (the config first, then every
|
|
46
|
-
* relative import in discovery order), so a warm load keys the config by
|
|
47
|
-
* stat-hashing that list instead of reading and scanning every file
|
|
48
|
-
* (1,000 configs: 5 ms against 15, measured 2026-09-03). Sound because
|
|
49
|
-
* closure membership can only change by editing a listed file, which
|
|
50
|
-
* changes that file's hash and so the key; the one exception, an
|
|
51
|
-
* extensionless relative import whose resolution a new file could
|
|
52
|
-
* shadow, is never indexed.
|
|
53
|
-
*/
|
|
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
|
|
71
|
-
getConfigClosures?(configPaths: readonly string[]): Map<string, string[]>
|
|
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
|
|
80
|
-
getConfigEval(key: string): string | null
|
|
81
|
-
/**
|
|
82
|
-
* Many keys in one round-trip (optional; a store without it is asked per
|
|
83
|
-
* key). A warm 1000-project run paid 3.6 ms for 1,000 point lookups where
|
|
84
|
-
* one `IN` query costs 0.7 (measured 2026-09-03).
|
|
85
|
-
*/
|
|
86
|
-
getConfigEvals?(keys: readonly string[]): Map<string, string>
|
|
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
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** What `configEvalKey` learned besides the key, for the store's closure index. */
|
|
93
|
-
export interface ConfigEvalKeyResult {
|
|
94
|
-
key: string
|
|
95
|
-
/** The config first, then every relative import in discovery order. */
|
|
96
|
-
closure: string[]
|
|
97
|
-
/** False when a relative import is extensionless — a new file could change its resolution. */
|
|
98
|
-
indexable: boolean
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export interface ConfigEvalKeyArgs {
|
|
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
|
|
105
|
-
/**
|
|
106
|
-
* Per-file identity, the same function the store's warm path uses. Absent
|
|
107
|
-
* (tests, a store without one), the git blob id is computed from the
|
|
108
|
-
* bytes in-process.
|
|
109
|
-
*/
|
|
110
|
-
hashFile?: (file: string) => Promise<string>
|
|
111
|
-
bytes: Uint8Array
|
|
112
|
-
/**
|
|
113
|
-
* The workspace root, as the config path spells it. A symlink BELOW it on
|
|
114
|
-
* the way to the config makes the closure unindexable; one above it
|
|
115
|
-
* (macOS's `/var`) moves nothing the warm path could miss. Absent, any
|
|
116
|
-
* symlink on the way does.
|
|
117
|
-
*/
|
|
118
|
-
workspaceRoot?: string | undefined
|
|
119
|
-
workspaceFingerprint: string
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** Closure files beyond this count evaluate live — a preset tree this deep is not the case this serves. */
|
|
123
|
-
const MAX_CLOSURE_FILES = 32
|
|
124
|
-
|
|
125
|
-
// Globals through which an evaluation can observe something the file bytes
|
|
126
|
-
// do not capture. Tested against the source with string literals and
|
|
127
|
-
// comments removed (`stripLiterals`), because `node -e "process.exit(0)"`
|
|
128
|
-
// is an ordinary command, not an impure config.
|
|
129
|
-
// `global` and `self` are live objects in Bun (aliases of `globalThis`), so
|
|
130
|
-
// a computed `global['proc' + 'ess']` reaches process without ever
|
|
131
|
-
// spelling it; `Temporal` is a clock. All three were CACHED AS PURE before
|
|
132
|
-
// they were listed (2026-09-03). `constructor` reaches `Function` through a
|
|
133
|
-
// property name (`({}).constructor.constructor('return process')()`, the
|
|
134
|
-
// body hidden in a literal the strip removes) and `localeCompare` answers
|
|
135
|
-
// by the host locale; both listed 2026-09-09. The reflective primitives
|
|
136
|
-
// reach `Function` with no denied word anywhere — a prototype's property
|
|
137
|
-
// names hold `constructor` without the code spelling it
|
|
138
|
-
// (`Object.getOwnPropertyNames(Object.getPrototypeOf(() => 0))`), and a
|
|
139
|
-
// literal naming it was stripped before the test ran; listed 2026-09-27
|
|
140
|
-
// (item 957). A key assembled at run time (`'constru' + 'ctor'`) still
|
|
141
|
-
// passes: the gate stops accidental impurity, not a config written to
|
|
142
|
-
// defeat it. `random` is denied as a word, not as `Math.random`: a
|
|
143
|
-
// destructured `const { random } = Math` was cached as pure (item 1036).
|
|
144
|
-
// `prompt`, `confirm` and `alert` read the user's terminal, and a cached
|
|
145
|
-
// evaluation replayed the first answer (D-25). A CommonJS config reaches
|
|
146
|
-
// `require` without naming it as `arguments[1]`, and one reading a file
|
|
147
|
-
// outside its closure so replayed the old bytes (D-36).
|
|
148
|
-
const IMPURE_RE =
|
|
149
|
-
/\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__|random|prompt|confirm|alert|arguments|Worker)\b|import\s*\.\s*meta|\bimport\s*\(/
|
|
150
|
-
|
|
151
|
-
/** Literal text that names a way to `Function` when used as a computed key. */
|
|
152
|
-
const IMPURE_LITERAL_RE = /constructor|__proto__|prototype/
|
|
153
|
-
|
|
154
|
-
// Static `import … from '…'` / `export … from '…'` / `import '…'` forms,
|
|
155
|
-
// matched on `stripLiterals` output with its strings kept as placeholders
|
|
156
|
-
// (`\0<n>\0`): a comment is gone, so an import after `/* … */` on its line
|
|
157
|
-
// is seen, and a string-named binding (`import { 'a-b' as x }`) is a
|
|
158
|
-
// placeholder like any other. The body spans newlines, so multi-line
|
|
159
|
-
// specifier lists match, but never another statement's `import` or
|
|
160
|
-
// `export`: in a file without semicolons a lazy `[^;]*?` ran from
|
|
161
|
-
// `export type Mode = …` on to the next line's `from '@vzn/vx'`, and the
|
|
162
|
-
// purity check read `type` and passed an impure import. Nor is a line
|
|
163
|
-
// start required: `} export * from './side.ts'` went unscanned, and the
|
|
164
|
-
// fail-closed count below counted only imports (item 1036).
|
|
165
|
-
const IMPORT_RE =
|
|
166
|
-
/\b(?:import|export)\b(?:(?!\b(?:import|export)\b)[^;])*?\bfrom\s*\0(\d+)\0|\bimport\s*\0(\d+)\0/g
|
|
167
|
-
|
|
168
|
-
// `import` as a keyword. Followed by `:` it is a property key (a task named
|
|
169
|
-
// `import`): no import form, static or dynamic, puts a colon after the word.
|
|
170
|
-
const IMPORT_WORD_RE = /\bimport\b(?!\s*:)/g
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Every static import in `source` as its specifier and statement, or `null`
|
|
174
|
-
* when the source cannot be lexed or holds an `import` no form above
|
|
175
|
-
* accounts for — a spelling this scan misses must fail closed: an unseen
|
|
176
|
-
* import is neither keyed nor gated for purity (item 952).
|
|
177
|
-
*/
|
|
178
|
-
function staticImports(
|
|
179
|
-
source: string,
|
|
180
|
-
): { code: string; strings: string[]; imports: Array<{ spec: string; statement: string }> } | null {
|
|
181
|
-
const strings: string[] = []
|
|
182
|
-
const code = stripLiterals(source, strings)
|
|
183
|
-
if (code === null) return null
|
|
184
|
-
const imports: Array<{ spec: string; statement: string }> = []
|
|
185
|
-
let seen = 0
|
|
186
|
-
for (const m of code.matchAll(IMPORT_RE)) {
|
|
187
|
-
imports.push({ spec: strings[Number(m[1] ?? m[2])]!, statement: m[0] })
|
|
188
|
-
seen += m[0].match(IMPORT_WORD_RE)?.length ?? 0
|
|
189
|
-
}
|
|
190
|
-
if (seen !== (code.match(IMPORT_WORD_RE)?.length ?? 0)) return null
|
|
191
|
-
return { code, strings, imports }
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/** The one bare specifier a pure config may import: core's identity helpers and types. */
|
|
195
|
-
const PURE_PACKAGE = '@vzn/vx'
|
|
196
|
-
/** The schema alone (`src/config.ts`): every value it exports is pure. */
|
|
197
|
-
const PURE_CONFIG_ENTRY = '@vzn/vx/config'
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* The `@vzn/vx` values a pure config may import. Core exports far more, and
|
|
201
|
-
* some of it reads the machine: a config calling `machineParallelism()` was
|
|
202
|
-
* replayed from the store on a box with another core count, and its tasks
|
|
203
|
-
* kept the key the first box's answer gave them (item 888). Types are
|
|
204
|
-
* always fine; a name outside this list, a namespace or default import, or
|
|
205
|
-
* an `export *` of the package evaluates live.
|
|
206
|
-
*/
|
|
207
|
-
export const PURE_CORE_EXPORTS: ReadonlySet<string> = new Set([
|
|
208
|
-
'defineProject',
|
|
209
|
-
'defineWorkspace',
|
|
210
|
-
'splitTaskId',
|
|
211
|
-
'normalizeGlob',
|
|
212
|
-
'isLiteralPattern',
|
|
213
|
-
'PLUGIN_HOOKS',
|
|
214
|
-
])
|
|
215
|
-
|
|
216
|
-
/** Whether an `import`/`export … from '@vzn/vx'` statement takes only pure values. */
|
|
217
|
-
function importsOnlyPure(statement: string): boolean {
|
|
218
|
-
const clause = statement
|
|
219
|
-
.replace(/^[\s;]*(?:import|export)\s+/, '')
|
|
220
|
-
.replace(/\bfrom\s*\0\d+\0\s*$/, '')
|
|
221
|
-
if (/^type\b/.test(clause)) return true
|
|
222
|
-
if (clause.includes('*')) return false
|
|
223
|
-
const braces = /\{([^}]*)\}/.exec(clause)
|
|
224
|
-
// Anything outside the braces is a default import.
|
|
225
|
-
if (
|
|
226
|
-
clause
|
|
227
|
-
.replace(/\{[^}]*\}/, '')
|
|
228
|
-
.replace(/,/g, '')
|
|
229
|
-
.trim() !== ''
|
|
230
|
-
)
|
|
231
|
-
return false
|
|
232
|
-
for (const part of (braces?.[1] ?? '').split(',')) {
|
|
233
|
-
const spec = part.trim()
|
|
234
|
-
if (spec === '' || /^type\s/.test(spec)) continue
|
|
235
|
-
const name = spec.split(/\s+as\s+/)[0]!.trim()
|
|
236
|
-
if (!PURE_CORE_EXPORTS.has(name)) return false
|
|
237
|
-
}
|
|
238
|
-
return true
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const decoder = new TextDecoder()
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* `source` with every string literal, template literal and comment removed
|
|
245
|
-
* (template `${…}` expressions are kept — they are code). Returns `null`
|
|
246
|
-
* when a `/` in code position is neither a comment nor a division the
|
|
247
|
-
* lexer can prove: a regex literal can contain a quote, and a lexer that
|
|
248
|
-
* misreads one would swallow real code as a string — a false SAFE, the one
|
|
249
|
-
* outcome this module must never produce. Configs do not need regexes;
|
|
250
|
-
* such a config evaluates live. Given `strings`, a quoted string becomes
|
|
251
|
-
* the placeholder `\0<n>\0` and its text `strings[n]`.
|
|
252
|
-
*/
|
|
253
|
-
export function stripLiterals(source: string, strings?: string[]): string | null {
|
|
254
|
-
let out = ''
|
|
255
|
-
let i = 0
|
|
256
|
-
const n = source.length
|
|
257
|
-
// Brace depth per open template expression, so a `}` closing the
|
|
258
|
-
// expression is told apart from one closing an object literal inside it.
|
|
259
|
-
const templateDepth: number[] = []
|
|
260
|
-
while (i < n) {
|
|
261
|
-
// Plain code is copied a run at a time, up to the next `/`, quote or
|
|
262
|
-
// backtick: a character at a time took 9 ms over 1,000 cold configs,
|
|
263
|
-
// runs take 2 (2026-10-03).
|
|
264
|
-
if (templateDepth.length === 0) {
|
|
265
|
-
let j = i
|
|
266
|
-
while (j < n) {
|
|
267
|
-
const k = source.charCodeAt(j)
|
|
268
|
-
if (k === 0x2f || k === 0x27 || k === 0x22 || k === 0x60) break
|
|
269
|
-
j++
|
|
270
|
-
}
|
|
271
|
-
if (j > i) {
|
|
272
|
-
out += source.slice(i, j)
|
|
273
|
-
i = j
|
|
274
|
-
continue
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
const c = source[i]!
|
|
278
|
-
const next = source[i + 1]
|
|
279
|
-
if (c === '/' && next === '/') {
|
|
280
|
-
while (i < n && source[i] !== '\n') i++
|
|
281
|
-
continue
|
|
282
|
-
}
|
|
283
|
-
if (c === '/' && next === '*') {
|
|
284
|
-
const end = source.indexOf('*/', i + 2)
|
|
285
|
-
if (end === -1) return null
|
|
286
|
-
i = end + 2
|
|
287
|
-
continue
|
|
288
|
-
}
|
|
289
|
-
if (c === '/') return null
|
|
290
|
-
if (c === "'" || c === '"') {
|
|
291
|
-
const start = ++i
|
|
292
|
-
while (i < n && source[i] !== c) {
|
|
293
|
-
if (source[i] === '\\') i++
|
|
294
|
-
if (source[i] === '\n') return null
|
|
295
|
-
i++
|
|
296
|
-
}
|
|
297
|
-
if (i >= n) return null
|
|
298
|
-
if (strings === undefined) out += ' '
|
|
299
|
-
else out += `\0${strings.push(source.slice(start, i)) - 1}\0`
|
|
300
|
-
i++
|
|
301
|
-
continue
|
|
302
|
-
}
|
|
303
|
-
if (c === '`') {
|
|
304
|
-
i++
|
|
305
|
-
for (;;) {
|
|
306
|
-
if (i >= n) return null
|
|
307
|
-
const t = source[i]!
|
|
308
|
-
if (t === '\\') {
|
|
309
|
-
i += 2
|
|
310
|
-
continue
|
|
311
|
-
}
|
|
312
|
-
if (t === '`') {
|
|
313
|
-
i++
|
|
314
|
-
break
|
|
315
|
-
}
|
|
316
|
-
if (t === '$' && source[i + 1] === '{') {
|
|
317
|
-
templateDepth.push(0)
|
|
318
|
-
i += 2
|
|
319
|
-
out += ' '
|
|
320
|
-
break
|
|
321
|
-
}
|
|
322
|
-
i++
|
|
323
|
-
}
|
|
324
|
-
continue
|
|
325
|
-
}
|
|
326
|
-
if (templateDepth.length > 0) {
|
|
327
|
-
const top = templateDepth.length - 1
|
|
328
|
-
if (c === '{') templateDepth[top]!++
|
|
329
|
-
else if (c === '}') {
|
|
330
|
-
if (templateDepth[top] === 0) {
|
|
331
|
-
// Back into the template literal: resume scanning its text.
|
|
332
|
-
templateDepth.pop()
|
|
333
|
-
i++
|
|
334
|
-
for (;;) {
|
|
335
|
-
if (i >= n) return null
|
|
336
|
-
const t = source[i]!
|
|
337
|
-
if (t === '\\') {
|
|
338
|
-
i += 2
|
|
339
|
-
continue
|
|
340
|
-
}
|
|
341
|
-
if (t === '`') {
|
|
342
|
-
i++
|
|
343
|
-
break
|
|
344
|
-
}
|
|
345
|
-
if (t === '$' && source[i + 1] === '{') {
|
|
346
|
-
templateDepth.push(0)
|
|
347
|
-
i += 2
|
|
348
|
-
break
|
|
349
|
-
}
|
|
350
|
-
i++
|
|
351
|
-
}
|
|
352
|
-
out += ' '
|
|
353
|
-
continue
|
|
354
|
-
}
|
|
355
|
-
templateDepth[top]!--
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
out += c
|
|
359
|
-
i++
|
|
360
|
-
}
|
|
361
|
-
return out
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
/** `git hash-object` of `bytes` (sha1 domain), the identity `Cache.hashFile` returns for a sha1 repo. */
|
|
365
|
-
export function blobOidOf(bytes: Uint8Array): string {
|
|
366
|
-
const hasher = new Bun.CryptoHasher('sha1')
|
|
367
|
-
hasher.update(`blob ${bytes.byteLength}\0`)
|
|
368
|
-
hasher.update(bytes)
|
|
369
|
-
return hasher.digest('hex')
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
let transpileMemo: string | undefined
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* What this process's transpiler was told besides the file bytes: the
|
|
376
|
-
* `bunfig.toml` Bun loaded at startup (the cwd's, and the global one) and
|
|
377
|
-
* the flags it ran with (`--define`, `--preload`, directly or through
|
|
378
|
-
* `BUN_OPTIONS`). A `[define]` value is a bare identifier to the config, so
|
|
379
|
-
* no deny word sees it, and flipping `BUILD_MODE` from `dev` to `prod`
|
|
380
|
-
* replayed the `dev` evaluation (item 956). Read once per process: a
|
|
381
|
-
* running Bun does not reload them either. A bunfig the process never
|
|
382
|
-
* loaded (a compiled binary's) only costs a miss when it changes.
|
|
383
|
-
*/
|
|
384
|
-
export function transpileInputs(): string {
|
|
385
|
-
if (transpileMemo !== undefined) return transpileMemo
|
|
386
|
-
const home = process.env['HOME']
|
|
387
|
-
const xdg = process.env['XDG_CONFIG_HOME']
|
|
388
|
-
const files = [
|
|
389
|
-
path.join(process.cwd(), 'bunfig.toml'),
|
|
390
|
-
...(xdg !== undefined && xdg !== '' ? [path.join(xdg, '.bunfig.toml')] : []),
|
|
391
|
-
...(home !== undefined && home !== '' ? [path.join(home, '.bunfig.toml')] : []),
|
|
392
|
-
]
|
|
393
|
-
const parts = [process.execArgv.join('\0'), process.env['BUN_OPTIONS'] ?? '']
|
|
394
|
-
for (const file of files) {
|
|
395
|
-
try {
|
|
396
|
-
parts.push(`${file}\0${blobOidOf(readFileSync(file))}`)
|
|
397
|
-
} catch {
|
|
398
|
-
// Absent: nothing loaded from it.
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
return (transpileMemo = xxh3(parts.join('\0')).toString(16))
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
function keySeed(workspaceFingerprint: string): bigint {
|
|
405
|
-
return xxh3(
|
|
406
|
-
`vx-config-eval-v${CONFIG_EVAL_VERSION}\0${VERSION}\0${Bun.version}\0${workspaceFingerprint}\0${transpileInputs()}\0`,
|
|
407
|
-
)
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
const EXPLICIT_EXT = /\.(?:m?[jt]s|cjs|cts)$/
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* A file's identity as the key folds it. `hashFile` names an executable
|
|
414
|
-
* `100755:<oid>`, the slow path's `hashBytes` the bare oid; the mode moves
|
|
415
|
-
* no evaluation, and with it the warm key of an executable config never
|
|
416
|
-
* met the stored one, so every load read and re-keyed it.
|
|
417
|
-
*/
|
|
418
|
-
function contentIdentity(identity: string): string {
|
|
419
|
-
return identity.startsWith('100755:') ? identity.slice(7) : identity
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/**
|
|
423
|
-
* The canonical directory Bun resolves `file`'s imports from: its REAL
|
|
424
|
-
* path's, so a config linked in from elsewhere imports its neighbours
|
|
425
|
-
* there, not beside the link (item 950). The directory is real-pathed
|
|
426
|
-
* rather than the file, whose realpath opens it: a config is opened once
|
|
427
|
-
* per run (`read-once.unsafe.test.ts`).
|
|
428
|
-
*/
|
|
429
|
-
function realDirOf(file: string): string {
|
|
430
|
-
if (lstatSync(file).isSymbolicLink()) return path.dirname(realPath(file))
|
|
431
|
-
return realPath(path.dirname(file))
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
/** Each workspace root's real path, asked once per process. */
|
|
435
|
-
const realRoots = new Map<string, string>()
|
|
436
|
-
|
|
437
|
-
/**
|
|
438
|
-
* Whether `realDir`, the real directory of `file`, is where its spelling
|
|
439
|
-
* says it is once the root's own links are resolved. A member directory
|
|
440
|
-
* linked in from elsewhere is not: the warm path keys the closure by the
|
|
441
|
-
* paths the last evaluation found, and a link retargeted to a config of
|
|
442
|
-
* the same bytes whose preset differs moves none of them (item 1036).
|
|
443
|
-
*/
|
|
444
|
-
function reachedThroughNoLink(file: string, realDir: string, root: string | undefined): boolean {
|
|
445
|
-
const dir = path.dirname(file)
|
|
446
|
-
// Without a root there is nothing to tell a member's own link from one
|
|
447
|
-
// above the tree (macOS's `/tmp` → `/private/tmp`), and comparing the
|
|
448
|
-
// spelling with the real path refused every config under such a link.
|
|
449
|
-
// Every run passes its root; a caller that does not keeps the index.
|
|
450
|
-
if (root === undefined) return true
|
|
451
|
-
let realRoot = realRoots.get(root)
|
|
452
|
-
if (realRoot === undefined) {
|
|
453
|
-
realRoot = realPath(root)
|
|
454
|
-
realRoots.set(root, realRoot)
|
|
455
|
-
}
|
|
456
|
-
return path.relative(realRoot, realDir) === path.relative(root, dir)
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
/** A regular file reached through no symlink: the path IS the file. */
|
|
460
|
-
function isCanonicalFile(file: string): boolean {
|
|
461
|
-
try {
|
|
462
|
-
return realPath(file) === file && statSync(file).isFile()
|
|
463
|
-
} catch {
|
|
464
|
-
return false
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
/**
|
|
469
|
-
* The cache key for evaluating `configPath`, or `null` when the config is
|
|
470
|
-
* not provably pure (or its closure cannot be read), in which case the
|
|
471
|
-
* caller evaluates live and stores nothing.
|
|
472
|
-
*/
|
|
473
|
-
export async function configEvalKey(a: ConfigEvalKeyArgs): Promise<ConfigEvalKeyResult | null> {
|
|
474
|
-
let h = keySeed(a.workspaceFingerprint)
|
|
475
|
-
const hashOf = a.hashFile ?? (async (_file: string, bytes?: Uint8Array) => blobOidOf(bytes!))
|
|
476
|
-
const visited = new Set<string>([a.configPath])
|
|
477
|
-
const closure: string[] = []
|
|
478
|
-
let indexable = true
|
|
479
|
-
const queue: Array<{ file: string; bytes: Uint8Array }> = [{ file: a.configPath, bytes: a.bytes }]
|
|
480
|
-
while (queue.length > 0) {
|
|
481
|
-
const { file, bytes } = queue.shift()!
|
|
482
|
-
// Asked of the first relative import only: a config with none costs no
|
|
483
|
-
// syscall for it.
|
|
484
|
-
let dir: string | undefined
|
|
485
|
-
const scanned = staticImports(decoder.decode(bytes))
|
|
486
|
-
if (scanned === null) return null
|
|
487
|
-
const { code } = scanned
|
|
488
|
-
// A backslash in code position is an identifier escape (`\u0070rocess`
|
|
489
|
-
// IS `process`) — the one spelling the deny-list cannot see. Refuse it.
|
|
490
|
-
if (code.includes('\\') || IMPURE_RE.test(code)) return null
|
|
491
|
-
if (scanned.strings.some((t) => IMPURE_LITERAL_RE.test(t))) return null
|
|
492
|
-
const identity = a.hashBytes
|
|
493
|
-
? a.hashBytes(bytes, file)
|
|
494
|
-
: a.hashFile
|
|
495
|
-
? contentIdentity(await a.hashFile(file))
|
|
496
|
-
: await hashOf(file, bytes)
|
|
497
|
-
h = xxh3(`${file}\0${identity}`, h)
|
|
498
|
-
closure.push(file)
|
|
499
|
-
for (const { spec, statement } of scanned.imports) {
|
|
500
|
-
if (spec === PURE_CONFIG_ENTRY) continue
|
|
501
|
-
if (spec === PURE_PACKAGE) {
|
|
502
|
-
if (/\bfrom\s*\0\d+\0\s*$/.test(statement) && !importsOnlyPure(statement)) return null
|
|
503
|
-
continue
|
|
504
|
-
}
|
|
505
|
-
if (!spec.startsWith('./') && !spec.startsWith('../')) return null
|
|
506
|
-
// The warm path re-hashes the files this resolution found and never
|
|
507
|
-
// resolves again, so it holds only when the specifier names its file
|
|
508
|
-
// outright: an explicit extension, no symlink on the way. A link
|
|
509
|
-
// retargeted without touching a listed file, or Bun's `.js` → `.ts`
|
|
510
|
-
// fallback (a `preset.js` created beside `preset.ts` takes over),
|
|
511
|
-
// moves the answer; such a closure is keyed but never indexed
|
|
512
|
-
// (item 950). The named file is taken without `Bun.resolveSync`,
|
|
513
|
-
// whose directory cache answers a retargeted link with its old
|
|
514
|
-
// target for the rest of the process.
|
|
515
|
-
if (dir === undefined) {
|
|
516
|
-
try {
|
|
517
|
-
dir = realDirOf(file)
|
|
518
|
-
if (file === a.configPath && !reachedThroughNoLink(file, dir, a.workspaceRoot)) {
|
|
519
|
-
indexable = false
|
|
520
|
-
}
|
|
521
|
-
} catch {
|
|
522
|
-
return null
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
const named = path.resolve(dir, spec)
|
|
526
|
-
let resolved: string
|
|
527
|
-
if (EXPLICIT_EXT.test(spec) && isCanonicalFile(named)) resolved = named
|
|
528
|
-
else {
|
|
529
|
-
indexable = false
|
|
530
|
-
try {
|
|
531
|
-
// Real-pathed: `Bun.resolveSync` answers with the symlinked
|
|
532
|
-
// spelling on one call and the real one on another (a tmp
|
|
533
|
-
// workspace under /var vs /private/var), and the key folds the
|
|
534
|
-
// path — a spelling that drifts between runs is a spurious miss
|
|
535
|
-
// and a duplicated memo row.
|
|
536
|
-
resolved = realPath(Bun.resolveSync(spec, dir))
|
|
537
|
-
} catch {
|
|
538
|
-
return null
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
if (visited.has(resolved)) continue
|
|
542
|
-
if (resolved.split(path.sep).includes('node_modules')) return null
|
|
543
|
-
visited.add(resolved)
|
|
544
|
-
if (visited.size > MAX_CLOSURE_FILES) return null
|
|
545
|
-
try {
|
|
546
|
-
queue.push({ file: resolved, bytes: await Bun.file(resolved).bytes() })
|
|
547
|
-
} catch {
|
|
548
|
-
return null
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
return { key: h.toString(16).padStart(16, '0'), closure, indexable }
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// `hashFile` names a symlink by its target string (git's mode 120000), not
|
|
556
|
-
// the bytes the evaluation read, so no stored key can match a fold of it.
|
|
557
|
-
// Only a linked config with no relative import is indexed at all; with no
|
|
558
|
-
// warm key it joins the round lookup on its slow key instead of paying a
|
|
559
|
-
// single lookup and an index rewrite per load. The identity is from the
|
|
560
|
-
// lstat `hashFiles` already took: no syscall is added.
|
|
561
|
-
const LINK_IDENTITY = '120000:'
|
|
562
|
-
|
|
563
|
-
/**
|
|
564
|
-
* The warm path: the key for a config whose ordered closure the store
|
|
565
|
-
* remembers, from per-file identities alone — no read, no scan. The fold is
|
|
566
|
-
* byte-identical to `configEvalKey`'s, so the two paths share entries. A
|
|
567
|
-
* changed or vanished file changes its identity and misses, and the slow
|
|
568
|
-
* path then re-indexes.
|
|
569
|
-
*/
|
|
570
|
-
export async function configEvalKeyFromClosure(a: {
|
|
571
|
-
closure: readonly string[]
|
|
572
|
-
hashFile: (file: string) => Promise<string>
|
|
573
|
-
workspaceFingerprint: string
|
|
574
|
-
}): Promise<string | null> {
|
|
575
|
-
let h = keySeed(a.workspaceFingerprint)
|
|
576
|
-
let identities: string[]
|
|
577
|
-
try {
|
|
578
|
-
identities = await Promise.all(a.closure.map((f) => a.hashFile(f)))
|
|
579
|
-
} catch {
|
|
580
|
-
return null
|
|
581
|
-
}
|
|
582
|
-
if (identities.some((id) => id.startsWith(LINK_IDENTITY))) return null
|
|
583
|
-
for (let i = 0; i < a.closure.length; i++) {
|
|
584
|
-
h = xxh3(`${a.closure[i]}\0${contentIdentity(identities[i]!)}`, h)
|
|
585
|
-
}
|
|
586
|
-
return h.toString(16).padStart(16, '0')
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/** `configEvalKeyFromClosure` over identities already in hand; null when one is missing. */
|
|
590
|
-
export function configEvalKeyFromIdentities(a: {
|
|
591
|
-
closure: readonly string[]
|
|
592
|
-
identities: ReadonlyMap<string, string>
|
|
593
|
-
workspaceFingerprint: string
|
|
594
|
-
}): string | null {
|
|
595
|
-
let h = keySeed(a.workspaceFingerprint)
|
|
596
|
-
for (const file of a.closure) {
|
|
597
|
-
const id = a.identities.get(file)
|
|
598
|
-
if (id === undefined || id.startsWith(LINK_IDENTITY)) return null
|
|
599
|
-
h = xxh3(`${file}\0${contentIdentity(id)}`, h)
|
|
600
|
-
}
|
|
601
|
-
return h.toString(16).padStart(16, '0')
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
/**
|
|
605
|
-
* Every file a config imports by relative specifier, transitively, outside
|
|
606
|
-
* `node_modules`, the config itself excluded. `vx watch` watches these: a
|
|
607
|
-
* shared preset outside the project (`../../shared/preset.mjs`, the way
|
|
608
|
-
* configs compose) changed what a run evaluates and no watcher saw it
|
|
609
|
-
* (item 949). A file that cannot be read or resolved ends its branch; one
|
|
610
|
-
* that cannot be resolved is still listed, by the name it would have.
|
|
611
|
-
*/
|
|
612
|
-
// The names an extensionless relative import may resolve to, as Bun tries them.
|
|
613
|
-
const IMPORT_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json']
|
|
614
|
-
|
|
615
|
-
export async function configImports(configPath: string): Promise<string[]> {
|
|
616
|
-
const seen = new Set<string>([configPath])
|
|
617
|
-
const out: string[] = []
|
|
618
|
-
const queue = [configPath]
|
|
619
|
-
while (queue.length > 0) {
|
|
620
|
-
const file = queue.shift()!
|
|
621
|
-
let source: string
|
|
622
|
-
try {
|
|
623
|
-
source = await Bun.file(file).text()
|
|
624
|
-
} catch {
|
|
625
|
-
continue
|
|
626
|
-
}
|
|
627
|
-
let dir: string | undefined
|
|
628
|
-
// A source the scan cannot read keeps the imports it can see: watching
|
|
629
|
-
// too few files is what this list exists to prevent.
|
|
630
|
-
const specs =
|
|
631
|
-
staticImports(source)?.imports.map((i) => i.spec) ??
|
|
632
|
-
[...source.matchAll(/\bfrom\s*['"]([^'"]+)['"]|\bimport\s*['"]([^'"]+)['"]/g)].map(
|
|
633
|
-
(m) => (m[1] ?? m[2])!,
|
|
634
|
-
)
|
|
635
|
-
for (const spec of specs) {
|
|
636
|
-
if (!spec.startsWith('./') && !spec.startsWith('../')) continue
|
|
637
|
-
let resolved: string
|
|
638
|
-
try {
|
|
639
|
-
dir ??= realDirOf(file)
|
|
640
|
-
resolved = realPath(Bun.resolveSync(spec, dir))
|
|
641
|
-
} catch {
|
|
642
|
-
// Missing for now, still imported: the config that names it fails
|
|
643
|
-
// to load until it is back, and a list without it left `vx watch`
|
|
644
|
-
// deaf to its return. Named as written, and not walked.
|
|
645
|
-
if (dir === undefined) continue
|
|
646
|
-
for (const ext of path.extname(spec) === '' ? IMPORT_EXTENSIONS : ['']) {
|
|
647
|
-
const missing = path.resolve(dir, spec + ext)
|
|
648
|
-
if (seen.has(missing)) continue
|
|
649
|
-
seen.add(missing)
|
|
650
|
-
out.push(missing)
|
|
651
|
-
}
|
|
652
|
-
continue
|
|
653
|
-
}
|
|
654
|
-
if (seen.has(resolved) || resolved.split(path.sep).includes('node_modules')) continue
|
|
655
|
-
seen.add(resolved)
|
|
656
|
-
out.push(resolved)
|
|
657
|
-
queue.push(resolved)
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
return out
|
|
661
|
-
}
|