@vzn/vx 0.0.636 → 0.0.637

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +213 -207
  3. package/docs/cli.md +699 -655
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +376 -198
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +2 -2
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +16 -12
  19. package/src/bin.ts +17 -2
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +105 -53
  22. package/src/cache/git-inputs.ts +277 -63
  23. package/src/cache/index.ts +7 -1
  24. package/src/cache/inputs.ts +125 -35
  25. package/src/cache/key-fold.ts +8 -7
  26. package/src/cache/layer.ts +8 -17
  27. package/src/cache/layered-cache.ts +16 -102
  28. package/src/cache/policy.ts +25 -29
  29. package/src/cache/run-history.ts +36 -14
  30. package/src/cache/schema.ts +21 -23
  31. package/src/cache/scoped-cache.ts +224 -0
  32. package/src/cli/cache.ts +150 -28
  33. package/src/cli/completions.ts +2 -2
  34. package/src/cli/core-alias.ts +1 -1
  35. package/src/cli/foreign-flags.ts +13 -12
  36. package/src/cli/help.ts +94 -63
  37. package/src/cli/index.ts +42 -13
  38. package/src/cli/info.ts +38 -23
  39. package/src/cli/init.ts +115 -406
  40. package/src/cli/last.ts +145 -4
  41. package/src/cli/lock.ts +333 -111
  42. package/src/cli/plan-format.ts +2 -2
  43. package/src/cli/plugin-commands.ts +5 -9
  44. package/src/cli/plugin-templates.ts +10 -10
  45. package/src/cli/run.ts +103 -149
  46. package/src/cli/select.ts +33 -39
  47. package/src/cli/show.ts +17 -19
  48. package/src/cli/task-verb.ts +2 -9
  49. package/src/cli/upgrade.ts +402 -145
  50. package/src/cli/watch-cycle.ts +9 -2
  51. package/src/cli/watch-filter.ts +33 -60
  52. package/src/cli/watch-fs.ts +29 -16
  53. package/src/cli/watch-judge.ts +12 -3
  54. package/src/cli/watch-set.ts +16 -42
  55. package/src/cli/watch.ts +188 -215
  56. package/src/cli/why.ts +28 -17
  57. package/src/cli/workspace-config.ts +31 -68
  58. package/src/config.ts +169 -70
  59. package/src/exec/env.ts +1 -31
  60. package/src/exec/executor.ts +1 -5
  61. package/src/exec/kill-tree.ts +2 -1
  62. package/src/exec/sandbox-runtime.ts +12 -1
  63. package/src/exec/sandbox-violations.ts +9 -1
  64. package/src/graph/dependency-spec.ts +1 -1
  65. package/src/graph/index.ts +1 -0
  66. package/src/graph/priorities.ts +1 -1
  67. package/src/graph/scheduler.ts +19 -7
  68. package/src/graph/task-graph.ts +44 -20
  69. package/src/handoff.ts +169 -0
  70. package/src/index.ts +24 -57
  71. package/src/orchestrator/affected-tasks.ts +7 -5
  72. package/src/orchestrator/command-windows.ts +44 -0
  73. package/src/orchestrator/deferred-outputs.ts +12 -10
  74. package/src/orchestrator/doctor.ts +98 -110
  75. package/src/orchestrator/download-policy.ts +3 -3
  76. package/src/orchestrator/events.ts +19 -3
  77. package/src/orchestrator/excluded-keys.ts +0 -2
  78. package/src/orchestrator/execute-task.ts +158 -63
  79. package/src/orchestrator/fingerprint-watch.ts +18 -15
  80. package/src/orchestrator/forecast.ts +10 -2
  81. package/src/orchestrator/framed-output.ts +8 -3
  82. package/src/orchestrator/graph-stage.ts +5 -5
  83. package/src/orchestrator/history.ts +70 -30
  84. package/src/orchestrator/hit-restore.ts +15 -3
  85. package/src/orchestrator/index.ts +18 -4
  86. package/src/orchestrator/local-shortcircuit.ts +2 -1
  87. package/src/orchestrator/lockfile-claim.ts +79 -20
  88. package/src/orchestrator/logger.ts +71 -39
  89. package/src/orchestrator/metrics.ts +71 -6
  90. package/src/orchestrator/miss-save.ts +3 -0
  91. package/src/orchestrator/options.ts +42 -29
  92. package/src/orchestrator/output-host.ts +72 -0
  93. package/src/orchestrator/plan.ts +6 -8
  94. package/src/orchestrator/plugin-host.ts +87 -63
  95. package/src/orchestrator/plugin.ts +94 -60
  96. package/src/orchestrator/prepare.ts +113 -72
  97. package/src/orchestrator/projects.ts +102 -99
  98. package/src/orchestrator/remote-prefetch.ts +2 -1
  99. package/src/orchestrator/run-context.ts +25 -6
  100. package/src/orchestrator/run-failures.ts +17 -8
  101. package/src/orchestrator/run-lock.ts +1 -1
  102. package/src/orchestrator/run-report.ts +9 -9
  103. package/src/orchestrator/run-settings.ts +48 -0
  104. package/src/orchestrator/run.ts +121 -95
  105. package/src/orchestrator/sandbox-fix.ts +39 -20
  106. package/src/orchestrator/sandbox-request.ts +84 -13
  107. package/src/orchestrator/signals.ts +1 -1
  108. package/src/orchestrator/stable-keys.ts +13 -11
  109. package/src/orchestrator/status-line.ts +48 -6
  110. package/src/orchestrator/summary.ts +46 -62
  111. package/src/orchestrator/task-hash.ts +12 -83
  112. package/src/orchestrator/telemetry.ts +3 -3
  113. package/src/settings.ts +417 -0
  114. package/src/util/errors.ts +1 -1
  115. package/src/util/essential-env.ts +31 -0
  116. package/src/util/hide-env.ts +107 -0
  117. package/src/util/index.ts +8 -2
  118. package/src/util/paths.ts +10 -0
  119. package/src/util/settle.ts +49 -30
  120. package/src/util/size.ts +23 -4
  121. package/src/util/timing.ts +3 -2
  122. package/src/util/ulid.ts +1 -1
  123. package/src/util/verbs.ts +0 -15
  124. package/src/workspace/affected.ts +77 -222
  125. package/src/workspace/config-eval.ts +15 -20
  126. package/src/workspace/config-imports.ts +78 -3
  127. package/src/workspace/config-schema.ts +209 -268
  128. package/src/workspace/dependency-rules.ts +57 -0
  129. package/src/workspace/filter.ts +2 -2
  130. package/src/workspace/fingerprint.ts +11 -121
  131. package/src/workspace/index.ts +20 -27
  132. package/src/workspace/load-reads.ts +4 -4
  133. package/src/workspace/lockfile.ts +96 -140
  134. package/src/workspace/migration.ts +242 -146
  135. package/src/workspace/package-graph.ts +30 -216
  136. package/src/workspace/project-loader.ts +80 -285
  137. package/src/workspace/repo-id.ts +2 -1
  138. package/src/workspace/workspace.ts +362 -831
  139. package/src/cache/config-evals.ts +0 -130
  140. package/src/workspace/config-cache.ts +0 -661
  141. 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
- }