@vzn/vx 0.0.21 → 0.0.22

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