rvlib-shipkit 0.38.1 → 1.0.0

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 (252) hide show
  1. package/CHANGELOG.md +367 -16
  2. package/README.md +26 -14
  3. package/package.json +2 -2
  4. package/src/assets/gen/comfyts-gen.ts +27 -10
  5. package/src/assets/gen/generate-roll.ts +1 -1
  6. package/src/assets/kit-config.ts +6 -6
  7. package/src/assets/run-assets.ts +14 -10
  8. package/src/assets/server/studio-server.ts +6 -5
  9. package/src/check.ts +343 -0
  10. package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
  11. package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
  12. package/src/checks/SK003-claude-md-shape.check.ts +66 -0
  13. package/src/checks/SK004-gitignore-policy.check.ts +309 -0
  14. package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
  15. package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
  16. package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
  17. package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
  18. package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
  19. package/src/checks/SK010-undeclared-unit.check.ts +111 -0
  20. package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
  21. package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
  22. package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
  23. package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
  24. package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
  25. package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
  26. package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
  27. package/src/checks/SK018-test-files-run.check.ts +28 -0
  28. package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
  29. package/src/checks/SK020-self-spawning-check.check.ts +147 -0
  30. package/src/checks/contracts.check.ts +20 -0
  31. package/src/cli/cmd_Asks.ts +3 -2
  32. package/src/cli/cmd_Assets.ts +2 -2
  33. package/src/cli/cmd_Bump.ts +1 -1
  34. package/src/cli/cmd_Check.ts +6 -6
  35. package/src/cli/cmd_Checks.ts +95 -0
  36. package/src/cli/cmd_Ci.ts +48 -13
  37. package/src/cli/cmd_Commands.ts +2 -6
  38. package/src/cli/cmd_ConfigKeys.ts +69 -0
  39. package/src/cli/cmd_Contracts.ts +1 -1
  40. package/src/cli/cmd_Fleet.ts +6 -2
  41. package/src/cli/cmd_Hooks.ts +1 -1
  42. package/src/cli/cmd_Keys.ts +43 -18
  43. package/src/cli/cmd_Mv.ts +2 -2
  44. package/src/cli/cmd_Prose.ts +10 -1
  45. package/src/cli/cmd_Releases.ts +2 -2
  46. package/src/cli/cmd_Serve.ts +1 -1
  47. package/src/cli/cmd_Start.ts +6 -6
  48. package/src/cli/cmd_Status.ts +25 -0
  49. package/src/cli/cmd_Tui.ts +2 -2
  50. package/src/cli.ts +7 -4
  51. package/src/collections/actions.ts +4 -4
  52. package/src/collections/checks.ts +258 -0
  53. package/src/collections/collection-types.ts +254 -1
  54. package/src/collections/comfy.ts +23 -0
  55. package/src/collections/md-body.ts +14 -0
  56. package/src/collections/pings.ts +38 -12
  57. package/src/collections/posts.ts +19 -3
  58. package/src/collections/providers.ts +198 -91
  59. package/src/collections/reflections.ts +41 -0
  60. package/src/collections/state-rules.ts +73 -0
  61. package/src/collections/test-time.ts +288 -0
  62. package/src/collections/tests.ts +181 -0
  63. package/src/collections/wikis.ts +66 -0
  64. package/src/comfy/comfy-panel.ts +292 -0
  65. package/src/contracts/apply-contracts.ts +17 -12
  66. package/src/contracts/contract-types.ts +16 -4
  67. package/src/contracts/contracts-engine.ts +78 -42
  68. package/src/contracts/manifests/discord.contract.ts +6 -6
  69. package/src/contracts/manifests/identity.contract.ts +3 -3
  70. package/src/contracts/manifests/itch.contract.ts +12 -15
  71. package/src/contracts/manifests/social.contract.ts +5 -5
  72. package/src/contracts/manifests/steam.contract.ts +16 -16
  73. package/src/contracts/run-contracts.ts +4 -3
  74. package/src/core/attach.ts +96 -0
  75. package/src/core/canonical-scripts.ts +25 -0
  76. package/src/core/check-ctx.ts +204 -14
  77. package/src/core/check-file-names.ts +112 -0
  78. package/src/core/check-pass.ts +84 -0
  79. package/src/core/check-runner.ts +796 -0
  80. package/src/core/config-keys.ts +256 -0
  81. package/src/core/config-rewrite.ts +77 -0
  82. package/src/core/discover-checks.ts +166 -90
  83. package/src/core/discover-legacy-checks.ts +26 -0
  84. package/src/core/doc-block.ts +13 -0
  85. package/src/core/floor-resolve.ts +264 -0
  86. package/src/core/floor.ts +20 -137
  87. package/src/core/git-bin.ts +24 -0
  88. package/src/core/git-root.ts +108 -0
  89. package/src/core/instance.ts +195 -0
  90. package/src/core/jobs.ts +118 -0
  91. package/src/core/layout.ts +52 -0
  92. package/src/core/legacy-shapes.ts +207 -0
  93. package/src/core/lib-root.ts +5 -0
  94. package/src/core/locked-write.ts +110 -39
  95. package/src/core/machine-commit.ts +5 -4
  96. package/src/core/migrate-layout.ts +557 -0
  97. package/src/core/motherhome.ts +2 -8
  98. package/src/core/nesting.ts +4 -12
  99. package/src/core/one-way-in-policy.ts +73 -17
  100. package/src/core/outcome-words.ts +45 -0
  101. package/src/core/package-manager.ts +86 -0
  102. package/src/core/parallel.ts +21 -0
  103. package/src/core/process-tree.ts +44 -0
  104. package/src/core/raw-access.ts +93 -0
  105. package/src/core/read-set.ts +152 -0
  106. package/src/core/run-check.ts +155 -213
  107. package/src/core/run-ci.ts +153 -189
  108. package/src/core/run-fix.ts +96 -0
  109. package/src/core/run-fixers.ts +53 -30
  110. package/src/core/self-update.ts +9 -3
  111. package/src/core/slots.ts +83 -0
  112. package/src/core/spawn-timeout.ts +34 -3
  113. package/src/core/when-block.ts +401 -0
  114. package/src/core/when.ts +135 -0
  115. package/src/core/write-status.ts +5 -2
  116. package/src/hooks/leak-guard.ts +10 -8
  117. package/src/hooks/one-way-in-guard.ts +13 -5
  118. package/src/hooks/root-identity.ts +2 -1
  119. package/src/hooks/run-hooks.ts +35 -23
  120. package/src/init-repo.ts +39 -23
  121. package/src/ledger/check-stats.ts +155 -0
  122. package/src/ledger/check-table.ts +179 -0
  123. package/src/ledger/db.ts +255 -0
  124. package/src/ledger/gate-checks.ts +433 -0
  125. package/src/ledger/git-key.ts +116 -77
  126. package/src/ledger/git-scan.ts +96 -35
  127. package/src/ledger/ledger.ts +116 -42
  128. package/src/ledger/live-runs.ts +111 -0
  129. package/src/ledger/live-timeline.ts +97 -0
  130. package/src/ledger/repo-view.ts +73 -0
  131. package/src/ledger/run-log.ts +448 -0
  132. package/src/ledger/run-timeline.ts +173 -0
  133. package/src/ledger/store.ts +23 -12
  134. package/src/ledger/suite-log.ts +161 -0
  135. package/src/ledger/toolchain-identity.ts +7 -1
  136. package/src/ledger/world-cache.ts +111 -0
  137. package/src/mv/mv-md.ts +16 -6
  138. package/src/problems/extract.ts +182 -0
  139. package/src/problems/problem-board.ts +37 -0
  140. package/src/problems/problem-line.ts +77 -0
  141. package/src/prose/prose-view.ts +90 -0
  142. package/src/prose/run-prose.ts +60 -19
  143. package/src/prose/ste.ts +140 -24
  144. package/src/reflections/reflections-queue.ts +81 -0
  145. package/src/release/git-facts.ts +15 -9
  146. package/src/release/run-bump.ts +6 -5
  147. package/src/release/run-release.ts +25 -11
  148. package/src/release/run-releases.ts +2 -2
  149. package/src/release/unit-gate.ts +7 -54
  150. package/src/release/unit-resolve.ts +282 -0
  151. package/src/release/units.ts +33 -217
  152. package/src/repo-config.ts +155 -85
  153. package/src/rules/rule.ts +36 -96
  154. package/src/rules/untrack-generated.ts +5 -4
  155. package/src/scaffold/files/vscode-tasks.json +10 -31
  156. package/src/scaffold/jsonc-spans.ts +113 -0
  157. package/src/scaffold/project-scaffold.ts +16 -43
  158. package/src/scan.ts +98 -0
  159. package/src/serve/gate-trigger.ts +148 -0
  160. package/src/serve/health-schedule.ts +102 -0
  161. package/src/serve/hub-app/chart-view.ts +179 -0
  162. package/src/serve/hub-app/check-view.tsx +345 -0
  163. package/src/serve/hub-app/check-words.ts +164 -0
  164. package/src/serve/hub-app/checks-svc.ts +280 -0
  165. package/src/serve/hub-app/clock-svc.ts +33 -0
  166. package/src/serve/hub-app/col-card.tsx +23 -39
  167. package/src/serve/hub-app/cols-svc.ts +44 -20
  168. package/src/serve/hub-app/comfy-svc.ts +171 -0
  169. package/src/serve/hub-app/comfy-view.tsx +81 -0
  170. package/src/serve/hub-app/dur.ts +41 -0
  171. package/src/serve/hub-app/hash-route.ts +121 -0
  172. package/src/serve/hub-app/header-bar.tsx +117 -0
  173. package/src/serve/hub-app/health-svc.ts +23 -28
  174. package/src/serve/hub-app/help.tsx +29 -0
  175. package/src/serve/hub-app/hub-app.tsx +122 -119
  176. package/src/serve/hub-app/hub-st.ts +115 -42
  177. package/src/serve/hub-app/hub-types.ts +117 -2
  178. package/src/serve/hub-app/hub.css +534 -192
  179. package/src/serve/hub-app/index.html +2 -0
  180. package/src/serve/hub-app/live-svc.ts +174 -0
  181. package/src/serve/hub-app/md.ts +15 -0
  182. package/src/serve/hub-app/media-svc.ts +4 -1
  183. package/src/serve/hub-app/media-view.tsx +15 -0
  184. package/src/serve/hub-app/next-run.ts +68 -0
  185. package/src/serve/hub-app/project-gutter.css +65 -0
  186. package/src/serve/hub-app/project-gutter.tsx +128 -0
  187. package/src/serve/hub-app/projects-svc.ts +226 -0
  188. package/src/serve/hub-app/prose-hits-st.ts +124 -0
  189. package/src/serve/hub-app/prose-hits-view.tsx +176 -0
  190. package/src/serve/hub-app/prose-hits.css +21 -0
  191. package/src/serve/hub-app/run-strip.ts +47 -0
  192. package/src/serve/hub-app/runs-tabs.ts +24 -0
  193. package/src/serve/hub-app/test-view.tsx +107 -0
  194. package/src/serve/hub-app/tests-svc.ts +62 -0
  195. package/src/serve/hub-app/tests-view.tsx +238 -0
  196. package/src/serve/hub-app/time-ago.tsx +34 -0
  197. package/src/serve/hub-app/timeline-chart.tsx +274 -0
  198. package/src/serve/hub-app/timeline-geometry.ts +314 -0
  199. package/src/serve/hub-app/timeline-svc.ts +62 -0
  200. package/src/serve/hub-app/timeline-view.tsx +245 -0
  201. package/src/serve/hub-app/tree-build.ts +410 -0
  202. package/src/serve/hub-app/tree-row.tsx +42 -0
  203. package/src/serve/hub-app/tree-svc.ts +174 -0
  204. package/src/serve/hub-app/wikis-view.tsx +53 -0
  205. package/src/serve/other-repos.ts +79 -0
  206. package/src/serve/project-gutter-route.ts +350 -0
  207. package/src/serve/project-gutter.ts +449 -0
  208. package/src/serve/run-serve.ts +245 -16
  209. package/src/serve/same-origin.ts +14 -0
  210. package/src/serve/serve-hub.ts +213 -18
  211. package/src/shipkit.ts +19 -12
  212. package/src/smoke/run-smoke.ts +3 -2
  213. package/src/start-repo.ts +75 -36
  214. package/src/suite/judge-suites.ts +209 -15
  215. package/src/suite/kept-timings.ts +58 -0
  216. package/src/suite/runner-shapes.ts +88 -0
  217. package/src/suite/suite-cli.ts +147 -24
  218. package/src/suite/suite-cpu-preload.ts +92 -0
  219. package/src/suite/test-units.ts +243 -0
  220. package/src/ts/config.ts +4 -69
  221. package/src/ts/jsonc.ts +69 -0
  222. package/src/ts/lex.ts +143 -0
  223. package/src/ts/rename.ts +73 -10
  224. package/src/ts/source-graph.ts +28 -8
  225. package/src/ts/tsconfig-projects.ts +6 -97
  226. package/src/ts/tsconfig-resolve.ts +108 -0
  227. package/src/tui/build-steps-panel.ts +61 -0
  228. package/src/tui/checks-panel.ts +24 -16
  229. package/src/tui/run-tui.ts +2 -2
  230. package/src/tui/tui-panel.ts +5 -4
  231. package/src/wiki/graph-colors.ts +68 -0
  232. package/src/wiki/graph-mount.ts +7 -9
  233. package/src/wiki/graph-page.ts +42 -25
  234. package/src/wiki/graph-positions.ts +105 -0
  235. package/src/wiki/run-wiki.ts +1 -1
  236. package/src/wiki/serve-graph.ts +33 -3
  237. package/src/cli/cmd_Rules.ts +0 -51
  238. package/src/cli/cmd_Units.ts +0 -82
  239. package/src/collections/graphs.ts +0 -32
  240. package/src/contracts/contracts-once.ts +0 -43
  241. package/src/core/check-suite.ts +0 -302
  242. package/src/core/repair-contract-dirs.ts +0 -136
  243. package/src/core/task-runner.ts +0 -339
  244. package/src/ledger/gate-units.ts +0 -138
  245. package/src/ledger/unit-table.ts +0 -105
  246. package/src/rules/SK002-contracts-fulfillness.ts +0 -24
  247. package/src/rules/SK003-claude-md-shape.ts +0 -70
  248. package/src/rules/SK004-gitignore-policy.ts +0 -238
  249. package/src/rules/SK010-undeclared-release-unit.ts +0 -127
  250. package/src/rules/rule-registry.ts +0 -64
  251. package/src/rules/run-fix.ts +0 -72
  252. package/src/tui/tasks-panel.ts +0 -60
@@ -6,17 +6,32 @@
6
6
  // `Ran N tests` so the gate can still report what ran.
7
7
  //
8
8
  // usage: suite-cli [--budget-ms N] [--slow file]... [--serial file]... [--declared-in <where>] -- <bun test args>
9
- import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
10
- import { tmpdir } from 'os'
9
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
10
+ import { availableParallelism, loadavg, tmpdir } from 'os'
11
11
  import { join } from 'path'
12
- import { FULL_TESTS_ENV } from '../ledger/gate-units.ts'
13
- import { confirmOver, crashedWorkers, DEFAULT_BUDGET_MS, judgeSuites, normSuitePath, parseSuiteFlags, planPhases } from './judge-suites.ts'
12
+ import { FULL_TESTS_ENV } from '../ledger/gate-checks.ts'
13
+ import { CHECK_ID_ENV, recordSuites, type SuitePhase } from '../ledger/suite-log.ts'
14
+ import { killTree } from '../core/spawn-timeout.ts'
15
+ import { parallelCap } from '../core/parallel.ts'
16
+ import { poolDir, poolOccupancy } from '../core/slots.ts'
17
+ import { keepTimings, seedTimings, timingsHome } from './kept-timings.ts'
18
+ import { aloneRunsNeeded, bunfigTest, readPhaseCpu, SUITE_CPU_ENV, type AloneRun, type Confirmed, confirmOver, crashedWorkers, type MachineLoad, WAIT_FACTOR, DEFAULT_BUDGET_MS, FULL_CEILING_MS, failedSuites, failLines, judgeSuites, normSuitePath, parseSuiteFlags, planPhases, QUICK_CEILING_MS, poolWorkers } from './judge-suites.ts'
14
19
 
15
- type Phase = { code: number; signal: string | null; out: string; timings: Record<string, number> }
20
+ type Phase = { code: number; signal: string | null; stalled: boolean; out: string; timings: Record<string, number>; cpuMs: number | null }
16
21
 
17
- async function runPhase(args: string[], timingsFile: string | null, echo = true): Promise<Phase> {
22
+ async function runPhase(args: string[], timingsFile: string | null, ceilingMs: number, echo = true, cpuFile: string | null = null): Promise<Phase> {
18
23
  const timing = timingsFile != null ? [`--timings=${timingsFile}`, '--update-timings'] : []
19
- const proc = Bun.spawn(['bun', 'test', ...args, ...timing], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' })
24
+ // the per suite CPU figure rides in a preload of the runner's own, beside
25
+ // the repo's (a command line preload adds to bunfig's, never replaces it)
26
+ const cpu = cpuFile != null ? ['--preload', CPU_PRELOAD] : []
27
+ const env = cpuFile != null ? { ...process.env, [SUITE_CPU_ENV]: cpuFile } : process.env
28
+ const proc = Bun.spawn(['bun', 'test', ...cpu, ...timing, ...args], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', env })
29
+ // the workers are children of `bun test`, and a stuck one is the process
30
+ // to reach, so the whole tree goes
31
+ let stalled = false
32
+ let tree: number[] = []
33
+ const ceiling = setTimeout(() => { stalled = true; tree = killTree(proc, 'SIGTERM') }, ceilingMs)
34
+ const killer = setTimeout(() => { killTree(proc, 'SIGKILL', tree) }, ceilingMs + 2000)
20
35
  let out = ''
21
36
  const pump = async (stream: ReadableStream<Uint8Array>, sink: NodeJS.WriteStream): Promise<void> => {
22
37
  const dec = new TextDecoder()
@@ -28,20 +43,41 @@ async function runPhase(args: string[], timingsFile: string | null, echo = true)
28
43
  }
29
44
  await Promise.all([pump(proc.stdout, process.stdout), pump(proc.stderr, process.stderr)])
30
45
  const code = await proc.exited
46
+ clearTimeout(ceiling)
47
+ clearTimeout(killer)
31
48
  let timings: Record<string, number> = {}
32
49
  try { if (timingsFile != null && existsSync(timingsFile)) timings = (JSON.parse(readFileSync(timingsFile, 'utf8')) as { files?: Record<string, number> }).files ?? {} }
33
50
  catch { timings = {} }
34
- return { code, signal: proc.signalCode ?? null, out, timings }
51
+ // wait4 rusage: the process plus every child it reaped, which is where
52
+ // bun test's workers and a suite's own subprocesses land
53
+ const usage = proc.resourceUsage()
54
+ const cpuMs = usage != null ? Number(usage.cpuTime.user + usage.cpuTime.system) / 1000 : null
55
+ return { code, signal: stalled ? null : proc.signalCode ?? null, stalled, out, timings, cpuMs }
35
56
  }
36
57
 
58
+ const CPU_PRELOAD = join(import.meta.dir, 'suite-cpu-preload.ts')
59
+
37
60
  const secs = (ms: number): string => `${(ms / 1000).toFixed(1)}s`
61
+
62
+ const machineLoad = (): MachineLoad => ({ load1: loadavg()[0] ?? 0, cores: availableParallelism() })
63
+
64
+ const overWords = (c: Confirmed): string =>
65
+ c.over === 'cpu' ? `${c.file} ${secs(c.ms)} of CPU (${secs(c.wallMs)} wall)`
66
+ : c.over === 'wait' ? `${c.file} ${secs(c.wallMs)} wall on an idle machine, more than ${WAIT_FACTOR}x the budget, with ${secs(c.cpuMs ?? 0)} of CPU: it waits (a timer, a sleep, a poll)`
67
+ : `${c.file} ${secs(c.wallMs)} wall (no CPU figure)`
68
+
69
+ /** the suite FILES a phase names: a flag is not a file, and the repo's own
70
+ * `bun test` arguments are not files this runner put there */
71
+ const phaseFiles = (phase: string[], rawArgs: string[]): string[] => phase.filter((a) => !a.startsWith('-') && !rawArgs.includes(a))
38
72
  const lastCount = (out: string, re: RegExp): number => Number([...out.matchAll(re)].at(-1)?.[1] ?? 0)
39
73
 
40
74
  export async function suiteCli(argv: string[]): Promise<number> {
75
+ const t0 = Date.now()
41
76
  const parsed = parseSuiteFlags(argv)
42
77
  if (typeof parsed === 'string') { console.log(`FAIL: suite runner: ${parsed}`); return 2 }
43
- const { tests, rawArgs, declaredIn } = parsed
78
+ const { tests, rawArgs, declaredIn, unit } = parsed
44
79
  const tier = process.env[FULL_TESTS_ENV] === '1' ? 'full' : 'quick'
80
+ const ceilingMs = parsed.ceilingMs ?? (tier === 'full' ? FULL_CEILING_MS : QUICK_CEILING_MS)
45
81
  const budget = tests.budgetMs ?? DEFAULT_BUDGET_MS
46
82
 
47
83
  // a declaration naming a gone suite would silently stop excluding anything
@@ -58,14 +94,44 @@ export async function suiteCli(argv: string[]): Promise<number> {
58
94
  // gates, and sharing a run measured slower than sequential and timed out
59
95
  const parallel = helpText.includes('--parallel') && tier === 'quick'
60
96
  const timed = helpText.includes('--update-timings')
61
- const plan = planPhases({ tests, tier, rawArgs, parallel })
97
+ // bun drops these the moment any ignore reaches its command line
98
+ const bunfig = bunfigTest(existsSync('bunfig.toml') ? readFileSync('bunfig.toml', 'utf8') : null)
99
+ if (bunfig.error != null) { console.log(`FAIL: ${bunfig.error}`); return 1 }
100
+ // the pool is read, never taken: this process runs on its check's slot
101
+ const cores = availableParallelism()
102
+ const held = parallel ? poolOccupancy({ dir: poolDir(), size: parallelCap() }).held.length : 0
103
+ const workers = parallel ? poolWorkers({ cores, size: parallelCap(), held }) : undefined
104
+ const plan = planPhases({ tests, tier, rawArgs, parallel, noIsolate: helpText.includes('--no-isolate'), configIgnores: bunfig.ignores, workers })
62
105
  const dir = mkdtempSync(join(tmpdir(), 'shipkit-suite-'))
63
106
  const notes: string[] = []
64
107
  if (!helpText.includes('--parallel')) notes.push(`skip: parallel suites (bun ${Bun.version} has no --parallel)`)
65
108
  if (!timed) notes.push(`skip: suite budget (bun ${Bun.version} writes no --timings)`)
66
109
  try {
67
- const p1 = await runPhase(plan.parallel, timed ? join(dir, 'parallel.json') : null)
68
- const p2 = plan.serial != null ? await runPhase(plan.serial, timed ? join(dir, 'serial.json') : null) : null
110
+ const setupMs = Date.now() - t0
111
+ // the parallel phase reads the durations the last run kept, so bun
112
+ // starts the slowest files first, and keeps what it measured for the next
113
+ const kept = timed && parallel ? timingsHome(process.cwd(), unit) : null
114
+ if (kept != null) seedTimings(kept, join(dir, 'parallel.json'))
115
+ const cpuFile = timed && parallel ? join(dir, 'cpu.tsv') : null
116
+ const p1At = Date.now()
117
+ const p1 = await runPhase(plan.parallel, timed ? join(dir, 'parallel.json') : null, ceilingMs, true, cpuFile)
118
+ const p1Ms = Date.now() - p1At
119
+ // bun merges what it read into what it writes, so a suite deleted since
120
+ // the last run is judged and kept only if the workers say it ran here
121
+ // or it is still on disk. On disk alone is the floor: a suite whose
122
+ // line went missing is never dropped from the judgment
123
+ const phaseCpu = cpuFile != null && existsSync(cpuFile) ? readPhaseCpu(readFileSync(cpuFile, 'utf8'), process.cwd()) : { ran: [], cpu: {} }
124
+ if (kept != null) {
125
+ const ranHere = new Set(phaseCpu.ran)
126
+ p1.timings = Object.fromEntries(Object.entries(p1.timings).filter(([f]) => (ranHere.has(normSuitePath(f)) || existsSync(f))))
127
+ if (!p1.stalled && p1.signal == null) {
128
+ writeFileSync(join(dir, 'parallel.json'), JSON.stringify({ version: 1, files: p1.timings }))
129
+ keepTimings(join(dir, 'parallel.json'), kept)
130
+ }
131
+ }
132
+ const p2At = Date.now()
133
+ const p2 = plan.serial != null ? await runPhase(plan.serial, timed ? join(dir, 'serial.json') : null, ceilingMs) : null
134
+ const p2Ms = p2 == null ? null : Date.now() - p2At
69
135
  const phases = [p1, ...(p2 != null ? [p2] : [])]
70
136
  const ran = phases.reduce((n, p) => n + lastCount(p.out, /Ran (\d+) tests?/g), 0)
71
137
  const failedTests = phases.reduce((n, p) => n + lastCount(p.out, /^\s*(\d+) fail$/gm), 0)
@@ -73,38 +139,95 @@ export async function suiteCli(argv: string[]): Promise<number> {
73
139
  const judged = judgeSuites({ timings, tests, tier })
74
140
  const slowest = judged.slowest.length > 0 ? ` (slowest: ${judged.slowest.map((t) => `${t.file} ${secs(t.ms)}`).join(', ')})` : ''
75
141
 
76
- // a parallel timing carries every other suite's load, so a suspect is
77
- // timed again alone, one process, before it can fail the task
78
- let over = judged.over
79
- if (over.length > 0) {
80
- const alone = await runPhase([...rawArgs, ...over.map((t) => t.file)], join(dir, 'alone.json'), false)
81
- over = confirmOver(over, alone.timings, budget)
142
+ // a parallel timing carries every other suite's load, so a suspect
143
+ // runs again alone, one process per suspect so its CPU is its own,
144
+ // before it can fail the task. One at a time: four alone runs side by
145
+ // side each burned about 40% more CPU than one alone (8 cores)
146
+ const aloneRuns: Record<string, AloneRun> = {}
147
+ const load = machineLoad()
148
+ const parallelCpu = phaseCpu.cpu
149
+ const { rerun, cleared: clearedInPhase } = aloneRunsNeeded(judged.over, parallelCpu, budget, load)
150
+ const aloneAt = Date.now()
151
+ for (const [i, t] of rerun.entries()) {
152
+ const run = await runPhase([...rawArgs, t.file], join(dir, `alone-${i}.json`), ceilingMs, false)
153
+ const wallMs = Object.entries(run.timings).find(([f]) => normSuitePath(f) === t.file)?.[1]
154
+ if (wallMs != null) aloneRuns[t.file] = { wallMs, cpuMs: run.cpuMs }
82
155
  }
156
+ const aloneMs = Date.now() - aloneAt
157
+ const { over, cleared } = rerun.length > 0 ? confirmOver(rerun, aloneRuns, budget, load) : { over: [], cleared: [] }
158
+ for (const c of clearedInPhase) notes.push(`note: ${c.file} took ${secs(c.wallMs)} in the parallel run and burned ${secs(c.cpuMs ?? 0)} of CPU there, plus ${secs(parallelCpu[c.file]?.priorMs ?? 0)} a lone run loads first, inside the ${budget}ms budget, so it did not run again alone`)
159
+ for (const c of cleared) notes.push(`note: ${c.file} took ${secs(c.wallMs)} alone but burned ${c.cpuMs != null ? secs(c.cpuMs) : '?'} of CPU, inside the ${budget}ms budget (load ${load.load1.toFixed(1)} on ${load.cores} cores)`)
83
160
 
84
- const killed = crashedWorkers(p1.out).map((c) => `killed by ${c.signal} while running ${c.file} (out of memory, or killed from outside)`)
161
+ // a ceiling kill makes bun report every worker it held as crashed, and
162
+ // those are the suites that never finished, not an outside kill
163
+ const stalledIn = (name: string, p: Phase | null, files: string[]): string | null =>
164
+ p?.stalled === true
165
+ ? `${name} ran past its ${secs(ceilingMs)} ceiling and was killed${files.length > 0 ? ` with ${files.join(', ')} still running` : ''}: a test worker stopped answering, most likely stuck in a synchronous call, which no per test timeout can interrupt`
166
+ : null
167
+ const crashed = crashedWorkers(p1.out)
168
+ const killed = p1.stalled ? [] : crashed.map((c) => `killed by ${c.signal} while running ${c.file} (out of memory, or killed from outside)`)
169
+ const stalls = [stalledIn('the parallel phase', p1, crashed.map((c) => c.file)), stalledIn('the serial phase', p2, phaseFiles(plan.serial ?? [], rawArgs).filter((a) => p2?.timings[normSuitePath(a)] == null))]
170
+ for (const line of stalls) if (line != null) killed.unshift(line)
85
171
  if (p1.signal != null) killed.push(`killed by ${p1.signal} during the parallel run (out of memory, or killed from outside)`)
86
172
  if (p2?.signal != null) {
87
- const unfinished = (plan.serial ?? []).filter((a) => !a.startsWith('-') && !rawArgs.includes(a) && p2.timings[normSuitePath(a)] == null)
173
+ const unfinished = phaseFiles(plan.serial ?? [], rawArgs).filter((a) => p2.timings[normSuitePath(a)] == null)
88
174
  killed.push(`killed by ${p2.signal} while running ${unfinished.join(' or ')} (out of memory, or killed from outside)`)
89
175
  }
176
+ // the record behind the hub's test files to fix: every suite this run
177
+ // timed, with the alone timing where one was taken, and red when a
178
+ // test in it failed or its worker died
179
+ const red = new Set([...phases.flatMap((p) => failedSuites(p.out)), ...crashedWorkers(p1.out).map((c) => c.file)])
180
+ const at = Date.now()
181
+ const alone = new Map([...over, ...cleared, ...clearedInPhase].map((t) => [t.file, t.ms]))
182
+ const why = failLines(phases.map((p) => p.out).join('\n'))
183
+ for (const c of crashedWorkers(p1.out)) if (!why.has(c.file)) why.set(c.file, `its worker crashed: ${c.signal}`)
184
+ // which phase timed each file, and the CPU spent on it: an alone
185
+ // rerun, the parallel phase's own figure, or a phase that ran that one file
186
+ const phaseOf = (f: string): SuitePhase => (p2 != null && Object.keys(p2.timings).some((k) => normSuitePath(k) === f) ? 'serial' : parallel ? 'parallel' : 'sequential')
187
+ const soloCpu = (p: Phase | null): number | null => (p != null && Object.keys(p.timings).length === 1 ? p.cpuMs : null)
188
+ const cpuOf = (f: string): number | null => aloneRuns[f]?.cpuMs ?? (phaseOf(f) === 'serial' ? soloCpu(p2) : parallelCpu[f]?.cpuMs ?? soloCpu(p1))
189
+ const check = process.env[CHECK_ID_ENV] ?? ''
190
+ const serialFiles = p2 == null ? 0 : Object.keys(p2.timings).length
191
+ const unrecorded = recordSuites(process.cwd(), Object.entries(timings).map(([file, ms]) => {
192
+ const f = normSuitePath(file)
193
+ return {
194
+ at, file: f, ms: alone.get(f) ?? ms, status: red.has(f) ? 'fail' as const : 'pass' as const, tier, budgetMs: budget, check, unit,
195
+ cpuMs: cpuOf(f), phase: phaseOf(f), phaseMs: ms, failLine: red.has(f) ? why.get(f) ?? '' : '',
196
+ }
197
+ }), {
198
+ at, check, unit, tier, wallMs: Date.now() - t0, setupMs, workers: parallel ? workers ?? 0 : 0, files: Object.keys(timings).length,
199
+ parallelMs: p1Ms, parallelCpuMs: p1.cpuMs, serialMs: p2Ms, serialCpuMs: p2?.cpuMs ?? null, serialFiles,
200
+ aloneMs, aloneFiles: rerun.length, load1: load.load1, cores: load.cores,
201
+ })
202
+ if (unrecorded != null) notes.push(unrecorded)
90
203
  // never blocking: a suite that got faster must not stop a release
91
204
  const stale = judged.noLongerSlow.map((t) => `WARN ${declaredIn}.slow declares ${t.file}, which ran in ${secs(t.ms)}, under half the ${budget}ms budget: remove the declaration`)
92
205
 
93
206
  for (const n of notes) console.log(n)
94
207
  for (const s of stale) console.log(s)
208
+ // the root of a repo with units leaves every unit dir to its own check,
209
+ // so finding no file of its own is a finding, not a failure
210
+ if (unit === 'root' && ran === 0 && failedTests === 0 && killed.length === 0 && /\b0 test files matching\b|did not match any test files/.test(p1.out)) {
211
+ for (const n of notes) console.log(n)
212
+ console.log('ok: no test file outside the units: each unit runs its own in test:<unit id>')
213
+ return 0
214
+ }
215
+ // a suite that runs nothing proves nothing, and a green would say it did
216
+ const vacuous = ran === 0 && failedTests === 0 && killed.length === 0
95
217
  const fails: string[] = [
96
218
  ...killed.map((k) => `FAIL: ${k}`),
97
- ...(failedTests > 0 || (phases.some((p) => p.code !== 0 && p.signal == null) && killed.length === 0)
219
+ ...(vacuous ? ['FAIL: ran 0 tests: a suite with no test in it proves nothing, write a real one'] : []),
220
+ ...(!vacuous && (failedTests > 0 || (phases.some((p) => p.code !== 0 && p.signal == null) && killed.length === 0))
98
221
  ? [`FAIL: ${failedTests} test(s) failed, Ran ${ran} tests${slowest}`] : []),
99
222
  ...(over.length > 0
100
- ? [`FAIL: ${over.length} suite(s) over the ${budget}ms budget: ${over.map((t) => `${t.file} ${secs(t.ms)}`).join(', ')} (timed alone). Make each fast, or declare it in ${declaredIn}.slow with what it catches and why it cannot be fast`]
223
+ ? [`FAIL: ${over.length} suite(s) over the ${budget}ms budget: ${over.map(overWords).join(', ')} (run alone, load ${load.load1.toFixed(1)} on ${load.cores} cores). Make each fast, or declare it in ${declaredIn}.slow with what it catches and why it cannot be fast`]
101
224
  : []),
102
225
  ]
103
226
  // the most actionable failure prints LAST, where the gate reads its headline
104
227
  for (const f of fails.slice(1).reverse()) console.log(f)
105
228
  if (fails.length > 0) { console.log(fails[0]); return 1 }
106
- const serialCount = plan.serial != null ? plan.serial.length - rawArgs.length : 0
107
- console.log(`ok: Ran ${ran} tests across ${Object.keys(timings).length || '?'} suites, ${parallel ? 'parallel' : 'one at a time'}${serialCount > 0 ? ` then ${serialCount} serial` : ''}, ${tier} tier${slowest}`)
229
+ const serialCount = plan.serial != null ? phaseFiles(plan.serial, rawArgs).length : 0
230
+ console.log(`ok: Ran ${ran} tests across ${Object.keys(timings).length || '?'} suites, ${parallel ? `parallel on ${workers} workers` : 'one at a time'}${serialCount > 0 ? ` then ${serialCount} serial` : ''}, ${tier} tier${slowest}`)
108
231
  return 0
109
232
  } finally {
110
233
  rmSync(dir, { recursive: true, force: true })
@@ -0,0 +1,92 @@
1
+ // THE CPU EACH SUITE BURNED INSIDE THE PARALLEL PHASE, preloaded into every
2
+ // worker by the suite runner. A suite over the budget in wall time is run again
3
+ // alone to learn its CPU, one after the other, and on a loaded machine nearly
4
+ // every gate heavy suite goes over in wall time: those re-runs were most of the
5
+ // test check's wall time. A suite whose CPU in the parallel phase already
6
+ // bounds its alone run under the budget needs no re-run.
7
+ //
8
+ // One line per suite: `<file>\t<cpu ms>\t<prior ms>`. `cpu` is the worker's own
9
+ // CPU plus that of every child it reaped, from the end of the previous suite in
10
+ // this worker to the end of this one, so the file's import and its hooks count.
11
+ // `prior` is ALL the worker burned before this suite began: its start, and the
12
+ // load, top level and tests of every earlier suite. A lone run can only need
13
+ // what this worker computed by the end of the suite (a module an earlier suite
14
+ // loaded, a memo an earlier test filled), so cpu plus prior bounds it from
15
+ // above, and sharing the machine only raises CPU.
16
+ //
17
+ // A child still alive, or dead and not yet reaped, when the suite ends has CPU
18
+ // nobody counted yet, so that suite's line says `-` and it runs alone if it is
19
+ // a suspect. The lines also say which suites ran at all.
20
+ //
21
+ // It needs `--parallel`: without it bun runs a preload's hooks once for the
22
+ // whole run, not once per suite. Children come from getrusage(RUSAGE_CHILDREN)
23
+ // through bun:ffi: Bun's own resourceUsage() is the process alone, and a gate
24
+ // heavy suite burns most of its CPU in git. A runtime where either call cannot
25
+ // be made records nothing, and every suspect then runs alone as before.
26
+ import { appendFileSync, readdirSync, readFileSync } from 'fs'
27
+ import { afterAll } from 'bun:test'
28
+ import { SUITE_CPU_ENV } from './judge-suites.ts'
29
+
30
+ const RUSAGE_SELF = 0
31
+ const RUSAGE_CHILDREN = -1
32
+
33
+ type Probes = { cpu: () => number; liveChildren: () => number }
34
+
35
+ /** user plus system ms of this process and of its reaped children, and how
36
+ * many children it has that are alive or not yet reaped. null when the
37
+ * platform does not answer both */
38
+ function probes(): Probes | null {
39
+ try {
40
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
41
+ const { dlopen, FFIType, ptr } = require('bun:ffi') as typeof import('bun:ffi')
42
+ const darwin = process.platform === 'darwin'
43
+ const libc = darwin ? '/usr/lib/libSystem.B.dylib' : process.platform === 'linux' ? 'libc.so.6' : null
44
+ if (libc == null) return null
45
+ const lib = dlopen(libc, {
46
+ getrusage: { args: [FFIType.i32, FFIType.ptr], returns: FFIType.i32 },
47
+ ...(darwin ? { proc_listchildpids: { args: [FFIType.i32, FFIType.ptr, FFIType.i32], returns: FFIType.i32 } } : {}),
48
+ })
49
+ // struct rusage opens with two struct timeval (64 bit seconds, then
50
+ // microseconds), user then system, on both platforms
51
+ const buf = new BigInt64Array(18)
52
+ const view = new DataView(buf.buffer)
53
+ const tv = (o: number): number => Number(view.getBigInt64(o, true)) * 1000 + view.getInt32(o + 8, true) / 1000
54
+ const one = (who: number): number => {
55
+ if (lib.symbols.getrusage(who, ptr(buf)) !== 0) throw new Error('getrusage failed')
56
+ return tv(0) + tv(16)
57
+ }
58
+ const pids = new Int32Array(1024)
59
+ const listChildren = (lib.symbols as Record<string, (...a: unknown[]) => unknown>)['proc_listchildpids']
60
+ const liveChildren = darwin
61
+ ? (): number => Number(listChildren!(process.pid, ptr(pids), pids.byteLength))
62
+ // a zombie is still listed with this pid as its parent
63
+ : (): number => {
64
+ let n = 0
65
+ for (const d of readdirSync('/proc')) {
66
+ if (!/^\d+$/.test(d)) continue
67
+ try { if (readFileSync(`/proc/${d}/stat`, 'utf8').replace(/^.*\) /s, '').split(' ')[1] === String(process.pid)) n++ } catch { /* gone */ }
68
+ }
69
+ return n
70
+ }
71
+ liveChildren()
72
+ return { cpu: () => one(RUSAGE_SELF) + one(RUSAGE_CHILDREN), liveChildren }
73
+ } catch {
74
+ return null
75
+ }
76
+ }
77
+
78
+ const out = process.env[SUITE_CPU_ENV]
79
+ const p = out != null && out !== '' ? probes() : null
80
+ if (out != null && p != null) {
81
+ let ended = p.cpu()
82
+ afterAll(() => {
83
+ const now = p.cpu()
84
+ // every suite gets a line, since the line is also how the runner knows
85
+ // it ran; a suite whose figure cannot be trusted says `-`
86
+ try {
87
+ const figure = p.liveChildren() === 0 ? `${Math.round(now - ended)}\t${Math.round(ended)}` : '-\t-'
88
+ appendFileSync(out, `${Bun.main}\t${figure}\n`)
89
+ } catch { /* no line: the suite runs alone if it is a suspect */ }
90
+ ended = now
91
+ })
92
+ }
@@ -0,0 +1,243 @@
1
+ // WHICH TEST CHECK RUNS WHICH TEST FILE. Every `*.test.ts` in the repo is
2
+ // discovered, each one belongs to the unit whose directory holds it
3
+ // (the root takes the rest), and each unit's check is `test` for the root and
4
+ // `test:<unit id>` for a sub-unit. A file that no check runs is an ORPHAN.
5
+ //
6
+ // Pure: every read goes through the reader it is handed, so the gate hands it
7
+ // the disk and a check hands it its context. The constraint a reader breaks
8
+ // first: bun REPLACES the bunfig ignores with the command line ones, and the
9
+ // suite runner puts them back (judge-suites.ts planPhases), so what runs is
10
+ // always the UNION modelled here.
11
+ import { basename, posix } from 'path'
12
+ import { rawName, routesToShipkit, slotBody } from '../core/canonical-scripts.ts'
13
+ import { parseJsonc } from '../ts/jsonc.ts'
14
+ import { bunfigTest, normSuitePath, parseBunTestScript, type BunfigTest, type TestsConfig } from './judge-suites.ts'
15
+
16
+ export { bunfigTest, type BunfigTest }
17
+
18
+ /** bun's own discovery pattern, as a glob a listing takes */
19
+ export const TEST_GLOB = '**/*{.test,.spec,_test_,_spec_}.{js,ts,jsx,tsx}'
20
+
21
+ /** bun's own discovery pattern: `**{.test,.spec,_test_,_spec_}.{js,ts,jsx,tsx}` */
22
+ export const isTestFile = (rel: string): boolean => /(\.test|\.spec|_test_|_spec_)\.(js|ts|jsx|tsx)$/.test(rel)
23
+
24
+ /** the check id a unit's tests run under */
25
+ export const testCheckId = (unitId: string): string => (unitId === 'root' ? 'test' : `test:${unitId}`)
26
+
27
+ /** `bun test` flags that take the NEXT token as their value */
28
+ const VALUE_FLAGS = new Set(['--timeout', '--rerun-each', '-t', '--test-name-pattern', '--preload', '--coverage-reporter', '--coverage-dir', '--reporter', '--reporter-outfile', '--max-concurrency', '--shard', '--timings', '--path-ignore-patterns', '--seed', '--retry', '--cwd', '--config', '-c', '--env-file', '--tsconfig-override'])
29
+
30
+ /** what one test check hands `bun test`, as far as discovery is concerned */
31
+ export type TestRun = {
32
+ /** the `bun test` arguments, or null when the body is not a plain `bun test`
33
+ * and nothing can say which files it runs */
34
+ args: string[] | null
35
+ bunfig: BunfigTest
36
+ /** ignores the gate adds (the sub-unit dirs, for the root), relative to the run's cwd */
37
+ extraIgnores: string[]
38
+ }
39
+
40
+ /** does this run reach this file (relative to the run's cwd) ? */
41
+ export function runReaches(file: string, run: TestRun): { runs: true } | { runs: false; ignoredBy: string } | { runs: false; outside: string } | null {
42
+ if (run.args == null) return null
43
+ const f = normSuitePath(file)
44
+ const cli: string[] = []
45
+ const filters: string[] = []
46
+ for (let i = 0; i < run.args.length; i++) {
47
+ const a = run.args[i]!
48
+ if (a.startsWith('--path-ignore-patterns=')) { cli.push(a.slice('--path-ignore-patterns='.length)); continue }
49
+ if (a === '--path-ignore-patterns') { cli.push(run.args[++i] ?? ''); continue }
50
+ if (VALUE_FLAGS.has(a)) { i++; continue }
51
+ if (!a.startsWith('-')) filters.push(a)
52
+ }
53
+ for (const g of [...run.bunfig.ignores, ...cli, ...run.extraIgnores]) if (g !== '' && new Bun.Glob(g).match(f)) return { runs: false, ignoredBy: g }
54
+ if (run.bunfig.root != null && run.bunfig.root !== '.' && !f.startsWith(`${run.bunfig.root}/`))
55
+ return { runs: false, outside: `bunfig.toml [test] root = "${run.bunfig.root}"` }
56
+ if (filters.length > 0 && !filters.some((p) => (p.startsWith('./') || p.startsWith('/') ? f.startsWith(normSuitePath(p)) : f.includes(p))))
57
+ return { runs: false, outside: `the \`bun test\` arguments name only ${filters.join(', ')}` }
58
+ return { runs: true }
59
+ }
60
+
61
+ /** a sub-unit as its generated checks see it: declared, never scanned.
62
+ * `releases` false = a `release: false` unit, which only scopes checks */
63
+ export type TestUnitDecl = { id: string; rel: string; releases: boolean; tests?: TestsConfig; tsconfig?: string; deps?: string[] }
64
+
65
+ /** the sub-units a repo declares, read straight off `units[]`. A unit whose
66
+ * RELEASE resolution fails (a missing npm flag, a bad tag) still gets its
67
+ * tests run: a packaging defect is rule SK010's to report, not a reason to
68
+ * stop testing the code */
69
+ export function declaredTestUnits(units: { dir?: unknown; id?: string; tests?: TestsConfig; tsconfig?: string; release?: boolean; deps?: string[] }[] | undefined): TestUnitDecl[] {
70
+ const out: TestUnitDecl[] = []
71
+ const seen = new Set<string>(['root'])
72
+ for (const d of units ?? []) {
73
+ if (typeof d.dir !== 'string' || d.dir === '') continue
74
+ const rel = normSuitePath(d.dir).replace(/\/+$/, '')
75
+ const id = d.id ?? basename(rel)
76
+ if (seen.has(id)) continue
77
+ seen.add(id)
78
+ out.push({ id, rel, releases: d.release !== false, ...(d.tests == null ? {} : { tests: d.tests }), ...(d.tsconfig == null ? {} : { tsconfig: d.tsconfig }), ...(d.deps == null ? {} : { deps: d.deps }) })
79
+ }
80
+ return out
81
+ }
82
+
83
+ type Pkg = { name?: string; scripts?: Record<string, string>; dependencies?: Record<string, string>; devDependencies?: Record<string, string>; peerDependencies?: Record<string, string>; optionalDependencies?: Record<string, string> }
84
+ const parsePkg = (text: string | null): Pkg | null => {
85
+ if (text == null) return null
86
+ try { return JSON.parse(text) as Pkg } catch { return null }
87
+ }
88
+
89
+ /** the script body a sub-unit's check runs: its own `test:raw`, else a `test`
90
+ * that does not route to shipkit, else a plain `bun test`. A unit's own
91
+ * preload and bunfig apply because the check runs in the unit's dir */
92
+ export function unitTestBody(pkgText: string | null): { script: string | null; body: string } {
93
+ const scripts = parsePkg(pkgText)?.scripts ?? {}
94
+ const raw = scripts[rawName('test')]
95
+ if (raw != null) return { script: rawName('test'), body: raw }
96
+ const t = scripts['test']
97
+ if (t != null && !routesToShipkit(t)) return { script: 'test', body: t }
98
+ return { script: null, body: 'bun test' }
99
+ }
100
+
101
+ /** the sibling units a unit's tests can read: the ones its package.json names
102
+ * in any dependency block, and the ones its tsconfig `paths` point into (a
103
+ * bench resolving the libs at their source folders names them nowhere else).
104
+ * Their dirs belong to its inputs. Derived, never hand kept, and over
105
+ * declaring is safe where under declaring is a stale green */
106
+ export function siblingUnits(units: TestUnitDecl[], read: (rel: string) => string | null): Map<string, string[]> {
107
+ const pkgs = new Map(units.map((u) => [u.id, parsePkg(read(posix.join(u.rel, 'package.json')))]))
108
+ const byName = new Map<string, TestUnitDecl>()
109
+ for (const u of units) { const n = pkgs.get(u.id)?.name; if (n != null) byName.set(n, u) }
110
+ const out = new Map<string, string[]>()
111
+ for (const u of units) {
112
+ const p = pkgs.get(u.id)
113
+ const names = new Set([p?.dependencies, p?.devDependencies, p?.peerDependencies, p?.optionalDependencies].flatMap((b) => Object.keys(b ?? {})))
114
+ const tsconfig = unitTsconfig(u)
115
+ const targets = pathTargets(read(tsconfig)).map((t) => posix.normalize(posix.join(posix.dirname(tsconfig), t)))
116
+ out.set(u.id, units.filter((s) => s.id !== u.id && ([...names].some((n) => byName.get(n) === s) || targets.some((t) => under(t, s.rel)))).map((s) => s.rel))
117
+ }
118
+ return out
119
+ }
120
+
121
+ /** the unit's own tsconfig, repo-relative: the declared one, else `<dir>/tsconfig.json` */
122
+ export const unitTsconfig = (u: TestUnitDecl): string => (u.tsconfig != null ? normSuitePath(u.tsconfig) : posix.join(u.rel, 'tsconfig.json'))
123
+
124
+ /** every target of a tsconfig `compilerOptions.paths`, as written */
125
+ function pathTargets(text: string | null): string[] {
126
+ if (text == null) return []
127
+ try {
128
+ const paths = (parseJsonc(text) as { compilerOptions?: { paths?: Record<string, unknown> } } | null)?.compilerOptions?.paths ?? {}
129
+ return Object.values(paths).flatMap((v) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []))
130
+ } catch { return [] }
131
+ }
132
+
133
+ /** the root run's ignores for the sub-unit dirs: each has a check of its own,
134
+ * run in its own dir with its own preload */
135
+ export const unitIgnores = (units: TestUnitDecl[]): string[] => units.map((u) => `${u.rel}/**`)
136
+
137
+ const under = (file: string, rel: string): boolean => rel === '' || file === rel || file.startsWith(`${rel}/`)
138
+
139
+ export type Orphan = { file: string; why: string }
140
+ export type TestPlan = {
141
+ /** check id → the repo-relative files it runs */
142
+ runs: Map<string, string[]>
143
+ /** a file a bunfig ignore keeps out, on purpose */
144
+ ignored: { file: string; pattern: string; checkId: string }[]
145
+ /** a file no check runs */
146
+ orphans: Orphan[]
147
+ /** checks whose body is not a plain `bun test`, so what they run is unknowable */
148
+ opaque: string[]
149
+ /** the files of those checks, each with the check it belongs to */
150
+ unread: { file: string; checkId: string }[]
151
+ }
152
+
153
+ /** which check runs each discovered test file. `rootRun` null = the repo has
154
+ * no test slot at all (unfilled, or turned off), so its files run nowhere */
155
+ export function planTests(o: {
156
+ files: string[]
157
+ units: TestUnitDecl[]
158
+ rootRun: TestRun | null
159
+ rootWhy?: string
160
+ unitRun: (u: TestUnitDecl) => TestRun
161
+ }): TestPlan {
162
+ const runs = new Map<string, string[]>([['test', []], ...o.units.map((u) => [testCheckId(u.id), []] as [string, string[]])])
163
+ const ignored: TestPlan['ignored'] = []
164
+ const orphans: Orphan[] = []
165
+ const opaque = new Set<string>()
166
+ const unread: TestPlan['unread'] = []
167
+ for (const file of o.files.filter(isTestFile).map(normSuitePath).sort()) {
168
+ // the deepest unit wins, so a unit nested in another still owns its own
169
+ const unit = o.units.filter((u) => under(file, u.rel)).sort((a, b) => b.rel.length - a.rel.length)[0]
170
+ const checkId = unit == null ? 'test' : testCheckId(unit.id)
171
+ const run = unit == null ? o.rootRun : o.unitRun(unit)
172
+ if (run == null) { orphans.push({ file, why: o.rootWhy ?? 'the repo has no test check' }); continue }
173
+ const rel = unit == null ? file : file.slice(unit.rel.length + 1)
174
+ const r = runReaches(rel, run)
175
+ if (r == null) { opaque.add(checkId); unread.push({ file, checkId }); continue }
176
+ if (r.runs) runs.get(checkId)!.push(file)
177
+ else if ('ignoredBy' in r) ignored.push({ file, pattern: r.ignoredBy, checkId })
178
+ else orphans.push({ file, why: `${checkId} never reaches it: ${r.outside}` })
179
+ }
180
+ return { runs, ignored, orphans, opaque: [...opaque], unread }
181
+ }
182
+
183
+ /** the whole plan for a repo, from what its config and files say. `read`
184
+ * answers a repo-relative path, null when absent */
185
+ export function testPlanOf(o: {
186
+ config: { defaults?: Record<string, unknown>; units?: { dir?: unknown; id?: string; tests?: TestsConfig; tsconfig?: string; release?: boolean; deps?: string[] }[] }
187
+ files: string[]
188
+ read: (rel: string) => string | null
189
+ }): TestPlan & { units: TestUnitDecl[] } {
190
+ const units = declaredTestUnits(o.config.units)
191
+ let scripts: Record<string, string> = {}
192
+ try { scripts = (JSON.parse(o.read('package.json') ?? '{}') as { scripts?: Record<string, string> }).scripts ?? {} } catch { scripts = {} }
193
+ const slot = slotBody('test', o.config.defaults?.['test'] as { cmd?: unknown; logic?: unknown } | false | undefined, scripts)
194
+ const bunfig = (dir: string): BunfigTest => bunfigTest(o.read(dir === '' ? 'bunfig.toml' : posix.join(dir, 'bunfig.toml')))
195
+ const rootRun: TestRun | null = slot.kind === 'override' ? { args: null, bunfig: bunfig(''), extraIgnores: [] }
196
+ : slot.kind === 'script' ? { args: parseBunTestScript(slot.body), bunfig: bunfig(''), extraIgnores: unitIgnores(units) }
197
+ : null
198
+ const rootWhy = slot.kind === 'off' ? '`defaults.test: false` turns the root test check off, so nothing runs it'
199
+ : slot.kind === 'routed-empty' ? "the `test` script routes to shipkit and there is no 'test:raw' body, so the root test check runs nothing"
200
+ : 'no `test` or `test:raw` script, so the root has no test check'
201
+ const plan = planTests({
202
+ files: o.files,
203
+ units,
204
+ rootRun,
205
+ rootWhy,
206
+ unitRun: (u) => ({ args: parseBunTestScript(unitTestBody(o.read(posix.join(u.rel, 'package.json'))).body), bunfig: bunfig(u.rel), extraIgnores: [] }),
207
+ })
208
+ return { ...plan, units }
209
+ }
210
+
211
+ /** every suite declared slow, repo relative: the root's own, then each
212
+ * unit's under its dir. The runs page and the tests collection read the
213
+ * same list, so a file is slow on both or on neither */
214
+ export function declaredSlowFiles(c: { tests?: TestsConfig; units?: { dir?: unknown; tests?: TestsConfig }[] }): string[] {
215
+ const own = (c.tests?.slow ?? []).map((s) => normSuitePath(s.file))
216
+ const units = (c.units ?? []).flatMap((u) => (typeof u.dir === 'string' && u.dir !== '' ? (u.tests?.slow ?? []).map((s) => posix.join(normSuitePath(u.dir as string), normSuitePath(s.file))) : []))
217
+ return [...own, ...units]
218
+ }
219
+
220
+ /** the root's declared inputs with every sub-unit dir cut out: a path that
221
+ * holds a unit becomes the entries beside it, derived from the listing. The
222
+ * root check never runs a unit's tests, and what it imports from one comes
223
+ * back through its code closure */
224
+ export function inputsOutsideUnits(inputs: string[], unitRels: string[], files: string[]): string[] {
225
+ const out = new Set<string>()
226
+ for (const raw of inputs) {
227
+ const input = raw === '.' ? '' : normSuitePath(raw).replace(/\/+$/, '')
228
+ const inside = unitRels.filter((u) => under(u, input) && u !== input)
229
+ if (unitRels.includes(input)) continue
230
+ if (inside.length === 0) { out.add(raw); continue }
231
+ for (const f of files) {
232
+ if (!under(f, input) || inside.some((u) => under(f, u))) continue
233
+ // the shallowest prefix of f that holds no unit
234
+ const parts = f.split('/')
235
+ const skip = input === '' ? 0 : input.split('/').length
236
+ for (let i = skip + 1; i <= parts.length; i++) {
237
+ const p = parts.slice(0, i).join('/')
238
+ if (!inside.some((u) => under(u, p))) { out.add(p); break }
239
+ }
240
+ }
241
+ }
242
+ return [...out].sort()
243
+ }