@vzn/vx 0.0.651 → 0.0.653

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 (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. package/src/workspace/workspace.ts +190 -30
@@ -36,8 +36,10 @@
36
36
  // with the message the first load gives (item 701).
37
37
 
38
38
  import path from 'node:path'
39
- import { MAX_TIMEOUT_MS, parseDuration, UserError } from '../util/index.js'
39
+ import * as ui from '../ui/index.js'
40
+ import { MAX_TIMEOUT_MS, parseDuration, testTimerMs, UserError } from '../util/index.js'
40
41
  import { nonJsonMessage, nonJsonPaths, type NonJsonValue } from './json-data.js'
42
+ import { changedExports, exportState } from './module-state.js'
41
43
 
42
44
  /** Why a config's `process.exit` throws, on both load paths (D-65). */
43
45
  export const CONFIG_EXIT = 'a config exports its object; it cannot end the run'
@@ -76,6 +78,8 @@ export const WATCHED_BUILTIN_NAMES = [
76
78
 
77
79
  const WORKER_SRC = `
78
80
  const nonJsonPaths = ${nonJsonPaths.toString()}
81
+ const exportState = ${exportState.toString()}
82
+ const changedExports = ${changedExports.toString()}
79
83
  // The parent's stdout is a verb's JSON, and a config printing on a repeat
80
84
  // load wrote into it: the parent's own redirect covers the parent thread
81
85
  // only (D-64). This worker only evaluates configs, so every route to fd 1
@@ -171,6 +175,15 @@ self.onmessage = async (e) => {
171
175
  const umaskBack = () => {
172
176
  if (umaskIn !== null && globalThis.process.umask() !== umaskIn) globalThis.process.umask(umaskIn)
173
177
  }
178
+ // Asked which shared modules this config changes: each is imported
179
+ // first, so its state before the config's own code runs is known.
180
+ const modules = []
181
+ for (const m of e.data.modules ?? []) {
182
+ try {
183
+ const ns = await import(m)
184
+ modules.push([m, ns, exportState(ns)])
185
+ } catch {}
186
+ }
174
187
  try {
175
188
  const literalPath = /[\\\\?]/.test(path)
176
189
  if (literalPath) {
@@ -196,6 +209,7 @@ self.onmessage = async (e) => {
196
209
  json: isObject && nonJson.length === 0 ? JSON.stringify(mod) : null,
197
210
  fn: typeof mod === 'function',
198
211
  changed: report,
212
+ modules: modules.filter(([, ns, before]) => changedExports(before, exportState(ns)).length > 0).map(([m]) => m),
199
213
  })
200
214
  } catch (thrown) {
201
215
  // Two syntax errors arrive as one AggregateError, whose own message
@@ -289,6 +303,41 @@ export function evalBudgetMs(): number {
289
303
  return n !== null && n > 0 && n <= MAX_TIMEOUT_MS ? n : 30_000
290
304
  }
291
305
 
306
+ /** How long a config evaluates before vx says it is still waiting on it. */
307
+ const EVAL_NOTICE_MS = 5_000
308
+
309
+ /**
310
+ * Calls `expire` once `budget` ms pass, and says once, at the notice mark,
311
+ * which config is still evaluating: a top-level await that never settles
312
+ * was otherwise `budget` ms of silence. One timer at a time, re-armed at the
313
+ * notice, so a load costs the one timer it always had. Returns the clear.
314
+ * `hold` keeps the process alive for it: in process, nothing else holds
315
+ * it under a never-settled top-level await, and the run exited silently.
316
+ */
317
+ export function evalDeadline(
318
+ kind: string,
319
+ configPath: string,
320
+ budget: number,
321
+ expire: () => void,
322
+ hold: boolean,
323
+ ): () => void {
324
+ const noticeMs = testTimerMs('CONFIG_EVAL_NOTICE', EVAL_NOTICE_MS)
325
+ let timer: ReturnType<typeof setTimeout>
326
+ if (noticeMs < budget) {
327
+ timer = setTimeout(() => {
328
+ const time = (ms: number): string => (ms < 1000 ? `${ms} ms` : `${ms / 1000} s`)
329
+ ui.sayLine(
330
+ 'warn',
331
+ `${kind} config ${configPath} still evaluating after ${time(noticeMs)}; vx gives up at ${time(budget)} (VX_CONFIG_WORKER_TIMEOUT)`,
332
+ )
333
+ timer = setTimeout(expire, budget - noticeMs)
334
+ if (!hold) timer.unref()
335
+ }, noticeMs)
336
+ } else timer = setTimeout(expire, budget)
337
+ if (!hold) timer.unref()
338
+ return () => clearTimeout(timer)
339
+ }
340
+
292
341
  const pending = new Map<number, Pending>()
293
342
  let worker: Worker | null = null
294
343
  let inFlight = 0
@@ -410,7 +459,7 @@ export async function evaluateConfigFresh(configPath: string): Promise<unknown>
410
459
  inFlight++
411
460
  // Declared out here so the `finally` can clear it on EVERY exit, including a
412
461
  // rejection. See the note at the clear itself.
413
- let timer: ReturnType<typeof setTimeout> | undefined
462
+ let clear: (() => void) | undefined
414
463
  try {
415
464
  const w = acquireWorker()
416
465
  // A worker thread the OS kills — memory pressure on a loaded CI box, a
@@ -422,14 +471,19 @@ export async function evaluateConfigFresh(configPath: string): Promise<unknown>
422
471
  const budget = evalBudgetMs()
423
472
  const reply = await new Promise<WorkerReply>((resolve, reject) => {
424
473
  pending.set(id, { resolve, reject, configPath })
425
- timer = setTimeout(() => {
426
- rejectAll(new Error(`config worker did not answer within ${budget}ms`))
427
- if (worker !== null) {
428
- worker.terminate()
429
- worker = null
430
- }
431
- }, budget)
432
- timer.unref?.()
474
+ clear = evalDeadline(
475
+ 'Project',
476
+ configPath,
477
+ budget,
478
+ () => {
479
+ rejectAll(new Error(`config worker did not answer within ${budget}ms`))
480
+ if (worker !== null) {
481
+ worker.terminate()
482
+ worker = null
483
+ }
484
+ },
485
+ false,
486
+ )
433
487
  w.postMessage({ id, path: abs, env: { ...process.env } })
434
488
  })
435
489
  const [nonJson] = reply.nonJson
@@ -445,7 +499,7 @@ export async function evaluateConfigFresh(configPath: string): Promise<unknown>
445
499
  // later fired it ran `rejectAll()` and terminated whatever worker was
446
500
  // current by then, killing an unrelated healthy round with a timeout
447
501
  // message naming a budget nobody set for it.
448
- clearTimeout(timer)
502
+ clear?.()
449
503
  pending.delete(id)
450
504
  retireIfIdle()
451
505
  }
@@ -459,20 +513,40 @@ export async function evaluateConfigFresh(configPath: string): Promise<unknown>
459
513
  * changes nothing, fails before reporting, or outlives the budget.
460
514
  */
461
515
  export async function builtinsChangedBy(configPath: string): Promise<string[]> {
516
+ return (await blame(configPath, { blame: true })).changed ?? []
517
+ }
518
+
519
+ /**
520
+ * Which of `modules` evaluating `configPath` alone changes the exported
521
+ * values of, in a worker of its own: a round that found a module several
522
+ * configs import changed asks each of them in turn, to name the one that
523
+ * changed it. Empty as `builtinsChangedBy` is.
524
+ */
525
+ export async function modulesChangedBy(
526
+ configPath: string,
527
+ modules: readonly string[],
528
+ ): Promise<string[]> {
529
+ return (await blame(configPath, { modules })).modules ?? []
530
+ }
531
+
532
+ async function blame(
533
+ configPath: string,
534
+ ask: { blame?: true; modules?: readonly string[] },
535
+ ): Promise<{ changed?: string[]; modules?: string[] }> {
462
536
  const w = new Worker(WORKER_URL)
463
537
  try {
464
- return await new Promise<string[]>((resolve) => {
465
- const timer = setTimeout(() => resolve([]), evalBudgetMs())
538
+ return await new Promise((resolve) => {
539
+ const timer = setTimeout(() => resolve({}), evalBudgetMs())
466
540
  timer.unref?.()
467
541
  w.onmessage = (event: MessageEvent): void => {
468
542
  clearTimeout(timer)
469
- resolve((event.data as { changed?: string[] }).changed ?? [])
543
+ resolve(event.data as { changed?: string[]; modules?: string[] })
470
544
  }
471
545
  w.onerror = (): void => {
472
546
  clearTimeout(timer)
473
- resolve([])
547
+ resolve({})
474
548
  }
475
- w.postMessage({ id: 0, path: path.resolve(configPath), env: { ...process.env }, blame: true })
549
+ w.postMessage({ id: 0, path: path.resolve(configPath), env: { ...process.env }, ...ask })
476
550
  })
477
551
  } finally {
478
552
  w.terminate()
@@ -474,8 +474,15 @@ function scanLocalImports(
474
474
  fromDir: string,
475
475
  loader: 'ts' | 'js',
476
476
  memo: LookupMemo,
477
+ /** Also follow a bare specifier into a package linked from outside `node_modules` ({@link linkedPackageFile}). */
478
+ linked = false,
477
479
  ): string[] {
478
- if (!LOCAL_CANDIDATE.test(source) && !hasAliasCandidate(source, fromDir, memo.tsconfig)) return []
480
+ if (
481
+ !LOCAL_CANDIDATE.test(source) &&
482
+ !hasAliasCandidate(source, fromDir, memo.tsconfig) &&
483
+ !(linked && hasBareCandidate(source))
484
+ )
485
+ return []
479
486
  let specifiers: string[]
480
487
  try {
481
488
  specifiers = scanner(loader)
@@ -488,13 +495,41 @@ function scanLocalImports(
488
495
  for (const spec of specifiers) {
489
496
  // Selection may widen on a named path; it is never hashed.
490
497
  const t = localTarget(spec, fromDir, memo)
491
- if (t === undefined) continue
492
- if ('file' in t) out.push(t.file)
498
+ if (t === undefined) {
499
+ const file = linked ? linkedPackageFile(spec, fromDir) : undefined
500
+ if (file !== undefined) out.push(file)
501
+ } else if ('file' in t) out.push(t.file)
493
502
  else out.push(...t.named)
494
503
  }
495
504
  return out
496
505
  }
497
506
 
507
+ /**
508
+ * The file a bare `spec` loads when the nearest `node_modules/<package>`
509
+ * above `fromDir` links to a directory outside any `node_modules`: a
510
+ * workspace package (`workspace:*`), whose source the lockfile does not
511
+ * pin. Realpath'd. Undefined for an installed package, or none installed:
512
+ * resolving then could ask the registry.
513
+ */
514
+ function linkedPackageFile(spec: string, fromDir: string): string | undefined {
515
+ if (spec.startsWith('.') || spec.startsWith('/') || !needsLookup(spec)) return undefined
516
+ const pkg = packageOf(spec)
517
+ for (let dir = fromDir; ;) {
518
+ const installed = path.join(dir, 'node_modules', pkg)
519
+ if (existsSync(installed)) {
520
+ if (realpathOr(installed).split(path.sep).includes('node_modules')) return undefined
521
+ try {
522
+ return realpathOr(Bun.resolveSync(spec, fromDir))
523
+ } catch {
524
+ return undefined
525
+ }
526
+ }
527
+ const up = path.dirname(dir)
528
+ if (up === dir) return undefined
529
+ dir = up
530
+ }
531
+ }
532
+
498
533
  export interface ConfigImportOwnersArgs {
499
534
  /**
500
535
  * Realpath'd HERE, not by the caller. `Bun.resolveSync` returns realpath'd
@@ -516,6 +551,11 @@ export interface ConfigImportOwners {
516
551
  owners: Set<string>
517
552
  /** A changed file a workspace config imports, which can re-key any task. */
518
553
  workspace?: string
554
+ /**
555
+ * The files a workspace config imports above the workspace root,
556
+ * realpath'd: `changed` cannot name them, so the caller asks git.
557
+ */
558
+ outside?: string[]
519
559
  }
520
560
 
521
561
  const TS_EXT = new Set(['.ts', '.mts', '.cts'])
@@ -527,7 +567,7 @@ const RESOLVED_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs
527
567
  export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<ConfigImportOwners> {
528
568
  const selected = new Set<string>()
529
569
  const wsConfigs = a.workspaceConfigs ?? []
530
- if (a.changed.length === 0) return { owners: selected }
570
+ if (a.changed.length === 0 && wsConfigs.length === 0) return { owners: selected }
531
571
  if (a.skip.size === a.projects.length && wsConfigs.length === 0) return { owners: selected }
532
572
  const workspaceRoot = await realpath(a.workspaceRoot).catch(() => a.workspaceRoot)
533
573
 
@@ -549,39 +589,50 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Con
549
589
  const memo = lookupMemo()
550
590
  const importedBy = new Map<string, string[]>()
551
591
  const visited = new Set<string>()
552
- // A level at a time, its files read together: one awaited read per
553
- // config put 5,000 of them 200 ms behind (D-23).
554
- let frontier = [...roots.keys(), ...wsRoots]
555
- while (frontier.length > 0) {
556
- const read = await Promise.all(
557
- frontier.map(async (file) => {
558
- if (visited.has(file)) return null
559
- visited.add(file)
560
- try {
561
- return { file, source: await Bun.file(file).text() }
562
- } catch (err) {
563
- // Out of descriptors is this level's width, not the file: read as
564
- // no edges, it dropped every project the import selects (D-63).
565
- if (isOutOfFds(err)) throw err
566
- return null // unreadable: no edges, and not this pass's problem to report
592
+ // The workspace config is loaded live, not from the lock, so its walk
593
+ // goes further: into a workspace package it names (linked into
594
+ // node_modules), and above the root, which `changed` cannot name.
595
+ const outside = new Set<string>()
596
+ const walkImports = async (start: string[], ws: boolean): Promise<void> => {
597
+ // A level at a time, its files read together: one awaited read per
598
+ // config put 5,000 of them 200 ms behind (D-23).
599
+ let frontier = start
600
+ while (frontier.length > 0) {
601
+ const read = await Promise.all(
602
+ frontier.map(async (file) => {
603
+ if (visited.has(file)) return null
604
+ visited.add(file)
605
+ try {
606
+ return { file, source: await Bun.file(file).text() }
607
+ } catch (err) {
608
+ // Out of descriptors is this level's width, not the file: read as
609
+ // no edges, it dropped every project the import selects (D-63).
610
+ if (isOutOfFds(err)) throw err
611
+ return null // unreadable: no edges, and not this pass's problem to report
612
+ }
613
+ }),
614
+ )
615
+ frontier = []
616
+ for (const entry of read) {
617
+ if (entry === null) continue
618
+ const { file, source } = entry
619
+ const loader = TS_EXT.has(path.extname(file)) ? 'ts' : 'js'
620
+ for (const target of scanLocalImports(source, path.dirname(file), loader, memo, ws)) {
621
+ if (target.split(path.sep).includes('node_modules')) continue
622
+ if (!target.startsWith(workspaceRoot + path.sep)) {
623
+ if (!ws) continue
624
+ outside.add(target)
625
+ }
626
+ const list = importedBy.get(target)
627
+ if (list) list.push(file)
628
+ else importedBy.set(target, [file])
629
+ frontier.push(target)
567
630
  }
568
- }),
569
- )
570
- frontier = []
571
- for (const entry of read) {
572
- if (entry === null) continue
573
- const { file, source } = entry
574
- const loader = TS_EXT.has(path.extname(file)) ? 'ts' : 'js'
575
- for (const target of scanLocalImports(source, path.dirname(file), loader, memo)) {
576
- if (!target.startsWith(workspaceRoot + path.sep)) continue
577
- if (target.split(path.sep).includes('node_modules')) continue
578
- const list = importedBy.get(target)
579
- if (list) list.push(file)
580
- else importedBy.set(target, [file])
581
- frontier.push(target)
582
631
  }
583
632
  }
584
633
  }
634
+ await walkImports([...wsRoots], true)
635
+ await walkImports([...roots.keys()], false)
585
636
 
586
637
  let workspace: string | undefined
587
638
  const seen = new Set<string>()
@@ -597,7 +648,8 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Con
597
648
  if (wsRoots.has(file)) workspace ??= from
598
649
  for (const importer of importedBy.get(file) ?? []) walk.push([importer, from])
599
650
  }
600
- return workspace === undefined ? { owners: selected } : { owners: selected, workspace }
651
+ if (workspace !== undefined) return { owners: selected, workspace }
652
+ return outside.size === 0 ? { owners: selected } : { owners: selected, outside: [...outside] }
601
653
  }
602
654
 
603
655
  /**
@@ -618,12 +670,15 @@ function realDirOf(file: string): string {
618
670
  * (item 949). A file that cannot be read ends its branch; an import that
619
671
  * cannot be resolved is still listed, by the names it would have.
620
672
  * `configSource` is the config's, when the caller has read it; `read` reads the
621
- * rest, for a caller that keeps what was read.
673
+ * rest, for a caller that keeps what was read. `statics`, when given,
674
+ * gets each file the config reaches through static imports alone: the ones
675
+ * its evaluation always evaluates, a dynamic `import()` aside.
622
676
  */
623
677
  export async function configImports(
624
678
  configPath: string,
625
679
  configSource?: string,
626
680
  read: (file: string) => Promise<string> = (file) => Bun.file(file).text(),
681
+ statics?: Set<string>,
627
682
  ): Promise<string[]> {
628
683
  const seen = new Set<string>([configPath])
629
684
  const memo = lookupMemo()
@@ -640,21 +695,20 @@ export async function configImports(
640
695
  } finally {
641
696
  given = undefined
642
697
  }
643
- let specs: string[]
698
+ let specs: Array<{ path: string; kind: string }>
644
699
  try {
645
- specs = scanner(TS_EXT.has(path.extname(file)) ? 'ts' : 'js')
646
- .scanImports(source)
647
- .map((i) => i.path)
700
+ specs = scanner(TS_EXT.has(path.extname(file)) ? 'ts' : 'js').scanImports(source)
648
701
  } catch {
649
702
  // A source the parser refuses (mid-edit) keeps the imports a plain
650
703
  // match sees: watching too few files is what this list exists to
651
704
  // prevent.
652
705
  specs = [...source.matchAll(/\bfrom\s*['"]([^'"]+)['"]|\bimport\s*['"]([^'"]+)['"]/g)].map(
653
- (m) => (m[1] ?? m[2])!,
706
+ (m) => ({ path: (m[1] ?? m[2])!, kind: 'import-statement' }),
654
707
  )
655
708
  }
709
+ const fromStatic = statics !== undefined && (file === configPath || statics.has(file))
656
710
  let dir: string | undefined
657
- for (const spec of specs) {
711
+ for (const { path: spec, kind } of specs) {
658
712
  let t: ReturnType<typeof localTarget>
659
713
  try {
660
714
  dir ??= realDirOf(file)
@@ -679,6 +733,7 @@ export async function configImports(
679
733
  seen.add(resolved)
680
734
  out.push(resolved)
681
735
  queue.push(resolved)
736
+ if (fromStatic && kind === 'import-statement') statics.add(resolved)
682
737
  }
683
738
  }
684
739
  return out