@vzn/vx 0.0.18 → 0.0.22

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