@vzn/vx 0.0.646 → 0.0.649

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 (184) hide show
  1. package/README.md +29 -22
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -16,10 +16,10 @@
16
16
  // Two rules keep the walk small, and the second is the one that makes it
17
17
  // affordable at all:
18
18
  //
19
- // - RELATIVE specifiers only. A bare specifier is a package; it moves when
20
- // the lockfile moves, which a lockfile plugin's key covers.
21
- // A tsconfig `paths` / `baseUrl` alias is the exception: Bun loads it
22
- // from disk (D-27).
19
+ // - LOCAL specifiers only (`localTarget`): relative, package.json
20
+ // `#imports`, or a tsconfig `paths` / `baseUrl` alias, each of which
21
+ // Bun loads from disk (D-27). Any other bare specifier is a package; it
22
+ // moves when the lockfile moves, which a lockfile plugin's key covers.
23
23
  // - One reversed BFS from every config at once, each file read once
24
24
  // however many configs reach it. It descends through every file in
25
25
  // the workspace, another project's included: a site's `vx.config.ts`
@@ -28,8 +28,8 @@
28
28
  // first file of another project, and containment selected core alone
29
29
  // while the site's key moved (X-128).
30
30
  //
31
- // `configImports` walks one config's relative closure forward, for the
32
- // files `vx watch` watches.
31
+ // `configImports` walks one config's local closure forward, by the same
32
+ // rule, for the files `vx watch` watches.
33
33
 
34
34
  import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'node:fs'
35
35
  import { realpath } from 'node:fs/promises'
@@ -323,20 +323,149 @@ function selfReference(pkg: string, fromDir: string): boolean {
323
323
  }
324
324
  }
325
325
 
326
+ /** The nearest `package.json`'s directory and `imports` map, per directory. */
327
+ type ImportsMemo = Map<string, { dir: string; imports: Record<string, unknown> } | null>
328
+
329
+ /** What one walk learns once: tsconfig paths and package `imports` per directory. */
330
+ interface LookupMemo {
331
+ tsconfig: TsconfigMemo
332
+ imports: ImportsMemo
333
+ }
334
+
335
+ function lookupMemo(): LookupMemo {
336
+ return { tsconfig: new Map(), imports: new Map() }
337
+ }
338
+
339
+ /** The `imports` map of the package `dir` sits in: its nearest `package.json`'s. */
340
+ function importsAt(
341
+ dir: string,
342
+ memo: ImportsMemo,
343
+ ): { dir: string; imports: Record<string, unknown> } | null {
344
+ const known = memo.get(dir)
345
+ if (known !== undefined) return known
346
+ let out: { dir: string; imports: Record<string, unknown> } | null = null
347
+ const manifest = path.join(dir, 'package.json')
348
+ const up = path.dirname(dir)
349
+ if (existsSync(manifest)) {
350
+ try {
351
+ const json = JSON.parse(readFileSync(manifest, 'utf8').replace(/^/, '')) as {
352
+ imports?: unknown
353
+ }
354
+ if (typeof json.imports === 'object' && json.imports !== null) {
355
+ out = { dir, imports: json.imports as Record<string, unknown> }
356
+ }
357
+ } catch {}
358
+ } else if (up !== dir) {
359
+ out = importsAt(up, memo)
360
+ }
361
+ memo.set(dir, out)
362
+ return out
363
+ }
364
+
365
+ /** Every string a package `imports` target holds, through arrays and conditions. */
366
+ function targetStrings(target: unknown): string[] {
367
+ if (typeof target === 'string') return [target]
368
+ if (Array.isArray(target)) return target.flatMap(targetStrings)
369
+ if (typeof target === 'object' && target !== null)
370
+ return Object.values(target).flatMap(targetStrings)
371
+ return []
372
+ }
373
+
326
374
  /**
327
- * A textual pass before the scan: a relative specifier opens with a quoted
328
- * `./` or `../`, or spells one with an escape, and most configs hold
329
- * neither, so the scan is skipped for them (D-23). A candidate in a comment
330
- * or string still goes to the scan, which decides.
375
+ * The paths a `#` specifier's entry names in the nearest `package.json`
376
+ * `imports` (exact key, else the `*` pattern with the longest head, as
377
+ * Node picks), and whether every target is local. An entry mapping to a
378
+ * package is the lockfile's, as any bare specifier.
331
379
  */
332
- const RELATIVE_CANDIDATE = /["'`](?:\.\.?\/|[^"'`\n]*\\)/
380
+ function packageImportPaths(
381
+ spec: string,
382
+ fromDir: string,
383
+ memo: ImportsMemo,
384
+ ): { paths: string[]; allLocal: boolean } | undefined {
385
+ const scope = importsAt(fromDir, memo)
386
+ if (scope === null) return undefined
387
+ let target: unknown = scope.imports[spec]
388
+ let hole = ''
389
+ if (target === undefined) {
390
+ let head = ''
391
+ for (const [key, t] of Object.entries(scope.imports)) {
392
+ const star = key.indexOf('*')
393
+ if (star === -1 || star < head.length) continue
394
+ const h = key.slice(0, star)
395
+ const tail = key.slice(star + 1)
396
+ if (spec.length < key.length - 1 || !spec.startsWith(h) || !spec.endsWith(tail)) continue
397
+ head = h
398
+ target = t
399
+ hole = spec.slice(h.length, spec.length - tail.length)
400
+ }
401
+ }
402
+ const strings = targetStrings(target)
403
+ const local = strings.filter((s) => s.startsWith('./'))
404
+ if (local.length === 0) return undefined
405
+ return {
406
+ paths: local.map((s) => path.resolve(scope.dir, s.replaceAll('*', hole))),
407
+ allLocal: local.length === strings.length,
408
+ }
409
+ }
333
410
 
334
411
  /**
335
- * Absolute resolved targets of the RELATIVE specifiers in `source`; one that
336
- * does not resolve contributes the paths it names.
412
+ * The local file `spec` names from `fromDir`, by the rules Bun loads one
413
+ * with: a relative path (`.`, `..`, `./x`, `../x`), a package.json
414
+ * `#imports` entry, or a tsconfig `paths` / `baseUrl` alias (D-27).
415
+ * `file` is the hit, realpath'd as `Bun.resolveSync` names one. With none, `named` lists the paths the
416
+ * specifier names (and, bare of an extension, the files Bun would have
417
+ * tried): most often the change itself deleted or renamed the target, and
418
+ * the config importing it is the project the change broke (item 958).
419
+ * Undefined for a package, a builtin or `@vzn/vx`.
337
420
  *
338
- * Paths come back realpath'd, because `Bun.resolveSync` realpaths them — see
339
- * the caller, which realpaths everything it compares against for exactly that
421
+ * Both walks ask this, so what `--affected` follows and what `vx watch`
422
+ * watches cannot part: the watch walk once kept `./` and `../` alone.
423
+ */
424
+ function localTarget(
425
+ spec: string,
426
+ fromDir: string,
427
+ memo: LookupMemo,
428
+ ): { file: string } | { named: string[] } | undefined {
429
+ if (spec === '.' || spec === '..' || spec.startsWith('./') || spec.startsWith('../')) {
430
+ try {
431
+ return { file: Bun.resolveSync(spec, fromDir) }
432
+ } catch {
433
+ return { named: expansions(path.resolve(fromDir, spec)) }
434
+ }
435
+ }
436
+ if (spec.startsWith('#')) {
437
+ const entry = packageImportPaths(spec, fromDir, memo.imports)
438
+ if (entry === undefined) return undefined
439
+ // Bun picks the condition; it is asked only when no condition names a
440
+ // package, which it would fetch from the registry with no
441
+ // node_modules above.
442
+ if (entry.allLocal) {
443
+ try {
444
+ return { file: Bun.resolveSync(spec, fromDir) }
445
+ } catch {}
446
+ }
447
+ return { named: entry.paths.flatMap(expansions) }
448
+ }
449
+ if (spec.startsWith('.') || spec.startsWith('/') || !needsLookup(spec)) return undefined
450
+ const bases = tsconfigBases(spec, fromDir, memo.tsconfig)
451
+ if (bases.length === 0) return undefined
452
+ const hit = bases.map(fileAt).find((f) => f !== undefined)
453
+ return hit !== undefined ? { file: realpathOr(hit) } : { named: bases.flatMap(expansions) }
454
+ }
455
+
456
+ /**
457
+ * A textual pass before the scan: a local specifier opens with a quoted
458
+ * `./`, `../`, `.`, `..` or `#`, or spells one with an escape, and most
459
+ * configs hold none, so the scan is skipped for them (D-23). A candidate in
460
+ * a comment or string still goes to the scan, which decides.
461
+ */
462
+ const LOCAL_CANDIDATE = /["'`](?:\.\.?(?:\/|["'`])|#|[^"'`\n]*\\)/
463
+
464
+ /**
465
+ * Absolute targets of the local specifiers in `source` ({@link localTarget}).
466
+ *
467
+ * Paths come back realpath'd, as `Bun.resolveSync` names them — see the
468
+ * caller, which realpaths everything it compares against for exactly that
340
469
  * reason. `import type` is erased by `scanImports` and so contributes no edge,
341
470
  * which is correct: an erased import cannot move a resolved value.
342
471
  */
@@ -344,10 +473,9 @@ function scanLocalImports(
344
473
  source: string,
345
474
  fromDir: string,
346
475
  loader: 'ts' | 'js',
347
- memo: TsconfigMemo,
476
+ memo: LookupMemo,
348
477
  ): string[] {
349
- const aliased = hasAliasCandidate(source, fromDir, memo)
350
- if (!aliased && !RELATIVE_CANDIDATE.test(source)) return []
478
+ if (!LOCAL_CANDIDATE.test(source) && !hasAliasCandidate(source, fromDir, memo.tsconfig)) return []
351
479
  let specifiers: string[]
352
480
  try {
353
481
  specifiers = scanner(loader)
@@ -358,27 +486,11 @@ function scanLocalImports(
358
486
  }
359
487
  const out: string[] = []
360
488
  for (const spec of specifiers) {
361
- if (!spec.startsWith('./') && !spec.startsWith('../')) {
362
- // A tsconfig alias is a local import Bun resolves by path (D-27); a
363
- // target the change deleted still names its paths, as below.
364
- if (!aliased || spec.startsWith('.') || spec.startsWith('/') || !needsLookup(spec)) continue
365
- const bases = tsconfigBases(spec, fromDir, memo)
366
- const hit = bases.map(fileAt).find((f) => f !== undefined)
367
- if (hit !== undefined) out.push(realpathOr(hit))
368
- else out.push(...bases.flatMap(expansions))
369
- continue
370
- }
371
- try {
372
- out.push(Bun.resolveSync(spec, fromDir))
373
- } catch {
374
- // Unresolvable: most often the change itself deleted or renamed the
375
- // target, and the config that imports it is exactly the project the
376
- // change broke. The edge is the path the specifier names (and, bare
377
- // of an extension, the files Bun would have tried), so the deleted
378
- // path in the diff still reaches its importer (item 958). Selection
379
- // may widen; it is never hashed.
380
- out.push(...expansions(path.resolve(fromDir, spec)))
381
- }
489
+ // Selection may widen on a named path; it is never hashed.
490
+ const t = localTarget(spec, fromDir, memo)
491
+ if (t === undefined) continue
492
+ if ('file' in t) out.push(t.file)
493
+ else out.push(...t.named)
382
494
  }
383
495
  return out
384
496
  }
@@ -396,6 +508,14 @@ export interface ConfigImportOwnersArgs {
396
508
  changed: readonly string[]
397
509
  /** Already-selected projects; their configs need no scan. */
398
510
  skip: ReadonlySet<string>
511
+ /** The workspace config files: one of `changed` they import is {@link ConfigImportOwners.workspace}. */
512
+ workspaceConfigs?: readonly string[]
513
+ }
514
+
515
+ export interface ConfigImportOwners {
516
+ owners: Set<string>
517
+ /** A changed file a workspace config imports, which can re-key any task. */
518
+ workspace?: string
399
519
  }
400
520
 
401
521
  const TS_EXT = new Set(['.ts', '.mts', '.cts'])
@@ -404,9 +524,11 @@ const TS_EXT = new Set(['.ts', '.mts', '.cts'])
404
524
  const RESOLVED_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json']
405
525
 
406
526
  /** Projects whose config file transitively imports one of `changed`. */
407
- export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set<string>> {
527
+ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<ConfigImportOwners> {
408
528
  const selected = new Set<string>()
409
- if (a.changed.length === 0 || a.skip.size === a.projects.length) return selected
529
+ const wsConfigs = a.workspaceConfigs ?? []
530
+ if (a.changed.length === 0) return { owners: selected }
531
+ if (a.skip.size === a.projects.length && wsConfigs.length === 0) return { owners: selected }
410
532
  const workspaceRoot = await realpath(a.workspaceRoot).catch(() => a.workspaceRoot)
411
533
 
412
534
  const roots = new Map<string, string>() // abs config path → project name
@@ -417,16 +539,19 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
417
539
  roots.set(await realpath(cfg).catch(() => path.resolve(cfg)), p.name)
418
540
  }),
419
541
  )
420
- if (roots.size === 0) return selected
542
+ const wsRoots = new Set(
543
+ await Promise.all(wsConfigs.map((f) => realpath(f).catch(() => path.resolve(f)))),
544
+ )
545
+ if (roots.size === 0 && wsRoots.size === 0) return { owners: selected }
421
546
 
422
547
  // target → the files that import it. Reversed up front so one BFS from the
423
548
  // changed set answers every root at once, instead of a walk per root.
424
- const tsconfigs: TsconfigMemo = new Map()
549
+ const memo = lookupMemo()
425
550
  const importedBy = new Map<string, string[]>()
426
551
  const visited = new Set<string>()
427
552
  // A level at a time, its files read together: one awaited read per
428
553
  // config put 5,000 of them 200 ms behind (D-23).
429
- let frontier = [...roots.keys()]
554
+ let frontier = [...roots.keys(), ...wsRoots]
430
555
  while (frontier.length > 0) {
431
556
  const read = await Promise.all(
432
557
  frontier.map(async (file) => {
@@ -447,7 +572,7 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
447
572
  if (entry === null) continue
448
573
  const { file, source } = entry
449
574
  const loader = TS_EXT.has(path.extname(file)) ? 'ts' : 'js'
450
- for (const target of scanLocalImports(source, path.dirname(file), loader, tsconfigs)) {
575
+ for (const target of scanLocalImports(source, path.dirname(file), loader, memo)) {
451
576
  if (!target.startsWith(workspaceRoot + path.sep)) continue
452
577
  if (target.split(path.sep).includes('node_modules')) continue
453
578
  const list = importedBy.get(target)
@@ -458,17 +583,21 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
458
583
  }
459
584
  }
460
585
 
586
+ let workspace: string | undefined
461
587
  const seen = new Set<string>()
462
- const walk = [...a.changed.map((rel) => path.resolve(workspaceRoot, rel))]
588
+ // Each entry carries the changed file it came from, to name the one that
589
+ // reaches a workspace config.
590
+ const walk = a.changed.map((rel) => [path.resolve(workspaceRoot, rel), rel] as const)
463
591
  while (walk.length > 0) {
464
- const file = walk.pop()!
592
+ const [file, from] = walk.pop()!
465
593
  if (seen.has(file)) continue
466
594
  seen.add(file)
467
595
  const owner = roots.get(file)
468
596
  if (owner !== undefined) selected.add(owner)
469
- for (const importer of importedBy.get(file) ?? []) walk.push(importer)
597
+ if (wsRoots.has(file)) workspace ??= from
598
+ for (const importer of importedBy.get(file) ?? []) walk.push([importer, from])
470
599
  }
471
- return selected
600
+ return workspace === undefined ? { owners: selected } : { owners: selected, workspace }
472
601
  }
473
602
 
474
603
  /**
@@ -482,24 +611,34 @@ function realDirOf(file: string): string {
482
611
  }
483
612
 
484
613
  /**
485
- * Every file a config imports by relative specifier, transitively, outside
614
+ * Every file a config imports by local specifier, transitively, outside
486
615
  * `node_modules`, the config itself excluded. `vx watch` watches these: a
487
616
  * shared preset outside the project (`../../shared/preset.mjs`, the way
488
617
  * configs compose) changed what a run evaluates and no watcher saw it
489
618
  * (item 949). A file that cannot be read ends its branch; an import that
490
619
  * cannot be resolved is still listed, by the names it would have.
620
+ * `configSource` is the config's, when the caller has read it; `read` reads the
621
+ * rest, for a caller that keeps what was read.
491
622
  */
492
- export async function configImports(configPath: string): Promise<string[]> {
623
+ export async function configImports(
624
+ configPath: string,
625
+ configSource?: string,
626
+ read: (file: string) => Promise<string> = (file) => Bun.file(file).text(),
627
+ ): Promise<string[]> {
493
628
  const seen = new Set<string>([configPath])
629
+ const memo = lookupMemo()
494
630
  const out: string[] = []
495
631
  const queue = [configPath]
632
+ let given = configSource
496
633
  while (queue.length > 0) {
497
634
  const file = queue.shift()!
498
635
  let source: string
499
636
  try {
500
- source = await Bun.file(file).text()
637
+ source = given ?? (await read(file))
501
638
  } catch {
502
639
  continue
640
+ } finally {
641
+ given = undefined
503
642
  }
504
643
  let specs: string[]
505
644
  try {
@@ -516,24 +655,26 @@ export async function configImports(configPath: string): Promise<string[]> {
516
655
  }
517
656
  let dir: string | undefined
518
657
  for (const spec of specs) {
519
- if (!spec.startsWith('./') && !spec.startsWith('../')) continue
520
- let resolved: string
658
+ let t: ReturnType<typeof localTarget>
521
659
  try {
522
660
  dir ??= realDirOf(file)
523
- resolved = realPath(Bun.resolveSync(spec, dir))
661
+ t = localTarget(spec, dir, memo)
524
662
  } catch {
663
+ continue
664
+ }
665
+ if (t === undefined) continue
666
+ if (!('file' in t)) {
525
667
  // Missing for now, still imported: the config that names it fails
526
668
  // to load until it is back, and a list without it left `vx watch`
527
669
  // deaf to its return. Named as written, and not walked.
528
- if (dir === undefined) continue
529
- for (const ext of path.extname(spec) === '' ? RESOLVED_EXTENSIONS : ['']) {
530
- const missing = path.resolve(dir, spec + ext)
670
+ for (const missing of t.named) {
531
671
  if (seen.has(missing)) continue
532
672
  seen.add(missing)
533
673
  out.push(missing)
534
674
  }
535
675
  continue
536
676
  }
677
+ const resolved = realpathOr(t.file)
537
678
  if (seen.has(resolved) || resolved.split(path.sep).includes('node_modules')) continue
538
679
  seen.add(resolved)
539
680
  out.push(resolved)