@vzn/vx 0.0.596 → 0.0.598

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 (79) hide show
  1. package/launcher.cjs +7 -3
  2. package/package.json +5 -5
  3. package/src/bin.ts +9 -5
  4. package/src/cache/archive.ts +35 -6
  5. package/src/cache/cache.ts +60 -31
  6. package/src/cache/git-inputs.ts +23 -0
  7. package/src/cache/index.ts +2 -0
  8. package/src/cache/inputs.ts +87 -32
  9. package/src/cache/key-fold.ts +1 -1
  10. package/src/cache/layer.ts +2 -1
  11. package/src/cache/layered-cache.ts +14 -9
  12. package/src/cache/output-index.ts +12 -7
  13. package/src/cache/run-history.ts +16 -8
  14. package/src/cli/cache.ts +2 -2
  15. package/src/cli/foreign-flags.ts +56 -2
  16. package/src/cli/info.ts +1 -1
  17. package/src/cli/init.ts +2 -2
  18. package/src/cli/last.ts +6 -3
  19. package/src/cli/lock.ts +5 -3
  20. package/src/cli/plan-format.ts +26 -20
  21. package/src/cli/run.ts +42 -15
  22. package/src/cli/select.ts +19 -5
  23. package/src/cli/show.ts +21 -27
  24. package/src/cli/upgrade.ts +1 -1
  25. package/src/cli/watch-cycle.ts +43 -0
  26. package/src/cli/watch-fs.ts +11 -3
  27. package/src/cli/watch.ts +50 -11
  28. package/src/cli/workspace-config.ts +3 -1
  29. package/src/config.ts +21 -0
  30. package/src/exec/executor.ts +1 -1
  31. package/src/exec/kill-tree.ts +121 -20
  32. package/src/exec/runner.ts +224 -48
  33. package/src/exec/sandbox-paths.ts +11 -0
  34. package/src/exec/sandbox-runtime.ts +187 -54
  35. package/src/exec/sandbox-violations.ts +59 -6
  36. package/src/graph/priorities.ts +1 -2
  37. package/src/graph/scheduler.ts +2 -2
  38. package/src/graph/task-graph.ts +145 -33
  39. package/src/index.ts +3 -1
  40. package/src/orchestrator/affected-tasks.ts +6 -2
  41. package/src/orchestrator/deferred-outputs.ts +3 -2
  42. package/src/orchestrator/doctor.ts +5 -3
  43. package/src/orchestrator/excluded-keys.ts +2 -13
  44. package/src/orchestrator/execute-task.ts +70 -28
  45. package/src/orchestrator/failure-mode.ts +38 -29
  46. package/src/orchestrator/framed-output.ts +5 -5
  47. package/src/orchestrator/hit-restore.ts +1 -1
  48. package/src/orchestrator/index.ts +0 -1
  49. package/src/orchestrator/keyed-projects.ts +6 -3
  50. package/src/orchestrator/lockfile-claim.ts +14 -7
  51. package/src/orchestrator/logger.ts +17 -7
  52. package/src/orchestrator/miss-save.ts +7 -11
  53. package/src/orchestrator/plan.ts +4 -20
  54. package/src/orchestrator/plugin-host.ts +7 -2
  55. package/src/orchestrator/plugin.ts +5 -2
  56. package/src/orchestrator/prepare.ts +4 -2
  57. package/src/orchestrator/projects.ts +1 -1
  58. package/src/orchestrator/run-artifacts.ts +2 -2
  59. package/src/orchestrator/run-context.ts +1 -1
  60. package/src/orchestrator/run-lock.ts +35 -25
  61. package/src/orchestrator/run.ts +2 -1
  62. package/src/orchestrator/sandbox-request.ts +31 -9
  63. package/src/orchestrator/signals.ts +22 -4
  64. package/src/orchestrator/stable-keys.ts +3 -11
  65. package/src/orchestrator/summary.ts +5 -9
  66. package/src/orchestrator/task-hash.ts +13 -3
  67. package/src/orchestrator/upstream.ts +11 -0
  68. package/src/util/hangup.ts +42 -0
  69. package/src/util/index.ts +2 -0
  70. package/src/util/paths.ts +1 -1
  71. package/src/workspace/affected.ts +79 -42
  72. package/src/workspace/config-cache.ts +15 -1
  73. package/src/workspace/config-eval.ts +47 -7
  74. package/src/workspace/config-imports.ts +8 -55
  75. package/src/workspace/config-schema.ts +30 -6
  76. package/src/workspace/lockfile.ts +2 -1
  77. package/src/workspace/migrate-scripts.ts +1 -1
  78. package/src/workspace/project-loader.ts +33 -6
  79. package/src/workspace/workspace.ts +36 -14
@@ -84,7 +84,10 @@ export interface AffectedArgs {
84
84
  * (item 954). The `--affected` sugar's graph walk has already staged every
85
85
  * config, so there it costs nothing more.
86
86
  */
87
- workspaceGlobOwners?: (changedPaths: readonly string[]) => Promise<Iterable<string>>
87
+ workspaceGlobOwners?: (
88
+ changedPaths: readonly string[],
89
+ nested: readonly string[],
90
+ ) => Promise<Iterable<string>>
88
91
  /**
89
92
  * The fingerprint files a plugin claims (`VxPlugin.fingerprint`) and its
90
93
  * answer for a change to one. Resolved lazily: loading the workspace
@@ -142,6 +145,12 @@ export interface AffectedChanges {
142
145
  /** Project → its changed paths, relative to the project's directory. */
143
146
  paths: ReadonlyMap<string, readonly string[]>
144
147
  whole: ReadonlySet<string>
148
+ /**
149
+ * The changed paths that are nested repositories (a submodule, an
150
+ * embedded repository), workspace-relative: each stands for every file
151
+ * in it, so a `workspaceFiles` glob that reaches under one is reached.
152
+ */
153
+ nested?: readonly string[]
145
154
  }
146
155
 
147
156
  export async function affectedChanges(
@@ -221,16 +230,10 @@ export async function affectedChanges(
221
230
  // submodules (`diff.ignoreSubmodules`, `submodule.<name>.ignore`), and
222
231
  // then an edit inside one, or a committed bump, was no change here while
223
232
  // the task's key moved (item 951).
224
- gitPaths(args.workspaceRoot, [
225
- 'diff',
226
- '--no-renames',
227
- '--ignore-submodules=none',
228
- '--relative',
229
- '--name-only',
230
- '-z',
231
- '--end-of-options',
232
- base,
233
- ]),
233
+ //
234
+ // `--raw` for each path's mode: a gitlink (160000) is a nested
235
+ // repository, one path standing for every file in it (`nested` below).
236
+ gitDiffPaths(args.workspaceRoot, base),
234
237
  // `git diff` never reports untracked-but-not-ignored files, but input
235
238
  // enumeration does (`git ls-files --cached --others --exclude-standard`),
236
239
  // so a brand-new source file changes a task's cache key. Union it in or
@@ -244,9 +247,13 @@ export async function affectedChanges(
244
247
  // file: re-running `vx lock` must not mark every project affected.
245
248
  // An untracked file under `node_modules` is an install, which the input
246
249
  // enumeration drops too (`isInstalledPath`); a tracked one is a source.
247
- const changed = [...diffed, ...untracked.filter((s) => !isInstalledPath(s))].filter(
250
+ const changed = [...diffed.paths, ...untracked.filter((s) => !isInstalledPath(s))].filter(
248
251
  (s) => s !== LOCKFILE_NAME,
249
252
  )
253
+ // The nested repositories git reports as one path: a gitlink, or an
254
+ // untracked embedded repository (`dir/`, the only path git prints so).
255
+ const nested = new Set(diffed.gitlinks)
256
+ for (const rel of untracked) if (rel.endsWith('/')) nested.add(rel)
250
257
 
251
258
  // A lockfile or workspace-definition change re-keys EVERY task, because the
252
259
  // workspace fingerprint folds those files into every cache key. Mapping
@@ -316,6 +323,7 @@ export async function affectedChanges(
316
323
  const owned = projectsContaining(
317
324
  args.workspaceRoot,
318
325
  changed,
326
+ nested,
319
327
  args.projects,
320
328
  realDirs,
321
329
  perTask
@@ -371,15 +379,21 @@ export async function affectedChanges(
371
379
  projects: args.projects,
372
380
  changed,
373
381
  skip: perTask ? new Set() : owned,
374
- realDirs,
375
382
  })) {
376
383
  owned.add(name)
377
384
  whole.add(name)
378
385
  }
379
386
 
380
- const done = (): AffectedChanges => ({ projects: owned, changed, paths, whole })
387
+ const nestedDirs = [...nested].map((rel) => rel.replace(/\/$/, ''))
388
+ const done = (): AffectedChanges => ({
389
+ projects: owned,
390
+ changed,
391
+ paths,
392
+ whole,
393
+ ...(nestedDirs.length > 0 ? { nested: nestedDirs } : {}),
394
+ })
381
395
  if (changed.length === 0 || args.workspaceGlobOwners === undefined) return done()
382
- for (const name of await args.workspaceGlobOwners(changed)) owned.add(name)
396
+ for (const name of await args.workspaceGlobOwners(changed, nestedDirs)) owned.add(name)
383
397
  return done()
384
398
  }
385
399
 
@@ -645,6 +659,37 @@ async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]>
645
659
  return stdout.split('\0').filter((s) => s.length > 0)
646
660
  }
647
661
 
662
+ /**
663
+ * `git diff --raw -z <base>`'s paths, and those that are a gitlink on
664
+ * either side: one the change removed took its files with it. Each record
665
+ * is `:<mode> <mode> <oid> <oid> <status>` then its path (one path:
666
+ * `--no-renames`).
667
+ */
668
+ async function gitDiffPaths(
669
+ workspaceRoot: string,
670
+ base: string,
671
+ ): Promise<{ paths: string[]; gitlinks: string[] }> {
672
+ const fields = await gitPaths(workspaceRoot, [
673
+ 'diff',
674
+ '--no-renames',
675
+ '--ignore-submodules=none',
676
+ '--relative',
677
+ '--raw',
678
+ '-z',
679
+ '--end-of-options',
680
+ base,
681
+ ])
682
+ const paths: string[] = []
683
+ const gitlinks: string[] = []
684
+ for (let i = 0; i + 1 < fields.length; i += 2) {
685
+ const rel = fields[i + 1]!
686
+ paths.push(rel)
687
+ const head = fields[i]!
688
+ if (head.startsWith('160000 ', 1) || head.startsWith('160000 ', 8)) gitlinks.push(rel)
689
+ }
690
+ return { paths, gitlinks }
691
+ }
692
+
648
693
  /**
649
694
  * Resolve the default base for `--affected` with no explicit value.
650
695
  * Tries the remote's HEAD branch first (`origin/main`, `origin/master`,
@@ -826,17 +871,11 @@ async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
826
871
  )
827
872
  }
828
873
 
829
- function isDirectory(abs: string): boolean {
830
- try {
831
- return statSync(abs).isDirectory()
832
- } catch {
833
- return false
834
- }
835
- }
836
-
837
874
  function projectsContaining(
838
875
  workspaceRoot: string,
839
876
  changedRelPaths: readonly string[],
877
+ /** The changed paths that are nested repositories (`nested` in `affectedChanges`). */
878
+ nested: ReadonlySet<string>,
840
879
  projects: readonly ProjectMeta[],
841
880
  realDirs: ReadonlyMap<string, string>,
842
881
  /** Told each owned path relative to its project, and each nested repository's projects. */
@@ -868,37 +907,35 @@ function projectsContaining(
868
907
  }
869
908
  const owned = new Set<string>()
870
909
  for (const rel of changedRelPaths) {
910
+ const repo = nested.has(rel)
871
911
  let dir = path.resolve(workspaceRoot, rel).normalize('NFC')
872
- let hit = false
873
912
  for (;;) {
874
913
  const name = dirToName.get(dir)
875
914
  if (name !== undefined) {
876
915
  owned.add(name)
877
916
  on?.path(name, relPosix(dir, path.resolve(workspaceRoot, rel).normalize('NFC')))
878
- hit = true
917
+ // Its files are the project's inputs, and none of them is this
918
+ // path: a task's globs cannot say whether they reach inside.
919
+ if (repo) on?.repo(name)
879
920
  break
880
921
  }
881
922
  const parent = path.dirname(dir)
882
923
  if (parent === dir) break // reached the filesystem root
883
924
  dir = parent
884
925
  }
885
- if (hit) continue
886
- // A changed path that is a DIRECTORY on disk is a nested repository —
887
- // a submodule whose checkout moved or is dirty (`vendor/sub`), an
888
- // embedded repository left untracked (`vendor/nested/`) — because git
889
- // reports nothing else as a directory. The workspace repository sees
890
- // the nested one as that single path, so a change inside is a change
891
- // to it, and every project under it is affected. Its own enumeration
892
- // already keys those projects on their files (git-inputs.ts); without
893
- // this, `--affected` after an edit inside selected none of them.
894
- const abs = path.resolve(workspaceRoot, rel)
895
- if (isDirectory(abs)) {
896
- const prefix = abs.normalize('NFC') + path.sep
897
- for (const [dir, name] of dirToName) {
898
- if (!dir.startsWith(prefix)) continue
899
- owned.add(name)
900
- on?.repo(name)
901
- }
926
+ if (!repo) continue
927
+ // A submodule whose checkout moved or is dirty (`vendor/sub`), an
928
+ // embedded repository left untracked (`vendor/nested/`): the workspace
929
+ // repository sees the nested one as that single path, so a change
930
+ // inside is a change to it, and every project under it is affected.
931
+ // Its own enumeration already keys those projects on their files
932
+ // (git-inputs.ts); without this, `--affected` after an edit inside
933
+ // selected none of them.
934
+ const prefix = path.resolve(workspaceRoot, rel).normalize('NFC') + path.sep
935
+ for (const [dir, name] of dirToName) {
936
+ if (!dir.startsWith(prefix)) continue
937
+ owned.add(name)
938
+ on?.repo(name)
902
939
  }
903
940
  }
904
941
  return owned
@@ -606,8 +606,12 @@ export function configEvalKeyFromIdentities(a: {
606
606
  * `node_modules`, the config itself excluded. `vx watch` watches these: a
607
607
  * shared preset outside the project (`../../shared/preset.mjs`, the way
608
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.
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.
610
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
+
611
615
  export async function configImports(configPath: string): Promise<string[]> {
612
616
  const seen = new Set<string>([configPath])
613
617
  const out: string[] = []
@@ -635,6 +639,16 @@ export async function configImports(configPath: string): Promise<string[]> {
635
639
  dir ??= realDirOf(file)
636
640
  resolved = realPath(Bun.resolveSync(spec, dir))
637
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
+ }
638
652
  continue
639
653
  }
640
654
  if (seen.has(resolved) || resolved.split(path.sep).includes('node_modules')) continue
@@ -103,6 +103,23 @@ const nonJsonPaths = ${nonJsonPaths.toString()}
103
103
  throw new Error('process.exit(' + (code ?? '') + ') in a config: ' + ${JSON.stringify(CONFIG_EXIT)})
104
104
  }
105
105
  }
106
+ // Bun's resolver reads \\ as a separator, so a config under a\\b is served
107
+ // from the path as written (project-loader.ts's LITERAL_QUERY), read now.
108
+ let literal = false
109
+ const serveLiteral = () => {
110
+ if (literal) return
111
+ literal = true
112
+ globalThis.Bun.plugin({
113
+ name: 'vx-config-literal',
114
+ setup(build) {
115
+ build.onResolve({ filter: /\\?vx-literal$/ }, (args) => ({ path: args.path, namespace: 'file' }))
116
+ build.onLoad({ filter: /\\?vx-literal$/ }, async (args) => {
117
+ const file = args.path.slice(0, -'?vx-literal'.length)
118
+ return { contents: await globalThis.Bun.file(file).text(), loader: /\\.[cm]?ts$/.test(file) ? 'ts' : 'js' }
119
+ })
120
+ },
121
+ })
122
+ }
106
123
  self.onmessage = async (e) => {
107
124
  const { id, path, env } = e.data
108
125
  // A Worker starts with the process's STARTUP environment, not the
@@ -155,7 +172,9 @@ self.onmessage = async (e) => {
155
172
  if (umaskIn !== null && globalThis.process.umask() !== umaskIn) globalThis.process.umask(umaskIn)
156
173
  }
157
174
  try {
158
- const ns = await import(path)
175
+ const literalPath = path.includes('\\\\')
176
+ if (literalPath) serveLiteral()
177
+ const ns = await import(literalPath ? path + '?vx-literal' : path)
159
178
  // Awaited as the in-process load's async return flattens it: a Promise
160
179
  // default loaded on a run and was refused as "an instance of Promise"
161
180
  // on every later evaluation in the process (D-5).
@@ -185,12 +204,16 @@ self.onmessage = async (e) => {
185
204
  : thrown
186
205
  const report = changed()
187
206
  umaskBack()
207
+ // A thrown string or plain object has no message, and String() of a
208
+ // null-prototype object throws here, inside the catch.
209
+ const isError = err !== null && typeof err === 'object' && typeof err.message === 'string'
188
210
  postMessage({
189
211
  id,
190
212
  ok: false,
191
- name: err?.name ?? 'Error',
192
- message: err?.message ?? String(err),
193
- stack: err?.stack ?? null,
213
+ thrown: isError ? null : globalThis.Bun.inspect(err, { compact: true }),
214
+ name: isError ? (err.name ?? 'Error') : 'Error',
215
+ message: isError ? err.message : '',
216
+ stack: isError ? (err.stack ?? null) : null,
194
217
  changed: report,
195
218
  position:
196
219
  err?.position && typeof err.position === 'object'
@@ -214,6 +237,8 @@ interface WorkerReply {
214
237
  nonJson: NonJsonValue[]
215
238
  name: string
216
239
  message: string
240
+ /** What a config threw that is not an Error, as `thrownValueMessage` shows it; null for an Error. */
241
+ thrown?: string | null
217
242
  stack: string | null
218
243
  /** A transpile error's location — `BuildMessage.position`, trimmed to what the loader reads. */
219
244
  position: { file?: string; line?: number; column?: number } | null
@@ -222,6 +247,16 @@ interface WorkerReply {
222
247
  interface Pending {
223
248
  resolve: (reply: WorkerReply) => void
224
249
  reject: (err: Error) => void
250
+ configPath: string
251
+ }
252
+
253
+ /**
254
+ * A config threw something that is not an Error: `throw 'no'` printed
255
+ * `vx: no`, naming no file, and a null-prototype object crashed vx's own
256
+ * error printer with a stack.
257
+ */
258
+ export function thrownValueMessage(kind: string, configPath: string, shown: string): string {
259
+ return `${kind} config ${configPath} threw ${shown}, which is not an Error`
225
260
  }
226
261
 
227
262
  /**
@@ -236,10 +271,11 @@ export function evalBudgetMs(): number {
236
271
  // BOUND on a worker that may be wedged, with no "no limit" reading. Falls
237
272
  // back rather than clamping, because honouring ~24.8 days would hang
238
273
  // `vx watch` forever on a worker the OS killed — and unbounded makes it 1 ms,
239
- // so EVERY config load times out instead. Both ends break the same feature.
274
+ // so EVERY config load times out instead. Both ends break the same feature,
275
+ // and `0` is the second end: it fired on the next tick (D-157).
240
276
  if (raw !== undefined && /^[0-9]+$/.test(raw)) {
241
277
  const n = Number(raw)
242
- if (n <= MAX_TIMEOUT_MS) return n
278
+ if (n > 0 && n <= MAX_TIMEOUT_MS) return n
243
279
  }
244
280
  return 30_000
245
281
  }
@@ -287,6 +323,10 @@ function acquireWorker(): Worker {
287
323
  p.resolve(msg)
288
324
  return
289
325
  }
326
+ if (typeof msg.thrown === 'string') {
327
+ p.reject(new UserError(thrownValueMessage('Project', p.configPath, msg.thrown)))
328
+ return
329
+ }
290
330
  // Rebuild the error the config actually threw. Name, message, stack
291
331
  // and a transpile error's position all survive the hop, so a broken
292
332
  // config reports the same text it would from an in-process import.
@@ -365,7 +405,7 @@ export async function evaluateConfigFresh(configPath: string): Promise<unknown>
365
405
  // evaluation costs, so it can only fire on a genuine wedge.
366
406
  const budget = evalBudgetMs()
367
407
  const reply = await new Promise<WorkerReply>((resolve, reject) => {
368
- pending.set(id, { resolve, reject })
408
+ pending.set(id, { resolve, reject, configPath })
369
409
  timer = setTimeout(() => {
370
410
  rejectAll(new Error(`config worker did not answer within ${budget}ms`))
371
411
  if (worker !== null) {
@@ -20,13 +20,13 @@
20
20
  // the lockfile moves, which the workspace fingerprint already covers.
21
21
  // A tsconfig `paths` / `baseUrl` alias is the exception: Bun loads it
22
22
  // from disk (D-27).
23
- // - Descend only through files owned by NO project, by a ROOT project
24
- // (whose own files are the shared tooling that is otherwise unowned,
25
- // D-41), or by the importing file's own project. A config reaching into another project (say a site's
26
- // `vx.config.ts` importing `../core/src/index.ts`) records that edge
27
- // and STOPS there — following it would drag substantially all of that
28
- // project's `src/` into the closure, and the containment channel
29
- // already selects the project that owns it.
23
+ // - One reversed BFS from every config at once, each file read once
24
+ // however many configs reach it. It descends through every file in
25
+ // the workspace, another project's included: a site's `vx.config.ts`
26
+ // importing `../core/src/index.ts` evaluates what that file imports
27
+ // too, so an edit there moves the site's key. The walk stopped at the
28
+ // first file of another project, and containment selected core alone
29
+ // while the site's key moved (X-128).
30
30
 
31
31
  import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
32
32
  import { realpath } from 'node:fs/promises'
@@ -393,32 +393,6 @@ export interface ConfigImportOwnersArgs {
393
393
  changed: readonly string[]
394
394
  /** Already-selected projects; their configs need no scan. */
395
395
  skip: ReadonlySet<string>
396
- /**
397
- * Each project dir's realpath, when the caller already has them: the
398
- * containment pass needs the same answers, and asking twice cost 5,000
399
- * realpaths at 5,000 projects (D-24).
400
- */
401
- realDirs?: ReadonlyMap<string, string>
402
- }
403
-
404
- /**
405
- * Project dir → name, REALPATH'D — which is why this cannot reuse the index
406
- * the containment pass builds. `Bun.resolveSync` hands back realpath'd
407
- * targets, and on darwin a workspace under `os.tmpdir()` lives at
408
- * `/var/folders/…` while its realpath is `/private/var/folders/…`; comparing
409
- * the two matches nothing and fails exactly like "found no imports".
410
- */
411
- async function realDirIndex(
412
- projects: readonly ProjectMeta[],
413
- known: ReadonlyMap<string, string> | undefined,
414
- ): Promise<Map<string, string>> {
415
- const out = new Map<string, string>()
416
- await Promise.all(
417
- projects.map(async (p) => {
418
- out.set(known?.get(p.dir) ?? (await realpath(p.dir).catch(() => p.dir)), p.name)
419
- }),
420
- )
421
- return out
422
396
  }
423
397
 
424
398
  const TS_EXT = new Set(['.ts', '.mts', '.cts'])
@@ -426,18 +400,6 @@ const TS_EXT = new Set(['.ts', '.mts', '.cts'])
426
400
  /** The extensions Bun tries for a specifier that names none. */
427
401
  const RESOLVED_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json']
428
402
 
429
- /** The deepest project containing `file`, or undefined when none does. */
430
- function ownerOf(file: string, dirToName: ReadonlyMap<string, string>): string | undefined {
431
- let dir = path.dirname(file)
432
- for (;;) {
433
- const name = dirToName.get(dir)
434
- if (name !== undefined) return name
435
- const parent = path.dirname(dir)
436
- if (parent === dir) return undefined
437
- dir = parent
438
- }
439
- }
440
-
441
403
  /** Projects whose config file transitively imports one of `changed`. */
442
404
  export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set<string>> {
443
405
  const selected = new Set<string>()
@@ -453,10 +415,6 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
453
415
  }),
454
416
  )
455
417
  if (roots.size === 0) return selected
456
- const dirToName = await realDirIndex(a.projects, a.realDirs)
457
- // A root project owns every file no member owns: the shared tooling the
458
- // walk descends through when the root is no project (D-41).
459
- const rootOwner = dirToName.get(workspaceRoot)
460
418
 
461
419
  // target → the files that import it. Reversed up front so one BFS from the
462
420
  // changed set answers every root at once, instead of a walk per root.
@@ -486,18 +444,13 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
486
444
  if (entry === null) continue
487
445
  const { file, source } = entry
488
446
  const loader = TS_EXT.has(path.extname(file)) ? 'ts' : 'js'
489
- const from = ownerOf(file, dirToName)
490
447
  for (const target of scanLocalImports(source, path.dirname(file), loader, tsconfigs)) {
491
448
  if (!target.startsWith(workspaceRoot + path.sep)) continue
492
449
  if (target.split(path.sep).includes('node_modules')) continue
493
450
  const list = importedBy.get(target)
494
451
  if (list) list.push(file)
495
452
  else importedBy.set(target, [file])
496
- // Descend ONLY through unowned files, the root project's own, or
497
- // the importer's own project's (a config split into `./tasks.mjs`
498
- // that imports shared tooling): see the header.
499
- const owner = ownerOf(target, dirToName)
500
- if (owner === undefined || owner === rootOwner || owner === from) frontier.push(target)
453
+ frontier.push(target)
501
454
  }
502
455
  }
503
456
  }
@@ -76,6 +76,16 @@ function validateRules(rules: unknown, configPath: string): void {
76
76
 
77
77
  const RETENTION_FIELDS = new Set(['olderThan', 'maxSize'])
78
78
 
79
+ /**
80
+ * A refused value as a message quotes it. A workspace file is evaluated
81
+ * in-process and no JSON rule precedes its schema, so a value here may be
82
+ * one `JSON.stringify` throws on (`30n`) or drops (a symbol): the refusal
83
+ * became a TypeError with a stack.
84
+ */
85
+ function shown(v: unknown): string {
86
+ return typeof v === 'bigint' ? `${v}n` : (JSON.stringify(v) ?? String(v))
87
+ }
88
+
79
89
  function validateRetention(retention: unknown, configPath: string): void {
80
90
  const where = `${configPath}: \`cacheRetention\``
81
91
  const field = (key: string) => `${configPath}: \`cacheRetention.${key}\``
@@ -93,12 +103,12 @@ function validateRetention(retention: unknown, configPath: string): void {
93
103
  (typeof olderThan !== 'string' || parseDuration(olderThan) === null)
94
104
  ) {
95
105
  throw new UserError(
96
- `${field('olderThan')} must be a duration like '30d', '12h', '90m' or '45s' (got ${JSON.stringify(olderThan)})`,
106
+ `${field('olderThan')} must be a duration like '30d', '12h', '90m' or '45s' (got ${shown(olderThan)})`,
97
107
  )
98
108
  }
99
109
  if (maxSize !== undefined && (typeof maxSize !== 'string' || parseSize(maxSize) === null)) {
100
110
  throw new UserError(
101
- `${field('maxSize')} must be a size like '10G', '500MB' or '64KB' (got ${JSON.stringify(maxSize)})`,
111
+ `${field('maxSize')} must be a size like '10G', '500MB' or '64KB' (got ${shown(maxSize)})`,
102
112
  )
103
113
  }
104
114
  // The bounds `vx cache prune` refuses, for the same reason: each evicts
@@ -179,7 +189,7 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
179
189
  throw new UserError(`${configPath}: \`plugins\` must be an array of plugin objects`)
180
190
  }
181
191
  const verbOwners = new Map<string, string>()
182
- const fileClaimants = new Map<string, string>()
192
+ const fileClaimants = new Map<string, { name: string; at: number }>()
183
193
  for (const [i, p] of config.plugins.entries()) {
184
194
  if (p === null || typeof p !== 'object') {
185
195
  // Nx's nx.json lists plugins by module name (`'@nx/vite/plugin'`);
@@ -199,6 +209,13 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
199
209
  // one whose `name` was overwritten after the stamp, is refused here —
200
210
  // the one boundary every plugin crosses.
201
211
  const pkg = (p as Record<symbol, unknown>)[PLUGIN_PACKAGE]
212
+ // An async factory called without `await` was told its plugin did not
213
+ // come from definePlugin — it had (D-159).
214
+ if (pkg === undefined && typeof (p as { then?: unknown }).then === 'function') {
215
+ throw new UserError(
216
+ `${configPath}: \`plugins[${i}]\` is a Promise — await the factory that returns the plugin`,
217
+ )
218
+ }
202
219
  if (typeof pkg !== 'string' || pkg.length === 0) {
203
220
  throw new UserError(
204
221
  `${configPath}: \`plugins[${i}]\` must come from definePlugin(import.meta, { … }) — a plugin's name is its package name`,
@@ -317,16 +334,23 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
317
334
  /[/\\]/.test(file)
318
335
  ) {
319
336
  throw new UserError(
320
- `${configPath}: plugin '${plug.name}' claims fingerprint file ${JSON.stringify(file)}, which is not a file name at the workspace root`,
337
+ `${configPath}: plugin '${plug.name}' claims fingerprint file ${shown(file)}, which is not a file name at the workspace root`,
321
338
  )
322
339
  }
323
340
  const owner = fileClaimants.get(file)
341
+ // One package twice (`plugins: [bun(), bun()]`) read "plugins 'x'
342
+ // and 'x' both claim" (D-158).
343
+ if (owner !== undefined && owner.name === plug.name) {
344
+ throw new UserError(
345
+ `${configPath}: plugin '${plug.name}' claims fingerprint file '${file}' twice (plugins[${owner.at}] and plugins[${i}]) — a file has one claimant; declare the plugin once`,
346
+ )
347
+ }
324
348
  if (owner !== undefined) {
325
349
  throw new UserError(
326
- `${configPath}: plugins '${owner}' and '${plug.name}' both claim fingerprint file '${file}' — a file has one claimant`,
350
+ `${configPath}: plugins '${owner.name}' and '${plug.name}' both claim fingerprint file '${file}' — a file has one claimant`,
327
351
  )
328
352
  }
329
- fileClaimants.set(file, plug.name)
353
+ fileClaimants.set(file, { name: plug.name, at: i })
330
354
  }
331
355
  }
332
356
  // A plugin must contribute at least one capability or lifecycle hook
@@ -152,7 +152,8 @@ export async function frozenProjectConfigs(
152
152
  ): Promise<ProjectConfig[]> {
153
153
  const entries = metas.map((meta) => {
154
154
  const rel = relPosix(root, meta.configPath)
155
- const entry = lock.projects[meta.name]
155
+ // Own entries only: `constructor` read Object's, "locked" at undefined (D-152).
156
+ const entry = Object.hasOwn(lock.projects, meta.name) ? lock.projects[meta.name] : undefined
156
157
  if (entry !== undefined && entry.configPath !== rel) {
157
158
  // A renamed or moved config: "has no entry for a" named an entry the
158
159
  // lock holds, under its old path (D-88).
@@ -847,7 +847,7 @@ export function migrateScripts(
847
847
  const names = runnable.filter((n) => taskNameProblem(n) === null)
848
848
  if (meta !== rootMeta && !('build' in scripts)) {
849
849
  const hook = LIFECYCLE_BUILD_HOOKS.find(
850
- (h) => typeof scripts[h] === 'string' && BUILDER.test(scripts[h] as string),
850
+ (h) => typeof scripts[h] === 'string' && BUILDER.test(scripts[h]),
851
851
  )
852
852
  if (hook !== undefined) lifecycleBuilds.push([meta.name, hook, scripts[hook] as string])
853
853
  }