rvlib-shipkit 0.38.0 → 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 +371 -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 +216 -18
  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
@@ -4,6 +4,7 @@
4
4
  import { isAbsolute, posix } from 'path'
5
5
 
6
6
  export type SlowSuite = {
7
+ /** the suite path, relative to the repo root (or to the unit dir inside `units[]`) */
7
8
  file: string
8
9
  /** what this suite catches that no fast test can */
9
10
  catches: string
@@ -11,12 +12,13 @@ export type SlowSuite = {
11
12
  whyNotFast: string
12
13
  }
13
14
  export type SerialSuite = {
15
+ /** the suite path, relative to the repo root (or to the unit dir inside `units[]`) */
14
16
  file: string
15
17
  /** what breaks when it shares a run with other suites */
16
18
  why: string
17
19
  }
18
20
  export type TestsConfig = {
19
- /** per suite wall time in the quick tier (default 2000). A suite over it under the parallel run is timed again alone, and only that timing fails it. The full tier fails nothing on time */
21
+ /** per suite time in the quick tier (default 2000). A suite whose wall time goes over it under the parallel run is run again alone, and fails when that run burns more CPU than this, or waits more than twice this while the machine is idle. A suite whose CPU inside the parallel run, children included, plus all its worker burned before it stays under it skips that run, unless it may wait on an idle machine or ended with a child not yet reaped. The full tier fails nothing on time */
20
22
  budgetMs?: number
21
23
  /** suites that break the budget on purpose: the full tier runs them, the quick tier does not */
22
24
  slow?: SlowSuite[]
@@ -25,14 +27,47 @@ export type TestsConfig = {
25
27
  }
26
28
 
27
29
  export const DEFAULT_BUDGET_MS = 2000
30
+
31
+ /** what a FULL tier run gives each test. Its suites build real repositories
32
+ * and spawn whole gates, and the same file has finished in 6.8s and in 21.3s
33
+ * on identical code depending on what else was running, so bun's 5s default
34
+ * turns machine load into a red release gate.
35
+ *
36
+ * It rides on the COMMAND LINE because nothing else can carry it: a preload
37
+ * calling setDefaultTimeout raises the ceiling for the FIRST file of a run
38
+ * and for nothing after it, which left 106 of this repo's 107 suites at the
39
+ * 5s default while the preload said otherwise. */
40
+ export const FULL_TIMEOUT_MS = 120_000
41
+ /** the wall ceiling of one `bun test` phase. bun's per test timeout never
42
+ * fires on a worker stuck in a SYNCHRONOUS call, and bun's spawnSync has been
43
+ * seen spinning on a child that already exited (planPhases avoids the known
44
+ * trigger), so without this a hang lasts until something outside kills it. The quick
45
+ * tier answers in seconds, so two minutes is far from any real run. */
46
+ export const QUICK_CEILING_MS = 120_000
47
+ /** the full tier runs every suite one after the other, about three minutes */
48
+ export const FULL_CEILING_MS = 1_800_000
28
49
  const TESTS_KEYS = ['budgetMs', 'slow', 'serial']
29
50
  const SLOW_KEYS = ['file', 'catches', 'whyNotFast']
30
51
  const SERIAL_KEYS = ['file', 'why']
52
+ const OWN_FLAGS = ['--budget-ms', '--slow', '--serial', '--declared-in', '--ceiling-ms', '--unit']
31
53
 
32
54
  export const normSuitePath = (file: string): string => posix.normalize(file.replace(/\\/g, '/')).replace(/^\.\//, '')
33
55
 
34
56
  /** the arguments after `bun test`, or null when a shell would interpret the script
35
57
  * (cd, env prefix, pipes, quotes, substitution): that body stays the repo's own */
58
+ export type BunfigTest = { root: string | null; ignores: string[]; error: string | null }
59
+
60
+ /** the `[test]` table of a bunfig.toml: where discovery starts, and what it skips */
61
+ export function bunfigTest(text: string | null): BunfigTest {
62
+ if (text == null) return { root: null, ignores: [], error: null }
63
+ let parsed: { test?: { root?: unknown; pathIgnorePatterns?: unknown } }
64
+ try { parsed = Bun.TOML.parse(text) as typeof parsed } catch (e) { return { root: null, ignores: [], error: `bunfig.toml is unparseable (${String(e).slice(0, 120)})` } }
65
+ const t = parsed.test ?? {}
66
+ const raw = t.pathIgnorePatterns
67
+ const ignores = typeof raw === 'string' ? [raw] : Array.isArray(raw) ? raw.filter((x): x is string => typeof x === 'string') : []
68
+ return { root: typeof t.root === 'string' ? normSuitePath(t.root) : null, ignores, error: null }
69
+ }
70
+
36
71
  export function parseBunTestScript(script: string): string[] | null {
37
72
  const s = script.trim()
38
73
  if (!/^bun test(\s|$)/.test(s)) return null
@@ -54,38 +89,78 @@ export function suiteFlags(t: TestsConfig | undefined): string[] {
54
89
 
55
90
  /** the inverse of suiteFlags, plus where the declarations live (printed in
56
91
  * every fix line) and the `bun test` arguments after `--` */
57
- export function parseSuiteFlags(argv: string[]): { tests: TestsConfig; rawArgs: string[]; declaredIn: string } | string {
92
+ export function parseSuiteFlags(argv: string[]): { tests: TestsConfig; rawArgs: string[]; declaredIn: string; ceilingMs: number | null; unit: string } | string {
58
93
  const tests: Required<TestsConfig> = { budgetMs: DEFAULT_BUDGET_MS, slow: [], serial: [] }
59
94
  let declaredIn = 'repo.config.ts tests'
95
+ let ceilingMs: number | null = null
96
+ let unit = ''
97
+ // the runner's own flags end at `--` OR at the first token that is not one
98
+ // of them: bun swallows a LEADING `--`, so a repo with no declaration hands
99
+ // over the `bun test` arguments with no separator at all
60
100
  let i = 0
61
- for (; i < argv.length && argv[i] !== '--'; i++) {
101
+ for (; i < argv.length; i += 2) {
62
102
  const flag = argv[i]!
103
+ if (flag === '--') { i++; break }
104
+ if (!OWN_FLAGS.includes(flag)) break
63
105
  const value = argv[i + 1]
64
106
  if (value == null || value === '--') return `${flag} needs a value`
65
- i++
66
107
  if (flag === '--budget-ms') tests.budgetMs = Number(value)
67
108
  else if (flag === '--slow') tests.slow.push({ file: value, catches: '(declared)', whyNotFast: '(declared)' })
68
109
  else if (flag === '--serial') tests.serial.push({ file: value, why: '(declared)' })
69
- else if (flag === '--declared-in') declaredIn = value
70
- else return `unknown flag ${flag}`
110
+ else if (flag === '--ceiling-ms') ceilingMs = Number(value)
111
+ else if (flag === '--unit') unit = value
112
+ else declaredIn = value
71
113
  }
72
114
  if (!(tests.budgetMs > 0)) return '--budget-ms must be a positive number'
73
- return { tests, rawArgs: argv.slice(i + 1), declaredIn }
115
+ if (ceilingMs != null && !(ceilingMs > 0)) return '--ceiling-ms must be a positive number'
116
+ return { tests, rawArgs: argv.slice(i), declaredIn, ceilingMs, unit }
74
117
  }
75
118
 
76
119
  /** what `bun test` receives in each phase. The serial phase names its files
77
- * explicitly, because bun reports no tests when a path is both named and ignored */
78
- export function planPhases(p: { tests: TestsConfig; tier: 'quick' | 'full'; rawArgs: string[]; parallel: boolean }): { parallel: string[]; serial: string[] | null } {
120
+ * explicitly, because bun reports no tests when a path is both named and ignored.
121
+ *
122
+ * `noIsolate` (the bun in use knows `--no-isolate`): the parallel phase keeps
123
+ * one global per worker, as a plain `bun test` and the serial phase always
124
+ * did. `--parallel` implies per file isolation, and under it a worker's
125
+ * spawnSync can spin forever on a child that already exited (bun issue
126
+ * 34069), which hung one full run in eight. A repo that passes `--isolate`
127
+ * itself keeps it. */
128
+ export function planPhases(p: { tests: TestsConfig; tier: 'quick' | 'full'; rawArgs: string[]; parallel: boolean; noIsolate?: boolean; configIgnores?: string[]; workers?: number }): { parallel: string[]; serial: string[] | null } {
79
129
  const slow = (p.tests.slow ?? []).map((s) => normSuitePath(s.file))
80
130
  const serial = (p.tests.serial ?? []).map((s) => normSuitePath(s.file))
81
- const ignored = [...new Set([...(p.tier === 'quick' ? slow : []), ...serial])]
131
+ const own = [...new Set([...(p.tier === 'quick' ? slow : []), ...serial])]
132
+ // bun REPLACES the bunfig pathIgnorePatterns with the command line ones, so
133
+ // the repo's own come along whenever this or the repo's arguments name any
134
+ const cliIgnores = p.rawArgs.some((a) => a.startsWith('--path-ignore-patterns'))
135
+ const ignored = own.length > 0 || cliIgnores ? [...own, ...(p.configIgnores ?? []).filter((g) => !own.includes(g))] : []
82
136
  const serialRun = serial.filter((f) => p.tier === 'full' || !slow.includes(f))
137
+ // the full tier ceiling, on every file. The quick tier keeps bun's
138
+ // default, where a suite is under a second and a hang must fail fast
139
+ const named = p.rawArgs.some((a) => a === '--timeout' || a.startsWith('--timeout='))
140
+ const args = p.tier === 'full' && !named ? [`--timeout=${FULL_TIMEOUT_MS}`, ...p.rawArgs] : p.rawArgs
141
+ const isolated = p.rawArgs.some((a) => a === '--isolate' || a === '--no-isolate')
83
142
  return {
84
- parallel: [...p.rawArgs, ...(p.parallel ? ['--parallel'] : []), ...ignored.map((f) => `--path-ignore-patterns=${f}`)],
85
- serial: serialRun.length > 0 ? [...p.rawArgs, ...serialRun] : null,
143
+ parallel: [...args, ...(p.parallel ? [p.workers != null ? `--parallel=${p.workers}` : '--parallel'] : []), ...(p.parallel && p.noIsolate === true && !isolated ? ['--no-isolate'] : []), ...ignored.map((f) => `--path-ignore-patterns=${f}`)],
144
+ serial: serialRun.length > 0 ? [...args, ...serialRun] : null,
86
145
  }
87
146
  }
88
147
 
148
+ /** how many `--parallel` workers one suite run takes. Every test check runs
149
+ * on one slot of the machine pool (core/slots.ts), so a run sized to every
150
+ * core while four other checks do the same puts five times the cores to
151
+ * work. A run takes its own slot's share of the cores plus the share of
152
+ * every slot free when it starts, never more than the cores, and never
153
+ * fewer than half of them: the count is fixed for the whole run, and a gate
154
+ * starts it while its first burst of short checks holds the pool, so the
155
+ * share alone left the longest suite on two workers long after that burst
156
+ * ended. Measured on 8 cores: two workers 12.2s, four 7.6s, eight 7.0s */
157
+ export function poolWorkers(p: { cores: number; size: number; held: number }): number {
158
+ const size = Math.max(1, p.size)
159
+ const mine = Math.max(1, size - Math.max(1, p.held) + 1)
160
+ const floor = Math.max(2, Math.floor(p.cores / 2))
161
+ return Math.max(floor, Math.min(p.cores, Math.round((p.cores * mine) / size)))
162
+ }
163
+
89
164
  type Timed = { file: string; ms: number }
90
165
 
91
166
  export function judgeSuites(p: { timings: Record<string, number>; tests: TestsConfig; tier: 'quick' | 'full' }): { over: Timed[]; noLongerSlow: Timed[]; slowest: Timed[] } {
@@ -102,14 +177,137 @@ export function judgeSuites(p: { timings: Record<string, number>; tests: TestsCo
102
177
  }
103
178
  }
104
179
 
105
- /** what stays over budget once each suspect ran alone: a parallel timing
106
- * carries the load of every other suite and every other gate on the machine,
107
- * so only the alone timing can fail a suite */
108
- export function confirmOver(over: Timed[], alone: Record<string, number>, budgetMs: number): Timed[] {
109
- const byFile = new Map(Object.entries(alone).map(([f, ms]) => [normSuitePath(f), ms]))
110
- return over.map((t) => ({ file: t.file, ms: byFile.get(t.file) ?? t.ms })).filter((t) => t.ms > budgetMs)
180
+ /** one suspect's run alone, in its own `bun test` process. cpuMs is user plus
181
+ * system time of that process and every child it reaped, null when the
182
+ * runtime reports none */
183
+ export type AloneRun = { wallMs: number; cpuMs: number | null }
184
+ /** the 1 minute load average against the core count, read before a suspect runs */
185
+ export type MachineLoad = { load1: number; cores: number }
186
+ /** how far past the budget a suite must WAIT on an idle machine before its
187
+ * wall time fails it: a suite that sleeps or polls costs its owner the same
188
+ * as one that computes, and the factor keeps scheduler noise out */
189
+ export const WAIT_FACTOR = 2
190
+ /** how much more CPU the same suite burns on a saturated machine. CPU time is
191
+ * not load free: measured on 8 cores, one suite went from 1.0s of CPU idle to
192
+ * 1.35 to 1.5s with the cores saturated (load 7 to 19), and did not grow past
193
+ * that. So a loaded machine fails a suite past the budget times this */
194
+ export const LOADED_CPU_FACTOR = 1.5
195
+ /** `ms` is the time the verdict used: cpu, or wall for a wait or when no cpu figure exists */
196
+ export type Confirmed = { file: string; ms: number; wallMs: number; cpuMs: number | null; over: 'cpu' | 'wait' | 'wall' | null }
197
+
198
+ /** the verdict on each suspect once it ran alone. A wall time carries the load
199
+ * of every other process on the machine, and another gate can triple it, so
200
+ * the budget judges CPU: what the suite itself burned, which load moves by
201
+ * a bounded factor only (LOADED_CPU_FACTOR). Wall time still fails a suite
202
+ * far over the budget while the machine was idle, because only then is the
203
+ * wait the suite's own */
204
+ export function confirmOver(suspects: Timed[], alone: Record<string, AloneRun>, budgetMs: number, load: MachineLoad): { over: Confirmed[]; cleared: Confirmed[] } {
205
+ const byFile = new Map(Object.entries(alone).map(([f, run]) => [normSuitePath(f), run]))
206
+ const idle = load.load1 < load.cores
207
+ const judged = suspects.map((t): Confirmed => {
208
+ const run = byFile.get(t.file)
209
+ const wallMs = run?.wallMs ?? t.ms
210
+ const cpuMs = run?.cpuMs ?? null
211
+ if (cpuMs == null) return { file: t.file, ms: wallMs, wallMs, cpuMs, over: wallMs > budgetMs ? 'wall' : null }
212
+ if (cpuMs > budgetMs * (idle ? 1 : LOADED_CPU_FACTOR)) return { file: t.file, ms: cpuMs, wallMs, cpuMs, over: 'cpu' }
213
+ if (idle && wallMs > budgetMs * WAIT_FACTOR) return { file: t.file, ms: wallMs, wallMs, cpuMs, over: 'wait' }
214
+ return { file: t.file, ms: cpuMs, wallMs, cpuMs, over: null }
215
+ })
216
+ return { over: judged.filter((c) => c.over != null), cleared: judged.filter((c) => c.over == null) }
217
+ }
218
+
219
+ /** how the parallel phase's workers hand the runner the CPU of each suite
220
+ * (suite-cpu-preload.ts writes it, readPhaseCpu reads it) */
221
+ export const SUITE_CPU_ENV = 'SHIPKIT_SUITE_CPU'
222
+
223
+ /** one suite's figures from the parallel phase: its own CPU, children
224
+ * included, and all its worker burned before it began (suite-cpu-preload.ts
225
+ * says why the sum bounds a lone run) */
226
+ export type PhaseCpu = { cpuMs: number; priorMs: number }
227
+
228
+ /** what the workers wrote, keyed like the timings: relative to the run's cwd.
229
+ * `ran` is every suite that finished in the phase, `cpu` the ones whose
230
+ * figure can be trusted. A file bun ran twice keeps its larger bound, and
231
+ * none at all once one of its runs could not be trusted */
232
+ export function readPhaseCpu(text: string, cwd: string): { ran: string[]; cpu: Record<string, PhaseCpu> } {
233
+ const ran = new Set<string>()
234
+ const unsure = new Set<string>()
235
+ const cpu: Record<string, PhaseCpu> = {}
236
+ const base = cwd.replace(/\\/g, '/')
237
+ for (const line of text.split('\n')) {
238
+ const [file, ms, prior] = line.split('\t')
239
+ if (file == null || file === '' || ms == null || prior == null) continue
240
+ const rel = normSuitePath(posix.relative(base, file.replace(/\\/g, '/')))
241
+ if (ms === '-' && prior === '-') { ran.add(rel); unsure.add(rel); continue }
242
+ if (!/^\d+$/.test(ms) || !/^\d+$/.test(prior)) continue
243
+ ran.add(rel)
244
+ const next = { cpuMs: Number(ms), priorMs: Number(prior) }
245
+ const had = cpu[rel]
246
+ if (had == null || next.cpuMs + next.priorMs > had.cpuMs + had.priorMs) cpu[rel] = next
247
+ }
248
+ for (const f of unsure) delete cpu[f]
249
+ return { ran: [...ran].sort(), cpu }
250
+ }
251
+
252
+ /** which suspects still need their alone run. A lone run can only need
253
+ * what the worker computed by the suite's end, its whole CPU before the
254
+ * suite (priorMs) plus what the suite burned, and sharing the machine only
255
+ * raises CPU, so a suspect whose sum is inside the budget is inside it alone
256
+ * too. A suspect with no figure (a child not yet reaped) runs alone. Only the alone run can see a WAIT, so a
257
+ * suite past WAIT_FACTOR times the budget in wall time still gets it while
258
+ * the machine is idle */
259
+ export function aloneRunsNeeded(suspects: Timed[], parallelCpu: Record<string, PhaseCpu>, budgetMs: number, load: MachineLoad): { rerun: Timed[]; cleared: Confirmed[] } {
260
+ const idle = load.load1 < load.cores
261
+ const rerun: Timed[] = []
262
+ const cleared: Confirmed[] = []
263
+ for (const t of suspects) {
264
+ const f = parallelCpu[t.file]
265
+ const mayWait = idle && t.ms > budgetMs * WAIT_FACTOR
266
+ if (f != null && f.cpuMs + f.priorMs <= budgetMs && !mayWait) cleared.push({ file: t.file, ms: f.cpuMs, wallMs: t.ms, cpuMs: f.cpuMs, over: null })
267
+ else rerun.push(t)
268
+ }
269
+ return { rerun, cleared }
111
270
  }
112
271
 
272
+ /** the suites with a failing test, read off bun's report: a file that failed
273
+ * prints its path as a header line, then its `(fail)` lines under it. A
274
+ * passing file prints nothing at all in a piped run, which is why this reads
275
+ * failures only */
276
+ export function failedSuites(output: string): string[] {
277
+ const out = new Set<string>()
278
+ let cur: string | null = null
279
+ for (const line of output.split('\n')) {
280
+ const head = line.match(/^(\S+\.(?:test|spec)\.[cm]?[jt]sx?):$/)
281
+ if (head != null) { cur = normSuitePath(head[1]!); continue }
282
+ // a file that cannot load prints no `(fail)` line, only this banner
283
+ if (cur != null && (/^\(fail\) /.test(line) || line.startsWith('# Unhandled error between tests'))) out.add(cur)
284
+ }
285
+ return [...out]
286
+ }
287
+
288
+ /** the FIRST failure of each failing suite, read off the same report as
289
+ * failedSuites: its `(fail)` line, timing dropped, with the first `error:`
290
+ * line bun printed before it in that file. What the record keeps so a page
291
+ * can say why a file is red without a rerun */
292
+ export function failLines(output: string): Map<string, string> {
293
+ const out = new Map<string, string>()
294
+ let cur: string | null = null
295
+ let err: string | null = null
296
+ for (const raw of output.split('\n')) {
297
+ const line = raw.trimEnd()
298
+ const head = line.match(/^(\S+\.(?:test|spec)\.[cm]?[jt]sx?):$/)
299
+ if (head != null) { cur = normSuitePath(head[1]!); err = null; continue }
300
+ if (cur == null || out.has(cur)) continue
301
+ if (err == null && /^\s*error: /.test(line)) err = line.trim()
302
+ const fail = line.match(/^\(fail\) (.*?)(?: \[[\d.]+m?s\])?$/)
303
+ if (fail != null) out.set(cur, clip(err == null ? fail[1]! : `${fail[1]!}: ${err}`))
304
+ else if (line.startsWith('# Unhandled error between tests')) out.set(cur, clip(err == null ? 'an unhandled error between tests' : `an unhandled error between tests: ${err}`))
305
+ }
306
+ return out
307
+ }
308
+
309
+ const clip = (s: string): string => (s.length > 300 ? `${s.slice(0, 299)}…` : s)
310
+
113
311
  /** the suites bun names as a crashed parallel worker */
114
312
  export function crashedWorkers(output: string): { file: string; signal: string }[] {
115
313
  return [...output.matchAll(/^\s*✗\s+(\S+)\s+\(worker crashed: (SIG[A-Z0-9]+)\)\s*$/gm)].map((m) => ({ file: normSuitePath(m[1]!), signal: m[2]! }))
@@ -0,0 +1,58 @@
1
+ // THE DURATIONS A SUITE RUN LEAVES FOR THE NEXT ONE. bun's --timings file does
2
+ // two jobs: the runner reads the durations back for the budget, and bun itself
3
+ // starts the slowest files first when the file already holds durations. A run
4
+ // that only ever reads a file in its own temp dir schedules in discovery order,
5
+ // so one long suite can start last and set the wall time alone.
6
+ //
7
+ // The kept file is shared by every run of the unit, several at once, so bun
8
+ // never writes it directly: each run gets a copy to read and update, and the
9
+ // kept file is replaced whole by a rename. A kept file that is not in bun's own
10
+ // shape seeds nothing, because bun fails the whole phase on one.
11
+ //
12
+ // bun MERGES what it measured into what it read, so a suite deleted since the
13
+ // last run comes back in the file as a timing this run never took. The runner
14
+ // judges and keeps only the suites the phase ran (suite-cli.ts).
15
+ import { copyFileSync, mkdirSync, readFileSync } from 'fs'
16
+ import { dirname, join } from 'path'
17
+ import { atomicWrite } from '../core/locked-write.ts'
18
+ import { shipkitRoot } from '../ledger/store.ts'
19
+
20
+ /** where a unit's durations live, beside everything else the repo remembers */
21
+ export const timingsHome = (root: string, unit: string): string => join(shipkitRoot(root), 'suite-timings', `${unit === '' ? 'root' : unit}.json`)
22
+
23
+ /** the text, when it is a timings document bun wrote */
24
+ const timingsText = (path: string): string | null => {
25
+ try {
26
+ const text = readFileSync(path, 'utf8')
27
+ // bun refuses a timings file without `version: 1`, and a refusal fails
28
+ // the whole phase, so only a file in bun's own shape is handed on
29
+ const parsed = JSON.parse(text) as { version?: unknown; files?: unknown }
30
+ return parsed != null && parsed.version === 1 && typeof parsed.files === 'object' && parsed.files != null && !Array.isArray(parsed.files) ? text : null
31
+ } catch {
32
+ return null
33
+ }
34
+ }
35
+
36
+ /** copy the kept durations to where this run's bun reads them. false when
37
+ * there are none worth reading */
38
+ export function seedTimings(kept: string, runFile: string): boolean {
39
+ if (timingsText(kept) == null) return false
40
+ try {
41
+ copyFileSync(kept, runFile)
42
+ return true
43
+ } catch {
44
+ return false
45
+ }
46
+ }
47
+
48
+ /** keep what this run measured. A run that wrote nothing readable keeps the
49
+ * last good file */
50
+ export function keepTimings(runFile: string, kept: string): void {
51
+ const text = timingsText(runFile)
52
+ if (text == null) return
53
+ try {
54
+ mkdirSync(dirname(kept), { recursive: true })
55
+ atomicWrite(kept, text)
56
+ } catch { /* the next run schedules blind, which is slower and never wrong */ }
57
+ }
58
+
@@ -0,0 +1,88 @@
1
+ // does this check body run bun's test runner ITSELF? Tests run through the
2
+ // generated test checks only (the floor `test` slot, one `test:<unit>` per
3
+ // declared unit, core/floor-resolve.ts): they run in the unit's dir with its
4
+ // bunfig and preload, parallel and timed against the budget, and SK018 knows
5
+ // which file each one reaches. A hand written check running `bun test` is a
6
+ // second runner nobody plans, times or dedupes. Rule SK019 fails on one.
7
+ //
8
+ // Read from source text, never by importing it: a legacy check script runs its
9
+ // body on import. The reader only looks at code, never at comments, and only at
10
+ // three shapes that EXECUTE something: an argv literal (`['bun', 'test']`), a
11
+ // command string handed to a shell (`sh -c`, `$\`…\``, `execSync(…)`), and a
12
+ // reference to the suite runner module. A string that merely NAMES a command
13
+ // (a message, a script body written into package.json) is data, and passes.
14
+ import { basename } from 'path'
15
+ import { lexSource } from '../ts/lex.ts'
16
+
17
+ /** a `bun run <script>` that runs the test slot's own body */
18
+ const TEST_SCRIPT = /^test(:raw)?$/
19
+
20
+ /** a package.json script body by name, null when there is none */
21
+ export type ScriptReader = (name: string) => string | null
22
+
23
+ /** what an argv runs, when it is bun's test runner: `bun test`, `bun run
24
+ * test:raw`, `bun run test`, `bun run <script>` whose body runs it (when
25
+ * `scripts` can read the body), or a shell command string that does */
26
+ export function argvRunsTests(argv: readonly string[], scripts?: ScriptReader): string | null {
27
+ const [head, a1, a2] = argv
28
+ if (head == null) return null
29
+ const bin = basename(head)
30
+ if (bin === 'bun' || head === 'process.execPath') {
31
+ if (a1 === 'test') return 'bun test'
32
+ if (a1 === 'run' && a2 != null) return runScript(a2, scripts)
33
+ }
34
+ if (argv.some((a) => a.includes('suite-cli'))) return 'the suite runner (suite-cli)'
35
+ if (['sh', 'bash', 'zsh'].includes(bin) && a1 === '-c' && a2 != null) return commandRunsTests(a2, scripts)
36
+ return null
37
+ }
38
+
39
+ /** `bun run <name>`: the test slot's own script, or one whose body runs the
40
+ * test runner. One level deep, so a script naming itself cannot loop */
41
+ function runScript(name: string, scripts?: ScriptReader): string | null {
42
+ if (TEST_SCRIPT.test(name)) return `bun run ${name}`
43
+ const body = scripts?.(name)
44
+ if (body == null) return null
45
+ const inner = commandRunsTests(body)
46
+ return inner == null ? null : `bun run ${name} (its script runs ${inner})`
47
+ }
48
+
49
+ /** a shell command string that runs bun's test runner, at its start or after
50
+ * a `&&`, `;` or `||`, env assignments (`TZ=UTC bun test`) allowed before it */
51
+ export function commandRunsTests(cmd: string, scripts?: ScriptReader): string | null {
52
+ for (const m of cmd.matchAll(/(?:^|&&|;|\|\|)\s*(?:\w+=\S*\s+)*bun\s+(test(?=\s|$)|run\s+([\w:.-]+))/g)) {
53
+ if (m[2] == null) return 'bun test'
54
+ const hit = runScript(m[2], scripts)
55
+ if (hit != null) return hit
56
+ }
57
+ return null
58
+ }
59
+
60
+ const LIT = '\\0(\\d+)\\0'
61
+ /** an argv literal in a position that EXECUTES it: `cmd: [`, `exec([`,
62
+ * `spawn([`, `Bun.spawn([`. A list of words held as data is not a run */
63
+ const ARGV_RUN = new RegExp(`(?:\\bcmd\\s*:\\s*|\\b(?:exec|execFile|execFileSync|spawn|spawnSync|execa)\\s*\\(\\s*|\\bBun\\.spawn(?:Sync)?\\s*\\(\\s*)\\[\\s*((?:process\\.execPath|${LIT})(?:\\s*,\\s*${LIT})*)`, 'g')
64
+ /** a string handed straight to a shell: `execSync('…')`, `$\`…\``. A
65
+ * template's chunks come as a run, `${…}` between them */
66
+ const SHELL_CALL = new RegExp(`(?:\\b(?:execSync|exec|execaCommand|spawnSync|spawn)\\s*\\(\\s*|\\$)\`?((?:${LIT})(?:\\$\\{[^\\0\`]*?\\}${LIT})*)`, 'g')
67
+ /** the suite runner's module, resolved or imported */
68
+ const SUITE_REF = new RegExp(`(?:\\bresolve\\s*\\(\\s*|\\bimport\\s*\\(\\s*|\\bfrom\\s*)\`?${LIT}`, 'g')
69
+
70
+ const literals = (run: string, strings: string[]): string[] => [...run.matchAll(new RegExp(`process\\.execPath|${LIT}`, 'g'))].map((x) => (x[1] == null ? 'process.execPath' : strings[Number(x[1])]!))
71
+
72
+ /** every way this source runs bun's test runner, one line each. Empty = it
73
+ * never does. `scripts` reads the package.json a `bun run <name>` names */
74
+ export function sourceRunsTests(src: string, scripts?: ScriptReader): string[] {
75
+ const { code, strings } = lexSource(src)
76
+ const found = new Set<string>()
77
+ for (const m of code.matchAll(ARGV_RUN)) {
78
+ const hit = argvRunsTests(literals(m[1]!, strings), scripts)
79
+ if (hit != null) found.add(hit)
80
+ }
81
+ for (const m of code.matchAll(SHELL_CALL)) {
82
+ // an interpolation reads as one word, so `cd ${dir} && bun test` stays a command
83
+ const hit = commandRunsTests(literals(m[1]!, strings).join('X'), scripts)
84
+ if (hit != null) found.add(hit)
85
+ }
86
+ for (const m of code.matchAll(SUITE_REF)) if (/(?:^|\/)suite-cli(?:\.ts)?$/.test(strings[Number(m[1])]!)) found.add('the suite runner (suite-cli)')
87
+ return [...found]
88
+ }