rvlib-shipkit 0.38.1 → 1.0.1

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 (255) hide show
  1. package/CHANGELOG.md +379 -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/derived.ts +1 -1
  6. package/src/assets/gen/generate-roll.ts +1 -1
  7. package/src/assets/kit-config.ts +6 -6
  8. package/src/assets/run-assets.ts +14 -10
  9. package/src/assets/server/studio-server.ts +6 -5
  10. package/src/check.ts +343 -0
  11. package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
  12. package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
  13. package/src/checks/SK003-claude-md-shape.check.ts +66 -0
  14. package/src/checks/SK004-gitignore-policy.check.ts +309 -0
  15. package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +49 -32
  16. package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
  17. package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
  18. package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
  19. package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
  20. package/src/checks/SK010-undeclared-unit.check.ts +111 -0
  21. package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
  22. package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
  23. package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
  24. package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
  25. package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
  26. package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
  27. package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
  28. package/src/checks/SK018-test-files-run.check.ts +28 -0
  29. package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
  30. package/src/checks/SK020-self-spawning-check.check.ts +147 -0
  31. package/src/checks/contracts.check.ts +20 -0
  32. package/src/cli/cmd_Asks.ts +3 -2
  33. package/src/cli/cmd_Assets.ts +2 -2
  34. package/src/cli/cmd_Bump.ts +1 -1
  35. package/src/cli/cmd_Check.ts +6 -6
  36. package/src/cli/cmd_Checks.ts +95 -0
  37. package/src/cli/cmd_Ci.ts +48 -13
  38. package/src/cli/cmd_Commands.ts +2 -6
  39. package/src/cli/cmd_ConfigKeys.ts +69 -0
  40. package/src/cli/cmd_Contracts.ts +1 -1
  41. package/src/cli/cmd_Fleet.ts +16 -3
  42. package/src/cli/cmd_Hooks.ts +1 -1
  43. package/src/cli/cmd_Keys.ts +43 -18
  44. package/src/cli/cmd_Mv.ts +2 -2
  45. package/src/cli/cmd_Prose.ts +10 -1
  46. package/src/cli/cmd_Releases.ts +2 -2
  47. package/src/cli/cmd_Serve.ts +1 -1
  48. package/src/cli/cmd_Start.ts +6 -6
  49. package/src/cli/cmd_Status.ts +25 -0
  50. package/src/cli/cmd_Tui.ts +2 -2
  51. package/src/cli.ts +7 -4
  52. package/src/collections/actions.ts +4 -4
  53. package/src/collections/checks.ts +257 -0
  54. package/src/collections/collection-types.ts +254 -1
  55. package/src/collections/comfy.ts +23 -0
  56. package/src/collections/md-body.ts +14 -0
  57. package/src/collections/pings.ts +38 -12
  58. package/src/collections/posts.ts +19 -3
  59. package/src/collections/providers.ts +198 -91
  60. package/src/collections/reflections.ts +41 -0
  61. package/src/collections/state-rules.ts +73 -0
  62. package/src/collections/test-time.ts +288 -0
  63. package/src/collections/tests.ts +181 -0
  64. package/src/collections/wikis.ts +66 -0
  65. package/src/comfy/comfy-panel.ts +292 -0
  66. package/src/contracts/apply-contracts.ts +17 -12
  67. package/src/contracts/contract-types.ts +16 -4
  68. package/src/contracts/contracts-engine.ts +78 -42
  69. package/src/contracts/manifests/discord.contract.ts +6 -6
  70. package/src/contracts/manifests/identity.contract.ts +3 -3
  71. package/src/contracts/manifests/itch.contract.ts +12 -15
  72. package/src/contracts/manifests/social.contract.ts +5 -5
  73. package/src/contracts/manifests/steam.contract.ts +16 -16
  74. package/src/contracts/run-contracts.ts +4 -3
  75. package/src/core/attach.ts +96 -0
  76. package/src/core/canonical-scripts.ts +25 -0
  77. package/src/core/check-ctx.ts +204 -14
  78. package/src/core/check-file-names.ts +112 -0
  79. package/src/core/check-pass.ts +84 -0
  80. package/src/core/check-runner.ts +796 -0
  81. package/src/core/config-keys.ts +256 -0
  82. package/src/core/config-rewrite.ts +77 -0
  83. package/src/core/discover-checks.ts +166 -90
  84. package/src/core/discover-legacy-checks.ts +26 -0
  85. package/src/core/doc-block.ts +13 -0
  86. package/src/core/floor-resolve.ts +264 -0
  87. package/src/core/floor.ts +20 -137
  88. package/src/core/git-bin.ts +24 -0
  89. package/src/core/git-root.ts +108 -0
  90. package/src/core/instance.ts +195 -0
  91. package/src/core/jobs.ts +118 -0
  92. package/src/core/layout.ts +52 -0
  93. package/src/core/legacy-shapes.ts +207 -0
  94. package/src/core/lib-root.ts +5 -0
  95. package/src/core/locked-write.ts +110 -39
  96. package/src/core/machine-commit.ts +5 -4
  97. package/src/core/migrate-layout.ts +616 -0
  98. package/src/core/motherhome.ts +2 -8
  99. package/src/core/nesting.ts +4 -12
  100. package/src/core/one-way-in-policy.ts +73 -17
  101. package/src/core/outcome-words.ts +45 -0
  102. package/src/core/package-manager.ts +88 -0
  103. package/src/core/parallel.ts +21 -0
  104. package/src/core/process-tree.ts +44 -0
  105. package/src/core/raw-access.ts +93 -0
  106. package/src/core/read-set.ts +152 -0
  107. package/src/core/run-check.ts +155 -213
  108. package/src/core/run-ci.ts +152 -189
  109. package/src/core/run-fix.ts +99 -0
  110. package/src/core/run-fixers.ts +64 -33
  111. package/src/core/self-update.ts +11 -3
  112. package/src/core/slots.ts +83 -0
  113. package/src/core/spawn-timeout.ts +34 -3
  114. package/src/core/when-block.ts +401 -0
  115. package/src/core/when.ts +135 -0
  116. package/src/core/write-status.ts +5 -2
  117. package/src/fleet/scan-fleet.ts +16 -0
  118. package/src/hooks/leak-guard.ts +20 -9
  119. package/src/hooks/one-way-in-guard.ts +13 -5
  120. package/src/hooks/root-identity.ts +2 -1
  121. package/src/hooks/run-hooks.ts +38 -26
  122. package/src/init-repo.ts +39 -23
  123. package/src/ledger/check-stats.ts +155 -0
  124. package/src/ledger/check-table.ts +179 -0
  125. package/src/ledger/db.ts +255 -0
  126. package/src/ledger/gate-checks.ts +433 -0
  127. package/src/ledger/git-key.ts +116 -77
  128. package/src/ledger/git-scan.ts +96 -35
  129. package/src/ledger/ledger.ts +116 -42
  130. package/src/ledger/live-runs.ts +111 -0
  131. package/src/ledger/live-timeline.ts +97 -0
  132. package/src/ledger/repo-view.ts +73 -0
  133. package/src/ledger/run-log.ts +448 -0
  134. package/src/ledger/run-timeline.ts +173 -0
  135. package/src/ledger/store.ts +23 -12
  136. package/src/ledger/suite-log.ts +161 -0
  137. package/src/ledger/toolchain-identity.ts +7 -1
  138. package/src/ledger/world-cache.ts +111 -0
  139. package/src/mv/mv-md.ts +16 -6
  140. package/src/problems/extract.ts +182 -0
  141. package/src/problems/problem-board.ts +37 -0
  142. package/src/problems/problem-line.ts +77 -0
  143. package/src/prose/prose-view.ts +90 -0
  144. package/src/prose/run-prose.ts +60 -19
  145. package/src/prose/ste.ts +140 -24
  146. package/src/reflections/reflections-queue.ts +81 -0
  147. package/src/release/git-facts.ts +15 -9
  148. package/src/release/run-bump.ts +6 -5
  149. package/src/release/run-release.ts +25 -11
  150. package/src/release/run-releases.ts +2 -2
  151. package/src/release/unit-gate.ts +7 -54
  152. package/src/release/unit-resolve.ts +282 -0
  153. package/src/release/units.ts +33 -217
  154. package/src/repo-config.ts +157 -88
  155. package/src/rules/rule.ts +36 -96
  156. package/src/rules/untrack-generated.ts +5 -4
  157. package/src/scaffold/files/vscode-tasks.json +10 -31
  158. package/src/scaffold/jsonc-spans.ts +113 -0
  159. package/src/scaffold/project-scaffold.ts +16 -43
  160. package/src/scan.ts +98 -0
  161. package/src/serve/gate-trigger.ts +148 -0
  162. package/src/serve/health-schedule.ts +102 -0
  163. package/src/serve/hub-app/chart-view.ts +179 -0
  164. package/src/serve/hub-app/check-view.tsx +345 -0
  165. package/src/serve/hub-app/check-words.ts +164 -0
  166. package/src/serve/hub-app/checks-svc.ts +280 -0
  167. package/src/serve/hub-app/clock-svc.ts +33 -0
  168. package/src/serve/hub-app/col-card.tsx +23 -39
  169. package/src/serve/hub-app/cols-svc.ts +44 -20
  170. package/src/serve/hub-app/comfy-svc.ts +171 -0
  171. package/src/serve/hub-app/comfy-view.tsx +81 -0
  172. package/src/serve/hub-app/dur.ts +41 -0
  173. package/src/serve/hub-app/hash-route.ts +121 -0
  174. package/src/serve/hub-app/header-bar.tsx +117 -0
  175. package/src/serve/hub-app/health-svc.ts +23 -28
  176. package/src/serve/hub-app/help.tsx +29 -0
  177. package/src/serve/hub-app/hub-app.tsx +122 -119
  178. package/src/serve/hub-app/hub-st.ts +115 -42
  179. package/src/serve/hub-app/hub-types.ts +117 -2
  180. package/src/serve/hub-app/hub.css +534 -192
  181. package/src/serve/hub-app/index.html +2 -0
  182. package/src/serve/hub-app/live-svc.ts +174 -0
  183. package/src/serve/hub-app/md.ts +15 -0
  184. package/src/serve/hub-app/media-svc.ts +4 -1
  185. package/src/serve/hub-app/media-view.tsx +15 -0
  186. package/src/serve/hub-app/next-run.ts +68 -0
  187. package/src/serve/hub-app/project-gutter.css +65 -0
  188. package/src/serve/hub-app/project-gutter.tsx +128 -0
  189. package/src/serve/hub-app/projects-svc.ts +226 -0
  190. package/src/serve/hub-app/prose-hits-st.ts +124 -0
  191. package/src/serve/hub-app/prose-hits-view.tsx +176 -0
  192. package/src/serve/hub-app/prose-hits.css +21 -0
  193. package/src/serve/hub-app/run-strip.ts +47 -0
  194. package/src/serve/hub-app/runs-tabs.ts +24 -0
  195. package/src/serve/hub-app/test-view.tsx +107 -0
  196. package/src/serve/hub-app/tests-svc.ts +62 -0
  197. package/src/serve/hub-app/tests-view.tsx +238 -0
  198. package/src/serve/hub-app/time-ago.tsx +34 -0
  199. package/src/serve/hub-app/timeline-chart.tsx +274 -0
  200. package/src/serve/hub-app/timeline-geometry.ts +314 -0
  201. package/src/serve/hub-app/timeline-svc.ts +62 -0
  202. package/src/serve/hub-app/timeline-view.tsx +245 -0
  203. package/src/serve/hub-app/tree-build.ts +410 -0
  204. package/src/serve/hub-app/tree-row.tsx +42 -0
  205. package/src/serve/hub-app/tree-svc.ts +174 -0
  206. package/src/serve/hub-app/wikis-view.tsx +53 -0
  207. package/src/serve/other-repos.ts +79 -0
  208. package/src/serve/project-gutter-route.ts +350 -0
  209. package/src/serve/project-gutter.ts +449 -0
  210. package/src/serve/run-serve.ts +245 -16
  211. package/src/serve/same-origin.ts +14 -0
  212. package/src/serve/serve-hub.ts +213 -18
  213. package/src/shipkit.ts +19 -12
  214. package/src/smoke/run-smoke.ts +9 -4
  215. package/src/start-repo.ts +75 -36
  216. package/src/suite/judge-suites.ts +209 -15
  217. package/src/suite/kept-timings.ts +58 -0
  218. package/src/suite/runner-shapes.ts +88 -0
  219. package/src/suite/suite-cli.ts +147 -24
  220. package/src/suite/suite-cpu-preload.ts +92 -0
  221. package/src/suite/test-units.ts +243 -0
  222. package/src/ts/config.ts +4 -69
  223. package/src/ts/jsonc.ts +69 -0
  224. package/src/ts/lex.ts +143 -0
  225. package/src/ts/rename.ts +73 -10
  226. package/src/ts/source-graph.ts +28 -8
  227. package/src/ts/tsconfig-projects.ts +6 -97
  228. package/src/ts/tsconfig-resolve.ts +108 -0
  229. package/src/tui/build-steps-panel.ts +61 -0
  230. package/src/tui/checks-panel.ts +24 -16
  231. package/src/tui/run-tui.ts +2 -2
  232. package/src/tui/tui-panel.ts +5 -4
  233. package/src/wiki/graph-colors.ts +68 -0
  234. package/src/wiki/graph-mount.ts +7 -9
  235. package/src/wiki/graph-page.ts +42 -25
  236. package/src/wiki/graph-positions.ts +105 -0
  237. package/src/wiki/run-wiki.ts +1 -1
  238. package/src/wiki/scan-md.ts +1 -1
  239. package/src/wiki/serve-graph.ts +33 -3
  240. package/src/cli/cmd_Rules.ts +0 -51
  241. package/src/cli/cmd_Units.ts +0 -82
  242. package/src/collections/graphs.ts +0 -32
  243. package/src/contracts/contracts-once.ts +0 -43
  244. package/src/core/check-suite.ts +0 -302
  245. package/src/core/repair-contract-dirs.ts +0 -136
  246. package/src/core/task-runner.ts +0 -339
  247. package/src/ledger/gate-units.ts +0 -138
  248. package/src/ledger/unit-table.ts +0 -105
  249. package/src/rules/SK002-contracts-fulfillness.ts +0 -24
  250. package/src/rules/SK003-claude-md-shape.ts +0 -70
  251. package/src/rules/SK004-gitignore-policy.ts +0 -238
  252. package/src/rules/SK010-undeclared-release-unit.ts +0 -127
  253. package/src/rules/rule-registry.ts +0 -64
  254. package/src/rules/run-fix.ts +0 -72
  255. package/src/tui/tasks-panel.ts +0 -60
@@ -0,0 +1,796 @@
1
+ // THE RUNNER — one DAG over every check this repo has, whatever declared it
2
+ // and whichever body it carries. There is no second engine: `shipkit ci` and
3
+ // `shipkit check` differ by which checks they ASK for, never by how one runs.
4
+ //
5
+ // What it does, in the order a reader meets it:
6
+ // - a check with no `deps` is a root, one with `deps` waits for them to PASS.
7
+ // A filtered out dep counts as satisfied, a failed or skipped dep skips its
8
+ // dependents transitively with a note saying why. Deps double as the race
9
+ // guard between two checks that touch one file.
10
+ // - `off` is handled ONCE, by name, before anything is scheduled: the check
11
+ // never runs, it prints its skip line with the reason, and its row reports
12
+ // `skip` so no surface reads it as a green nobody measured.
13
+ // - a REPLAY comes first, from the ledger (content keyed) or from the world
14
+ // cache (TTL). A replayed check runs nothing and says so on its first line.
15
+ // - a `cmd` body is a subprocess: its whole output is streamed to a log file,
16
+ // its wire lines are parsed back, the exit code is truth, and it is killed
17
+ // with its whole process TREE at its ceiling (120s by default, whatever a
18
+ // build step's floor resolution gives it).
19
+ // - a `run` body runs IN PROCESS through check.ts runCheckBody, which tags its
20
+ // lines with the check id and catches a throw as that check's failure. It
21
+ // cannot be killed, so a hanging one hangs the gate: that is the price of
22
+ // in-process, and `cmd` is the answer for anything that can hang. Its one
23
+ // parameter hangs off ONE pass per call (core/check-pass.ts), which is where
24
+ // a shared scan is memoised, and what it read goes to the ledger with it.
25
+ // - `blocking: false` is demoted at the SOURCE: the FAIL lines are rewritten
26
+ // to warnings before anything reads them, and the real outcome rides beside
27
+ // them on the result, so the exit code means exactly "something that blocks
28
+ // you failed" and a page can still show the red.
29
+ // - ONE tap fires per check, with what the wire lines cannot say: the real
30
+ // status, whether it replayed, whether it can block, what it cost.
31
+ import { closeSync, mkdirSync, openSync, readdirSync, rmSync, writeSync } from 'fs'
32
+ import { basename, join } from 'path'
33
+ import { shareRun } from './attach.ts'
34
+ import { CheckCtx } from './check-ctx.ts'
35
+ import { CheckPass } from './check-pass.ts'
36
+ import type { ReadSet } from './read-set.ts'
37
+ import { RepoView } from '../ledger/repo-view.ts'
38
+ import { parallelCap } from './parallel.ts'
39
+ import { machinePool, SLOT_HELD_ENV, takeSlot, type SlotPool } from './slots.ts'
40
+ import { killTree, spawnWithTimeout } from './spawn-timeout.ts'
41
+ import { formatLine, parseWireOutput, type Line } from './wire.ts'
42
+ import { offLine, type WhenDecl } from './when.ts'
43
+ import { runCheckBody, type CheckDef, type CheckEnv } from '../check.ts'
44
+ import { Ledger, stampCached } from '../ledger/ledger.ts'
45
+ import { attachedStamp } from './outcome-words.ts'
46
+ import type { RunLog, RunStatus } from '../ledger/run-log.ts'
47
+ import { liveStateOf } from '../ledger/live-runs.ts'
48
+ import { CHECK_ID_ENV } from '../ledger/suite-log.ts'
49
+ import { rememberWorld, replayWorld, type WorldCache } from '../ledger/world-cache.ts'
50
+ import { extractProblems } from '../problems/extract.ts'
51
+ import { feedsProblemsPane, type Population, type Problem } from '../problems/problem-line.ts'
52
+
53
+ /** the ceiling a check takes when it names none. A build step's floor
54
+ * resolution hands it a longer one: a whole tool chain is not one question
55
+ * about the world. */
56
+ export const CHECK_TIMEOUT_MS = 120_000
57
+
58
+ /** epoch ms that never goes backwards inside one process, whole numbers so a
59
+ * row's `at - ms` is its start exactly: the wall clock can step back (an NTP
60
+ * correction), and a check stamped before an earlier one ended makes the
61
+ * record show more checks at once than ever ran */
62
+ const clock = (): number => Math.floor(performance.timeOrigin + performance.now())
63
+
64
+ export type CheckBody = Pick<CheckDef, 'run' | 'cmd' | 'env' | 'cwd'>
65
+
66
+ /** one check, resolved: everything the runner needs and nothing it does not.
67
+ * ledger/gate-checks.ts builds these from the one enumeration, so a surface
68
+ * can never list a different set than the one that runs. */
69
+ export type RunnableCheck = {
70
+ id: string
71
+ title: string
72
+ /** the fix pointer floor, appended after `docsBase` when a failing body
73
+ * attached no precise pointer of its own */
74
+ owner: string
75
+ /** null = it never said, which the runner treats as fatal and the caller
76
+ * reports as a defect */
77
+ blocking: boolean | null
78
+ /** a build step: the gate runs it, the health pass does not */
79
+ build: boolean
80
+ when: WhenDecl
81
+ deps: string[]
82
+ group: string | null
83
+ timeoutMs: number
84
+ /** the declared world TTL in ms, 0 when this check is not a world probe */
85
+ ttlMs: number
86
+ /** the rotation half of a world key (a secret hash, a config switch) */
87
+ rotation?: string
88
+ /** it rewrites the repo unless frozen, so frozen is part of its key */
89
+ writes?: boolean
90
+ body: CheckBody
91
+ /** an idempotent repair exists, so a fix pointer advertises it */
92
+ fix?: unknown
93
+ /** what a failing subprocess points the reader at */
94
+ pointer: string | null
95
+ /** how it is remembered (problems/problem-line.ts populationOf). Absent
96
+ * reads a declared TTL as world and anything else as not */
97
+ population?: Population
98
+ }
99
+
100
+ export type CheckResult = {
101
+ id: string
102
+ /** the REAL outcome. A failure that cannot block still says fail here */
103
+ status: RunStatus
104
+ /** it failed and cannot block: red on its own page, never on the gate */
105
+ advisory: boolean
106
+ replayed: boolean
107
+ ms: number
108
+ exitCode: number | null
109
+ /** the check's own wire lines, demoted already when it cannot block */
110
+ lines: Line[]
111
+ /** its file anchored findings, for the problems pane. Empty for a world
112
+ * check, whatever it printed (problems/problem-line.ts owns the rule) */
113
+ problems: Problem[]
114
+ /** the headline a failure summary reads */
115
+ lastLine: string
116
+ /** why it did not run (a dead dep, a bail, an interrupt) */
117
+ note: string
118
+ logPath: string | null
119
+ }
120
+
121
+ /** one check finishing, with the facts the wire lines drop. ONE tap: the TUI,
122
+ * the hub stream and the run record all read this. */
123
+ export type CheckEvent = {
124
+ id: string
125
+ /** the name the wire and the TUI know it by (its title) */
126
+ name: string
127
+ status: RunStatus
128
+ ms: number
129
+ replayed: boolean
130
+ blocking: boolean | null
131
+ advisory: boolean
132
+ lines: Line[]
133
+ /** file anchored findings of this verdict, measured or replayed. The
134
+ * runner always sets it, absent reads as none */
135
+ problems?: Problem[]
136
+ logPath?: string | null
137
+ lastLine?: string
138
+ note?: string
139
+ /** it measured nothing here: another run measuring the same key answered it (core/attach.ts) */
140
+ attached?: boolean
141
+ }
142
+
143
+ /** which question is being asked. `gate` is `shipkit ci` (is my change
144
+ * good), `health` is `shipkit check` (is this repo healthy), and the only
145
+ * thing it changes is WHICH checks are asked. */
146
+ export type Audience = 'gate' | 'health'
147
+
148
+ export type RunChecksOpts = {
149
+ checks: RunnableCheck[]
150
+ root: string
151
+ config: CheckEnv['config']
152
+ audience: Audience
153
+ /** `release` checks are in at the full tier and out at the quick one */
154
+ fullTier: boolean
155
+ /** where a `cmd` check's whole output is streamed */
156
+ logDir: string
157
+ /** the world answers' store, null = nothing is remembered by TTL */
158
+ cache: WorldCache | null
159
+ /** the run's ledger, null = every check is measured */
160
+ ledger: Ledger | null
161
+ /** the run record, written per check, never read */
162
+ log: RunLog | null
163
+ /** extra environment for every `cmd` check of this run */
164
+ childEnv?: Record<string, string | undefined>
165
+ bail?: boolean
166
+ maxParallel?: number
167
+ /** the machine pool every measured check takes a slot from (core/slots.ts).
168
+ * Omitted: this machine's pool. null: no pool, the in process cap alone */
169
+ pool?: SlotPool | null
170
+ skipGroups?: string[]
171
+ onlyIds?: string[]
172
+ withIds?: string[]
173
+ /** run only the checks that can BLOCK. A check declaring blocking:false
174
+ * cannot change the outcome, so a release does not spend a third-party
175
+ * API call to learn that a DNS provider is reachable. */
176
+ onlyBlocking?: boolean
177
+ /** the BUILD steps alone. The TUI's tasks panel owns this: it renders the
178
+ * rest in its own panel, and running them twice per refresh proves the
179
+ * same thing twice. */
180
+ buildOnly?: boolean
181
+ /** the gate asks the PROACTIVE checks too (`release.requireHealthy`): a
182
+ * repo that wants a missing cred to stop its publish asks for exactly
183
+ * those probes, and the everyday opt-out must not drop them silently. */
184
+ includeProactive?: boolean
185
+ /** ids this caller does not need. A sub-unit release skips the steps that
186
+ * gate the whole repo, since the check's own gate already ran. */
187
+ exclude?: string[]
188
+ print?: (line: string) => void
189
+ color?: boolean
190
+ onCheck?: (e: CheckEvent) => void
191
+ }
192
+
193
+ export type RunChecksSummary = {
194
+ /** nothing that can block you failed */
195
+ ok: boolean
196
+ interrupted: boolean
197
+ /** every check that was asked for, in declaration order */
198
+ results: CheckResult[]
199
+ /** checks this run did not ask about, with the reason */
200
+ parked: string[]
201
+ /** checks that really failed and cannot block */
202
+ advisoryFailures: string[]
203
+ /** checks that never declared whether they block */
204
+ undeclared: string[]
205
+ /** a registration nobody can act on: the run printed these and stopped */
206
+ errors: string[]
207
+ }
208
+
209
+ const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, '').replace(/\r/g, '')
210
+
211
+ // only-mode keeps the transitive dep closure — running `--only typecheck`
212
+ // with a lint race-guard dep must still run lint first
213
+ function closure(checks: RunnableCheck[], ids: string[]): Set<string> {
214
+ const byId = new Map(checks.map((u) => [u.id, u]))
215
+ const keep = new Set<string>()
216
+ const visit = (id: string): void => {
217
+ if (keep.has(id)) return
218
+ keep.add(id)
219
+ for (const d of byId.get(id)?.deps ?? []) visit(d)
220
+ }
221
+ for (const id of ids) visit(id)
222
+ return keep
223
+ }
224
+
225
+ export type Selection = {
226
+ /** what this run measures */
227
+ selected: RunnableCheck[]
228
+ /** turned off by name, with the reason: never run, always reported */
229
+ off: RunnableCheck[]
230
+ /** not asked about by this run, and why */
231
+ parked: { id: string; text: string }[]
232
+ /** asked for, and it never said whether it blocks */
233
+ undeclared: string[]
234
+ errors: string[]
235
+ }
236
+
237
+ /** what this run asks about. The audience decides the shape of the question,
238
+ * `--only` and `--with` name checks past any filter, and OFF is the one word
239
+ * no flag lifts. */
240
+ export function selectChecks(o: Pick<RunChecksOpts, 'checks' | 'audience' | 'fullTier' | 'skipGroups' | 'onlyIds' | 'withIds' | 'onlyBlocking' | 'buildOnly' | 'exclude' | 'includeProactive'>): Selection {
241
+ const errors: string[] = []
242
+ const ids = new Set(o.checks.map((u) => u.id))
243
+ if (ids.size !== o.checks.length) errors.push('duplicate check ids')
244
+ for (const u of o.checks) {
245
+ if ((u.body.cmd == null) === (u.body.run == null)) errors.push(`check ${u.id}: exactly one body required, \`run\` (in process) or \`cmd\` (argv)`)
246
+ for (const d of u.deps) if (!ids.has(d)) errors.push(`check ${u.id}: unknown dep '${d}'`)
247
+ }
248
+ // `--only` and `--with` address a check by the name an operator last saw
249
+ // it under: its id, its title, or the file a probe lives in
250
+ const byName = new Map<string, string>()
251
+ for (const u of o.checks) {
252
+ byName.set(u.id.toLowerCase(), u.id)
253
+ byName.set(u.title.toLowerCase(), u.id)
254
+ if (u.owner !== '') byName.set(u.owner.toLowerCase(), u.id)
255
+ }
256
+ // `--only` also takes a GROUP, every check declaring that `group`, so a
257
+ // family of checks runs without a hand kept list. A check name wins over
258
+ // a group of the same spelling
259
+ const byGroup = new Map<string, string[]>()
260
+ for (const u of o.checks) if (u.group != null) byGroup.set(u.group.toLowerCase(), [...(byGroup.get(u.group.toLowerCase()) ?? []), u.id])
261
+ const resolve = (raw: string, groups: boolean): string[] | null => {
262
+ const id = byName.get(raw.toLowerCase())
263
+ if (id != null) return [id]
264
+ return groups ? byGroup.get(raw.toLowerCase()) ?? null : null
265
+ }
266
+ const named = new Set<string>()
267
+ const onlyIds: string[] = []
268
+ for (const [raw, groups] of [...(o.onlyIds ?? []).map((r) => [r, true] as const), ...(o.withIds ?? []).map((r) => [r, false] as const)]) {
269
+ const ids = resolve(raw, groups)
270
+ if (ids == null) errors.push(`unknown check id${groups ? ' or group' : ''} '${raw}'`)
271
+ else for (const id of ids) { named.add(id); if (groups) onlyIds.push(id) }
272
+ }
273
+ // a dep cycle must die loud at select time, not hang the loop
274
+ const state = new Map<string, 0 | 1>()
275
+ const byId = new Map(o.checks.map((u) => [u.id, u]))
276
+ const walk = (id: string, trail: string[]): void => {
277
+ if (state.get(id) === 1) return
278
+ if (state.get(id) === 0) { errors.push(`dep cycle: ${[...trail, id].join(' → ')}`); return }
279
+ state.set(id, 0)
280
+ for (const d of byId.get(id)?.deps ?? []) walk(d, [...trail, id])
281
+ state.set(id, 1)
282
+ }
283
+ for (const u of o.checks) walk(u.id, [])
284
+ if (errors.length > 0) return { selected: [], off: [], parked: [], undeclared: [], errors }
285
+
286
+ const skipGroups = new Set(o.skipGroups ?? [])
287
+ const excluded = new Set(o.exclude ?? [])
288
+ const off: RunnableCheck[] = []
289
+ const parked: { id: string; text: string }[] = []
290
+ const undeclared: string[] = []
291
+ let selected: RunnableCheck[] = []
292
+ const nonBlocking = new Map<string, RunnableCheck>()
293
+ const take = (u: RunnableCheck): void => {
294
+ // a check that never said whether it blocks is a defect, not a
295
+ // setting, and it is named whether or not this run measures it
296
+ if (u.blocking == null) undeclared.push(u.id)
297
+ selected.push(u)
298
+ }
299
+ for (const u of o.checks) {
300
+ // IS IT THIS RUN'S BUSINESS AT ALL? The health pass never builds (a
301
+ // repo's test suite and its type checker answer "is my change good",
302
+ // which is the gate's question), the TUI's build panel asks for
303
+ // nothing else, and a sub-unit release skips what its own gate ran.
304
+ // A check out of scope here is not even reported as off: it is not part
305
+ // of this question.
306
+ if (!named.has(u.id)) {
307
+ if (o.audience === 'health' && u.build) continue
308
+ if (o.buildOnly === true && !u.build) continue
309
+ if (excluded.has(u.id)) { parked.push({ id: u.id, text: `${u.id} (not part of this release, its own gate covers it)` }); continue }
310
+ }
311
+ // OFF is the one word that means never, past every other flag: a
312
+ // switch a filter can lift is a suggestion
313
+ if (u.when.at === 'off') { off.push(u); continue }
314
+ if (named.has(u.id)) { take(u); continue }
315
+ if (u.group != null && skipGroups.has(u.group)) { parked.push({ id: u.id, text: `${u.id} (--skip ${u.group})` }); continue }
316
+ if (o.audience === 'gate') {
317
+ if (u.when.at === 'health' && o.includeProactive !== true) { parked.push({ id: u.id, text: `${u.id} (health)` }); continue }
318
+ if (u.when.at === 'release' && !o.fullTier) { parked.push({ id: u.id, text: `${u.id} (release)` }); continue }
319
+ }
320
+ if (o.onlyBlocking === true && u.blocking !== true) { if (u.blocking == null) undeclared.push(u.id); nonBlocking.set(u.id, u); continue }
321
+ take(u)
322
+ }
323
+ // a non blocking check cannot change the outcome on its own, but one a
324
+ // selected check waits on decides whether that check may run at all:
325
+ // dropping it read as "dep satisfied", so a suite whose prerequisite only
326
+ // warns ran without it and went red
327
+ for (let i = 0; i < selected.length; i++)
328
+ for (const d of selected[i]!.deps) {
329
+ const dep = nonBlocking.get(d)
330
+ if (dep != null) { nonBlocking.delete(d); selected.push(dep) }
331
+ }
332
+ if (o.onlyIds != null && o.onlyIds.length > 0) {
333
+ const keep = closure(o.checks, onlyIds)
334
+ selected = selected.filter((u) => keep.has(u.id))
335
+ }
336
+ return { selected, off, parked, undeclared, errors }
337
+ }
338
+
339
+ /** THIS run's log dir. It used to be `logDir` itself, wiped at the top of
340
+ * every run: a second agent starting a gate deleted the log files the first
341
+ * was still streaming into, so the surviving FAIL line pointed at a path
342
+ * that no longer existed. Now each run owns `run-<pid>-<started>/`.
343
+ *
344
+ * Old run dirs are swept on the way in, and only ones nothing can be using:
345
+ * the pid must not be running AND the dir must be older than the grace
346
+ * window, so a recycled pid cannot cost a live run its logs. */
347
+ function openRunDir(base: string): string {
348
+ // the pid plus the millisecond separates two PROCESSES; the counter
349
+ // separates two runs inside one (the TUI can start a second before the
350
+ // clock ticks), so no run ever writes into another's dir
351
+ const dir = join(base, `run-${process.pid}-${Date.now()}-${runSeq++}`)
352
+ mkdirSync(dir, { recursive: true })
353
+ const cutoff = Date.now() - RUN_DIR_KEEP_MS
354
+ try {
355
+ for (const name of readdirSync(base)) {
356
+ // run dirs AND the per-run release survey files, which a killed
357
+ // run leaves behind: both carry the pid and the start time, so one
358
+ // sweep covers both rather than one path leaking forever
359
+ const m = /^(?:run|releases)-(\d+)-(\d+)(?:-\d+)?(?:\.json(?:\.lock)?)?$/.exec(name)
360
+ if (m == null || name === basename(dir)) continue
361
+ if (Number(m[2]) > cutoff) continue
362
+ try { process.kill(Number(m[1]), 0); continue } catch (e) { if ((e as { code?: string }).code === 'EPERM') continue }
363
+ rmSync(join(base, name), { recursive: true, force: true })
364
+ }
365
+ } catch {
366
+ // an unreadable log base is a housekeeping miss, never a gate failure
367
+ }
368
+ return dir
369
+ }
370
+
371
+ /** how long a finished run's logs stay readable, and the pid-reuse guard */
372
+ const RUN_DIR_KEEP_MS = 6 * 60 * 60 * 1000
373
+ let runSeq = 0
374
+
375
+ type Live = RunnableCheck & CheckResult & {
376
+ startedAt: number
377
+ /** when its body stopped, stamped BEFORE its slot goes back: a row must
378
+ * never outlast the slot, or another process starting on it overlaps */
379
+ endedAt: number
380
+ /** the body never reached its subject, so there is no answer to remember */
381
+ unmeasured: boolean
382
+ /** its answer came from another run measuring the same key */
383
+ attached: boolean
384
+ /** what an in process body read, null when it reached the world or ran
385
+ * as a subprocess */
386
+ reads: ReadSet | null
387
+ /** it printed nothing of its own, so its one line is the headline below */
388
+ synthetic: boolean
389
+ /** what the headline carries after the id: a failure's last line, or the
390
+ * reason it never ran */
391
+ tail: string
392
+ }
393
+
394
+ export async function runChecks(o: RunChecksOpts): Promise<RunChecksSummary> {
395
+ const print = o.print ?? ((l: string) => console.log(l))
396
+ const color = o.color ?? (process.stdout.isTTY && process.env.NO_COLOR == null)
397
+ const emit = (l: Line): void => print(formatLine(l, color))
398
+ const sel = selectChecks(o)
399
+ const advisoryFailures: string[] = []
400
+ if (sel.errors.length > 0) {
401
+ for (const e of sel.errors) emit({ kind: 'fail', text: e })
402
+ return { ok: false, interrupted: false, results: [], parked: [], advisoryFailures, undeclared: [], errors: sel.errors }
403
+ }
404
+ // ONE pass for every body of this call: its scans run once, and its file
405
+ // list is the ledger's, so a listing and its validation read one snapshot
406
+ const pass = new CheckPass(o.root, o.config, () => o.ledger?.view() ?? RepoView.open(o.root))
407
+ const results: CheckResult[] = []
408
+ const report = (u: RunnableCheck, r: CheckResult, at?: number, attached = false): void => {
409
+ results.push(r)
410
+ o.onCheck?.({
411
+ id: u.id, name: u.title, status: r.status, ms: r.ms, replayed: r.replayed,
412
+ blocking: u.blocking, advisory: r.advisory, lines: r.lines, problems: r.problems, logPath: r.logPath, lastLine: r.lastLine, note: r.note, attached,
413
+ })
414
+ // an attached check is filed as a replay: it measured nothing, and a
415
+ // row carrying its wait would read as a check running in this process
416
+ o.log?.add({ id: u.id, status: r.status, ms: r.ms, replayed: r.replayed || attached, lines: r.lines, at })
417
+ }
418
+
419
+ // OFF, in ONE place: skipped by name, never run, never stored, and the
420
+ // reason printed where the verdict would have been
421
+ for (const u of sel.off) {
422
+ const line: Line = { kind: 'skip', text: offLine(u.id, u.when) }
423
+ emit(line)
424
+ report(u, { id: u.id, status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [line], problems: [], lastLine: line.text, note: 'off', logPath: null })
425
+ }
426
+ // `--only` that leaves nothing to run asked a question nobody answers, and
427
+ // a green over zero checks reads exactly like a clean repo
428
+ if ((o.onlyIds ?? []).length > 0 && sel.selected.length === 0) {
429
+ const text = `--only ${o.onlyIds!.join(', ')} leaves nothing to run${sel.off.length > 0 ? `: ${sel.off.map((u) => u.id).join(', ')} is off` : ''}`
430
+ emit({ kind: 'fail', text })
431
+ return { ok: false, interrupted: false, results, parked: [], advisoryFailures, undeclared: [], errors: [text] }
432
+ }
433
+
434
+ const runDir = openRunDir(o.logDir)
435
+ const checks: Live[] = sel.selected.map((u) => ({
436
+ ...u,
437
+ status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [], problems: [], lastLine: '', note: '', startedAt: 0, endedAt: 0, unmeasured: false, attached: false, reads: null, synthetic: false, tail: '',
438
+ logPath: u.body.cmd != null ? join(runDir, `${u.id.replace(/[^a-zA-Z0-9-]/g, '_')}.log`) : null,
439
+ }))
440
+ const selectedIds = new Set(checks.map((u) => u.id))
441
+ const byId = new Map(checks.map((u) => [u.id, u]))
442
+ const queued = new Set(checks.map((u) => u.id))
443
+ const done = new Map<string, Live>()
444
+ const running = new Map<string, Promise<void>>()
445
+ const cap = o.maxParallel ?? parallelCap()
446
+ const pool = o.pool === undefined ? machinePool() : o.pool
447
+ let slotWaitSaid = false
448
+ let interrupted = false
449
+ let bailed = false
450
+ const procs = new Map<string, ReturnType<typeof Bun.spawn>>()
451
+ const killed = new Map<string, string>()
452
+ const killAll = (reason: string): void => {
453
+ for (const [id, proc] of procs) {
454
+ killed.set(id, reason)
455
+ const tree = killTree(proc, 'SIGTERM')
456
+ setTimeout(() => { killTree(proc, 'SIGKILL', tree) }, 2000)
457
+ }
458
+ }
459
+
460
+ const depState = (u: Live): 'ready' | 'blocked' | 'dead' => {
461
+ for (const d of u.deps) {
462
+ if (!selectedIds.has(d)) continue // filtered dep = satisfied
463
+ const dep = done.get(d)
464
+ if (dep == null) return 'blocked'
465
+ if (dep.status !== 'pass') { u.note = `dep ${d} ${dep.status}`; return 'dead' }
466
+ }
467
+ return 'ready'
468
+ }
469
+
470
+ /** what a check is remembered under while nothing on disk describes it: a
471
+ * TTL says how long the WORLD keeps, never that the probe was rewritten
472
+ * or that a different shipkit measured it. */
473
+ const worldKey = (u: RunnableCheck): string => {
474
+ let code = ''
475
+ try { code = o.ledger?.layer.keyFor(u.id).codeKey ?? '' } catch { code = '' }
476
+ // a check that writes answers a different question when frozen
477
+ const frozen = u.writes === true ? `\0frozen=${process.env['SHIPKIT_FROZEN'] === '1'}` : ''
478
+ return `tc=${o.ledger?.layer.toolchainId ?? ''}\0code=${code}\0${u.rotation ?? ''}${frozen}`
479
+ }
480
+
481
+ // a non-blocking failure becomes a WARNING here, at the source: printing
482
+ // FAIL and exiting 0 would be the tool lying about its own severity
483
+ const demote = (u: Live): void => {
484
+ if (u.blocking !== false || u.status !== 'fail') return
485
+ u.advisory = true
486
+ if (!advisoryFailures.includes(u.id)) advisoryFailures.push(u.id)
487
+ u.lines = u.lines.map((l) => (l.kind === 'fail' ? { ...l, kind: 'warn' as const } : l.kind === 'fix-fail' ? { ...l, kind: 'fix-warn' as const } : l))
488
+ u.problems = u.problems.map((p) => ({ ...p, severity: 'warning' as const }))
489
+ }
490
+
491
+ /** what this check contributes to the problems pane, from what it printed */
492
+ const feeds = (u: Live): boolean => feedsProblemsPane(u.population ?? (u.ttlMs > 0 ? 'world' : 'neither'))
493
+ const findings = (u: Live, lines: Line[], output?: string): Problem[] => {
494
+ if (!feeds(u)) return []
495
+ return extractProblems({ lines, output }, { root: o.root, cwd: u.body.cwd ?? o.root, check: u.id })
496
+ }
497
+
498
+ const finish = (u: Live): void => {
499
+ const finishedAt = u.endedAt > 0 ? u.endedAt : clock()
500
+ u.ms = u.replayed || u.startedAt === 0 ? 0 : finishedAt - u.startedAt
501
+ done.set(u.id, u)
502
+ if (u.lines.length === 0) headlineOf(u)
503
+ demote(u)
504
+ // the duration is rendered at PRINT time and never stored: a wall time
505
+ // baked into a remembered line comes back on a replay as if this run
506
+ // had measured it (ledger/ledger.ts rule 5)
507
+ const head = u.lines[0]
508
+ const shown = u.synthetic && !u.replayed && head != null
509
+ ? [{ ...head, text: `${u.id} (${(u.ms / 1000).toFixed(1)}s)${u.tail}` }, ...u.lines.slice(1)]
510
+ : u.lines
511
+ for (const l of shown) emit(l)
512
+ if (u.status === 'fail' && !u.advisory && u.logPath != null)
513
+ emit({ kind: 'fix-fail', text: `full log: ${u.logPath}` })
514
+ report(u, { id: u.id, status: u.status, advisory: u.advisory, replayed: u.replayed, ms: u.ms, exitCode: u.exitCode, lines: u.lines, problems: u.problems, lastLine: u.lastLine, note: u.note, logPath: u.logPath }, finishedAt, u.attached)
515
+ if (u.status === 'fail' && !u.advisory && o.bail === true && !bailed) { bailed = true; killAll('bail') }
516
+ }
517
+
518
+ /** a stored answer, re-emitted without running anything */
519
+ const replay = (u: Live): boolean => {
520
+ const hit = o.ledger?.replay(u.id)
521
+ if (hit != null) {
522
+ u.replayed = true
523
+ u.lines = stampCached(hit.lines, hit.measuredMs)
524
+ u.lastLine = hit.lastLine ?? ''
525
+ u.status = hit.status
526
+ // a replay stands for the verdict it replays, findings included:
527
+ // the inputs have not moved, so what was wrong is still wrong
528
+ u.problems = !feeds(u) ? [] : hit.problems ?? findings(u, hit.lines)
529
+ return true
530
+ }
531
+ if (o.cache == null || u.ttlMs <= 0) return false
532
+ const stored = replayWorld(o.cache, u.id, worldKey(u), u.ttlMs)
533
+ if (stored == null) return false
534
+ u.replayed = true
535
+ u.lines = stored
536
+ u.problems = findings(u, stored)
537
+ // a replayed WORLD negative keeps its severity: it is still the
538
+ // answer, and demoting it to a pass would hide a real finding
539
+ u.status = stored.some((l) => l.kind === 'warn' || l.kind === 'fail') ? 'warn' : 'pass'
540
+ return true
541
+ }
542
+
543
+ const measure = async (u: Live): Promise<void> => {
544
+ const t = new CheckCtx(pass)
545
+ if (u.body.run != null) {
546
+ // check.ts owns the semantics: a throwing body still fails with its
547
+ // id, every line is tagged, and a failure carries a fix pointer
548
+ await runCheckBody({ id: u.id, title: u.title, owner: u.owner, run: u.body.run, fix: u.fix }, t)
549
+ u.reads = t.untracked == null ? t.reads.snapshot() : null
550
+ u.lines = t.lines
551
+ u.problems = findings(u, t.lines)
552
+ u.unmeasured = t.unmeasured
553
+ const last = t.lines.filter((l) => l.kind === 'fail').at(-1) ?? t.lines.at(-1)
554
+ u.lastLine = last?.text.slice(0, 200) ?? ''
555
+ u.status = t.failed ? 'fail' : t.warned ? 'warn' : 'pass'
556
+ u.exitCode = t.failed ? 1 : 0
557
+ return
558
+ }
559
+ const cmd = u.body.cmd!
560
+ // the log opens only once the child actually spawned (a missing binary
561
+ // must not leave a dangling open on the log dir), and every write is
562
+ // synchronous: when measure returns the whole log is on disk and no
563
+ // open is still pending against a dir the caller may remove next
564
+ let fd: number | null = null
565
+ let logProblem: string | null = null
566
+ const logWrite = (text: string): void => {
567
+ if (fd == null) return
568
+ try { writeSync(fd, text) } catch (e) { logProblem ??= (e as Error).message; try { closeSync(fd) } catch { /* already gone */ } fd = null }
569
+ }
570
+ const r = await spawnWithTimeout(cmd, {
571
+ timeoutMs: u.timeoutMs,
572
+ // a child runs on this check's slot and takes none of its own
573
+ env: { ...process.env, ...o.childEnv, ...u.body.env, [SLOT_HELD_ENV]: '1', [CHECK_ID_ENV]: u.id },
574
+ cwd: u.body.cwd ?? o.root,
575
+ onSpawn: (proc) => {
576
+ try { fd = openSync(u.logPath!, 'a') } catch (e) { logProblem = (e as Error).message }
577
+ procs.set(u.id, proc)
578
+ },
579
+ onChunk: (text) => {
580
+ logWrite(text)
581
+ for (const raw of text.split(/\r?\n/)) {
582
+ const s = stripAnsi(raw).trim()
583
+ if (s !== '') u.lastLine = s.slice(0, 200)
584
+ }
585
+ },
586
+ })
587
+ procs.delete(u.id)
588
+ // TS narrows `fd` to its initializer across the await (the assignment
589
+ // happens in the onSpawn closure), so widen explicitly
590
+ const open = fd as number | null
591
+ if (open != null) try { closeSync(open) } catch { /* the write path already reported it */ }
592
+ if (r.spawnFailed) { u.lastLine = r.out; u.status = 'fail'; u.exitCode = -1; return }
593
+ const killReason = killed.get(u.id)
594
+ if (killReason != null) { u.note = `killed (${killReason})`; u.status = 'skip'; u.exitCode = r.code; return }
595
+ // a check REPORTS, in wire lines. A build step runs a TOOL, whose
596
+ // output is a log rather than a report, so its verdict is the one
597
+ // line the headline draws and the rest is in the file
598
+ u.lines = u.build ? [] : parseWireOutput(r.out)
599
+ if (logProblem != null) u.lines.push({ kind: 'warn', text: `${u.id}: its log ${u.logPath} was not written: ${logProblem}` })
600
+ u.exitCode = r.code
601
+ const failed = u.lines.some((l) => l.kind === 'fail')
602
+ if (r.timedOut) {
603
+ const tail = u.lastLine !== '' ? ` — ${u.lastLine}` : ''
604
+ u.lastLine = `timeout: ${u.id} killed at its ${(u.timeoutMs / 1000).toFixed(1)}s ceiling, raise its timeoutMs${tail}`
605
+ }
606
+ // exit code is truth: a non-zero exit with no FAIL line (a crash, non
607
+ // wire output, a timeout) must still fail loud. A BUILD step says it
608
+ // in its headline instead, which is the line that carries the wall
609
+ // time and the log path
610
+ if (r.code !== 0 && !failed && !u.build) {
611
+ u.lines.push({ kind: 'fail', text: `${u.id} exited ${r.code}${r.timedOut ? ` (timeout after ${u.timeoutMs}ms)` : ''}${u.lastLine === '' ? '' : ` — ${u.lastLine.slice(0, 160)}`}` })
612
+ if (u.pointer != null) u.lines.push({ kind: 'fix-fail', text: u.pointer })
613
+ }
614
+ u.status = r.code !== 0 || failed ? 'fail' : u.lines.some((l) => l.kind === 'warn' || l.kind === 'fix-warn') ? 'warn' : 'pass'
615
+ u.problems = findings(u, u.lines, r.out)
616
+ const lastFail = u.lines.filter((l) => l.kind === 'fail').at(-1)
617
+ if (lastFail != null) u.lastLine = lastFail.text.slice(0, 200)
618
+ }
619
+
620
+ /** a check that printed nothing of its own still has to report that it
621
+ * ran: the verdict, its name, and (at print time) what it cost */
622
+ const headlineOf = (u: Live): void => {
623
+ u.synthetic = true
624
+ u.tail = u.status === 'fail' ? (u.lastLine === '' ? '' : ` — ${u.lastLine}`) : u.status === 'skip' ? (u.note === '' ? '' : ` (${u.note})`) : ''
625
+ u.lines = [{ kind: u.status === 'fail' ? 'fail' : u.status === 'skip' ? 'skip' : 'ok', text: `${u.id}${u.tail}` }]
626
+ }
627
+
628
+ /** what an attached asker adopts: the verdict and the lines BEFORE this
629
+ * run's own demotion, which the asker applies from its own declaration */
630
+ type Answer = Pick<Live, 'status' | 'lines' | 'problems' | 'lastLine' | 'exitCode' | 'logPath' | 'synthetic' | 'tail'> & { measuredMs: number }
631
+ const answerOf = (u: Live): Answer => ({
632
+ status: u.status, lines: u.lines, problems: u.problems, lastLine: u.lastLine, exitCode: u.exitCode, logPath: u.logPath,
633
+ synthetic: u.synthetic, tail: u.tail, measuredMs: (u.endedAt > 0 ? u.endedAt : clock()) - u.startedAt,
634
+ })
635
+ // only a GREEN is ever stored. A pass is one whatever the check blocks. A
636
+ // WARN from a check that cannot block is not: it may be a red about the
637
+ // world, and a content key has no TTL, so storing it would pin it until an
638
+ // unrelated edit moved the tree
639
+ const storable = (u: Live): u is Live & { status: 'pass' | 'warn' } => u.status === 'pass' || (u.status === 'warn' && u.blocking !== false)
640
+
641
+ const adopt = (u: Live, a: Answer, pid: number): void => {
642
+ const stamp = ` ${attachedStamp(pid, a.measuredMs)}`
643
+ u.status = a.status
644
+ u.lastLine = a.lastLine
645
+ u.exitCode = a.exitCode
646
+ // the full log lives in the run that measured
647
+ u.logPath = a.logPath
648
+ u.attached = true
649
+ const head = a.lines[0]
650
+ u.lines = head == null ? [] : [{ ...head, text: `${head.text}${stamp}` }, ...a.lines.slice(1)]
651
+ u.problems = feeds(u) ? a.problems ?? [] : []
652
+ if (o.ledger != null && storable(u))
653
+ o.ledger.record(u.id, { status: u.status, lines: a.lines, lastLine: a.lastLine, problems: u.problems, measuredMs: a.measuredMs })
654
+ }
655
+
656
+ /** ONE measurement per in flight question: an asker whose exact key
657
+ * another run is measuring right now waits for that answer instead of
658
+ * running the tool a second time (core/attach.ts) */
659
+ const measureShared = async (u: Live): Promise<void> => {
660
+ const slot = o.ledger?.attachSlot(u.id) ?? null
661
+ if (slot == null) return await measureAndKeep(u)
662
+ const r = await shareRun<Answer>(slot, {
663
+ askedAt: Date.now(),
664
+ label: u.id,
665
+ print,
666
+ // past the holder's own ceiling, so a live holder always answers first
667
+ maxWaitMs: u.timeoutMs + 30_000,
668
+ abort: () => interrupted || bailed,
669
+ }, async () => {
670
+ await measureAndKeep(u)
671
+ return { value: answerOf(u), share: !killed.has(u.id) && !u.unmeasured }
672
+ })
673
+ if (r.kind === 'attached') { o.ledger?.noteAttached(); adopt(u, r.value, r.pid) }
674
+ else if (r.kind === 'aborted') { u.status = 'skip'; u.note = bailed ? 'bailed' : 'interrupted'; u.lines = [] }
675
+ else if (r.kind === 'overlapped') emit({ kind: 'warn', text: `${u.id} ran again beside another run: ${r.warn}` })
676
+ }
677
+
678
+ const measureAndKeep = async (u: Live): Promise<void> => {
679
+ // a machine slot for the body, and only the body: a replay or an
680
+ // attached wait runs nothing, so it holds none
681
+ const slot = pool == null ? null : await takeSlot(pool, { repo: o.config.name, check: u.id }, {
682
+ abort: () => interrupted || bailed,
683
+ onWait: (held) => {
684
+ if (slotWaitSaid) return
685
+ slotWaitSaid = true
686
+ const who = held.slice(0, 3).map((h) => `${h.repo}/${h.check} pid ${h.pid}`).join(', ')
687
+ emit({ kind: 'ok', text: `waiting for a machine slot: ${held.length} of ${pool.size} held${who === '' ? '' : ` (${who}${held.length > 3 ? ', …' : ''})`}` })
688
+ },
689
+ })
690
+ if (pool != null && slot == null) { u.status = 'skip'; u.note = bailed ? 'bailed' : 'interrupted'; u.lines = []; u.unmeasured = true; return }
691
+ // its time starts when it runs, so a row never counts the queue
692
+ u.startedAt = clock()
693
+ try { await measure(u) } finally { u.endedAt = clock(); slot?.release() }
694
+ // the headline is built BEFORE anything is remembered: a check
695
+ // whose result is stored empty replays as a line nobody can tell
696
+ // from a measurement
697
+ if (u.lines.length === 0) headlineOf(u)
698
+ // what may be remembered, and for how long, is ONE policy
699
+ // (ledger/world-cache.ts): a clean answer keeps for the declared
700
+ // TTL, a measured negative only long enough to stop a re-ask storm
701
+ if (o.cache != null && u.ttlMs > 0) {
702
+ const clean = u.status === 'pass'
703
+ u.lines = rememberWorld(o.cache, u.id, worldKey(u), u.ttlMs, {
704
+ lines: u.lines,
705
+ clean,
706
+ unmeasured: u.unmeasured || u.lines.some((l) => l.kind === 'skip'),
707
+ keepNegative: u.blocking === false,
708
+ })
709
+ }
710
+ if (o.ledger != null && storable(u))
711
+ o.ledger.record(u.id, { status: u.status, lines: u.lines, lastLine: u.lastLine, problems: u.problems, measuredMs: clock() - u.startedAt }, u.reads)
712
+ }
713
+
714
+ const start = (u: Live): void => {
715
+ queued.delete(u.id)
716
+ u.startedAt = clock()
717
+ const p = (async (): Promise<void> => {
718
+ if (replay(u)) return finish(u)
719
+ await measureShared(u)
720
+ finish(u)
721
+ })().finally(() => running.delete(u.id))
722
+ running.set(u.id, p)
723
+ }
724
+
725
+ // what is running, what finished and what waits on what, for the surfaces
726
+ // that show a run in flight. Written once per turn of the loop, after the
727
+ // starts, and once more marked ended when the runner returns
728
+ const snapshot = (ended: number | null) => liveStateOf({
729
+ cap,
730
+ total: checks.length,
731
+ finished: done,
732
+ running: running.keys(),
733
+ startedAt: (id) => byId.get(id)?.startedAt ?? 0,
734
+ queued,
735
+ depsOf: (id) => byId.get(id)?.deps ?? [],
736
+ selected: selectedIds,
737
+ ended,
738
+ })
739
+
740
+ const onSigint = (): void => { interrupted = true; killAll('interrupt') }
741
+ process.on('SIGINT', onSigint)
742
+ try {
743
+ while (queued.size > 0 || running.size > 0) {
744
+ if (interrupted || bailed) {
745
+ for (const id of [...queued]) {
746
+ const u = byId.get(id)!
747
+ u.note = bailed ? 'bailed' : 'interrupted'
748
+ queued.delete(id)
749
+ done.set(id, u)
750
+ }
751
+ await Promise.all(running.values())
752
+ break
753
+ }
754
+ let started = false
755
+ for (const id of [...queued]) {
756
+ if (running.size >= cap) break
757
+ const u = byId.get(id)!
758
+ const st = depState(u)
759
+ if (st === 'dead') { queued.delete(id); finish(u); started = true }
760
+ else if (st === 'ready') { start(u); started = true }
761
+ }
762
+ o.log?.live(snapshot(null))
763
+ if (running.size > 0) await Promise.race(running.values())
764
+ else if (!started && queued.size > 0) {
765
+ // nothing running, nothing startable: impossible unless a bug
766
+ for (const id of [...queued]) emit({ kind: 'fail', text: `${id}: scheduler stuck (unsatisfiable deps?)` })
767
+ break
768
+ }
769
+ }
770
+ } finally {
771
+ process.off('SIGINT', onSigint)
772
+ // the runner is the one scope that knows the run is over, a throw
773
+ // included: the row says so, and the flush deletes it
774
+ o.log?.live(snapshot(Date.now()))
775
+ }
776
+
777
+ // bail and interrupt leftovers never finished: they are visible, never a
778
+ // fake pass
779
+ for (const u of checks) if (!results.some((r) => r.id === u.id)) report(u, { id: u.id, status: 'skip', advisory: false, replayed: false, ms: 0, exitCode: null, lines: [], problems: [], lastLine: u.lastLine, note: u.note, logPath: u.logPath })
780
+ const order = new Map(o.checks.map((u, i) => [u.id, i]))
781
+ results.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0))
782
+ const blocked = results.filter((r) => r.status === 'fail' && !r.advisory)
783
+ const skipped = results.filter((r) => r.status === 'skip' && r.note !== '' && r.note !== 'off')
784
+ if (blocked.length > 0) emit({ kind: 'fail', text: `${blocked.length}/${results.length} failed: ${blocked.map((r) => r.id).join(', ')}` })
785
+ if (skipped.length > 0) emit({ kind: 'note', text: `skip: ${skipped.map((r) => `${r.id} (${r.note})`).join(', ')}` })
786
+ if (blocked.length === 0 && !interrupted && results.length > 0) emit({ kind: 'ok', text: `all ${results.filter((r) => r.status !== 'skip').length} check(s) green` })
787
+ return {
788
+ ok: blocked.length === 0 && !interrupted,
789
+ interrupted,
790
+ results,
791
+ parked: sel.parked.map((p) => p.text),
792
+ advisoryFailures,
793
+ undeclared: sel.undeclared,
794
+ errors: [],
795
+ }
796
+ }