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,226 @@
1
+ // the project gutter's state: every repo on this disk that shares the house
2
+ // wiki, polled from /api/projects.json while the gutter is mounted. The
3
+ // server measures at most once per 5s whatever the number of tabs, so this
4
+ // poll is cheap.
5
+ import { makeAutoObservable, observableRef, runInAction } from 'mobx'
6
+ import type { ProjectRow as Project, Release } from '../project-gutter-route.ts'
7
+ import type { HubSt } from './hub-st.ts'
8
+
9
+ export type { ProjectRow as Project, Release } from '../project-gutter-route.ts'
10
+
11
+ export const PROJECTS_POLL_MS = 5_000
12
+
13
+ /** one of six kind hues, hashed from the name, so an initial keeps its
14
+ * colour from one load to the next */
15
+ export const hueOf = (name: string): number => {
16
+ let h = 0
17
+ for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0
18
+ return (h % 6) + 1
19
+ }
20
+
21
+ const plural = (n: number, one: string): string => `${n} ${one}${n === 1 ? '' : 's'}`
22
+
23
+ /** an age in the card's short form: `just now`, `3h ago`, `5d ago` */
24
+ export function shortAgo(at: number, now: number): string {
25
+ const hours = Math.floor((now - at) / 3_600_000)
26
+ if (hours < 1) return 'just now'
27
+ if (hours < 24) return `${hours}h ago`
28
+ return `${Math.floor(hours / 24)}d ago`
29
+ }
30
+
31
+ /** the gap a pointer over tile `index` points at: the gap above the tile in
32
+ * its top half, the one below it in its bottom half. Gap g sits before the
33
+ * tile at index g, gap n after the last one */
34
+ export const dropGap = (index: number, offsetY: number, height: number): number => (offsetY < height / 2 ? index : index + 1)
35
+
36
+ /** true when dropping `from` into `gap` would leave the order as it is */
37
+ export const idleGap = (from: number, gap: number): boolean => gap === from || gap === from + 1
38
+
39
+ /** the names with the one at `from` moved into `gap` */
40
+ export function moveTo(names: string[], from: number, gap: number): string[] {
41
+ if (from < 0 || from >= names.length || idleGap(from, gap)) return names
42
+ const out = names.filter((_, i) => i !== from)
43
+ out.splice(gap > from ? gap - 1 : gap, 0, names[from]!)
44
+ return out
45
+ }
46
+
47
+ /** the names by last activity, newest first, the never active last, ties in
48
+ * their current order */
49
+ export const byActivity = (list: Project[]): string[] =>
50
+ list.map((p, i) => ({ p, i }))
51
+ .sort((a, b) => (b.p.activity ?? -Infinity) - (a.p.activity ?? -Infinity) || a.i - b.i)
52
+ .map(({ p }) => p.name)
53
+
54
+ /** what the icon face shows, and nothing more: at most ONE badge (the open
55
+ * asks, the only fact that needs a human's hands), dim while VS Code does
56
+ * not have the folder open, and a pulsing edge while Claude sessions run */
57
+ export type Face = { badge: number | null; dim: boolean; busy: boolean }
58
+ export const faceOf = (p: Project): Face => ({
59
+ badge: p.asks > 0 ? p.asks : null,
60
+ dim: p.vscode === false,
61
+ busy: p.sessions != null && p.sessions > 0,
62
+ })
63
+
64
+ /** the hover card as data, in three parts: a header that says which repo
65
+ * this is, a NEEDS YOU part that exists only when something waits on a
66
+ * human, and label and value rows. Each part leaves out an unknown fact */
67
+ export type CardModel = {
68
+ head: { name: string; current: boolean; icon: string | null; version: string | null; branch: string | null; description: string | null }
69
+ needs: { asks: { count: number; titles: string[]; more: number } | null; gate: string | null } | null
70
+ facts: { k: 'release' | 'live' | 'activity'; v: string; tone?: 'warn' }[]
71
+ }
72
+
73
+ /** how many ask titles and gate ids the card names before `+N` */
74
+ export const CARD_NAMES = 3
75
+
76
+ export function cardModel(p: Project, now: number): CardModel {
77
+ const branch = p.branch == null ? null : p.dirty != null && p.dirty > 0 ? `${p.branch} · ${p.dirty} uncommitted` : p.branch
78
+ const asks = p.asks > 0 ? { count: p.asks, titles: p.askTitles.slice(0, CARD_NAMES), more: Math.max(0, p.asks - CARD_NAMES) } : null
79
+ const reds = p.gate != null && p.gate !== 'green' ? p.gate.reds : []
80
+ const gate = reds.length === 0 ? null : `gate red: ${reds.slice(0, CARD_NAMES).join(', ')}${reds.length > CARD_NAMES ? ` +${reds.length - CARD_NAMES}` : ''}`
81
+ const facts: CardModel['facts'] = []
82
+ if (p.release === 'never') facts.push({ k: 'release', v: 'never released', tone: 'warn' })
83
+ else if (p.release != null) {
84
+ const parts = [p.release.tag]
85
+ if (p.release.at > 0) parts.push(shortAgo(p.release.at, now))
86
+ parts.push(`${plural(p.release.ahead, 'commit')} since`)
87
+ facts.push({ k: 'release', v: parts.join(' · ') })
88
+ }
89
+ if (p.live != null) facts.push({ k: 'live', v: p.live })
90
+ const act: string[] = []
91
+ if (p.sessions != null && p.sessions > 0) act.push(plural(p.sessions, 'agent'))
92
+ if (p.vscode != null) act.push(p.vscode ? 'VS Code open' : 'VS Code closed')
93
+ if (p.activity != null) act.push(shortAgo(p.activity, now))
94
+ if (act.length > 0) facts.push({ k: 'activity', v: act.join(' · ') })
95
+ return {
96
+ head: { name: p.name, current: p.current, icon: p.icon, version: p.version, branch, description: p.description },
97
+ needs: asks == null && gate == null ? null : { asks, gate },
98
+ facts,
99
+ }
100
+ }
101
+
102
+ export class ProjectsSvc {
103
+ list: Project[] = []
104
+ /** why the last poll or open failed, shown in the gutter's tooltip */
105
+ error = ''
106
+ /** the icon under the pointer and the middle of its row, for the card */
107
+ hover: { name: string; y: number } | null = null
108
+ /** the tile being dragged, and the gap it would land in */
109
+ drag: string | null = null
110
+ gap: number | null = null
111
+ private gen = 0
112
+ /** bumped by every reorder, so a poll that left before it cannot put
113
+ * the old order back */
114
+ private orderGen = 0
115
+
116
+ constructor(private st: HubSt) {
117
+ makeAutoObservable<this, 'st' | 'gen' | 'orderGen'>(this, { st: false, gen: false, orderGen: false, list: observableRef })
118
+ }
119
+
120
+ get dragFrom(): number {
121
+ return this.list.findIndex((p) => p.name === this.drag)
122
+ }
123
+
124
+ /** the gap the drop line shows, or null when the drop would change nothing */
125
+ get shownGap(): number | null {
126
+ if (this.drag == null || this.gap == null || idleGap(this.dragFrom, this.gap)) return null
127
+ return this.gap
128
+ }
129
+
130
+ hoverIn(name: string, y: number): void {
131
+ if (this.drag == null) this.hover = { name, y }
132
+ }
133
+
134
+ hoverOut(name: string): void {
135
+ if (this.hover?.name === name) this.hover = null
136
+ }
137
+
138
+ get hovered(): Project | null {
139
+ return this.hover == null ? null : this.list.find((p) => p.name === this.hover!.name) ?? null
140
+ }
141
+
142
+ dragStart(name: string): void {
143
+ this.hover = null
144
+ this.drag = name
145
+ this.gap = null
146
+ }
147
+
148
+ dragOver(index: number, offsetY: number, height: number): void {
149
+ if (this.drag != null) this.gap = dropGap(index, offsetY, height)
150
+ }
151
+
152
+ dragEnd(): void {
153
+ this.drag = null
154
+ this.gap = null
155
+ }
156
+
157
+ drop(): void {
158
+ const from = this.dragFrom
159
+ const gap = this.gap
160
+ this.dragEnd()
161
+ if (from < 0 || gap == null || idleGap(from, gap)) return
162
+ void this.reorder(moveTo(this.list.map((p) => p.name), from, gap))
163
+ }
164
+
165
+ /** one reorder by last activity; from then on it is the manual order */
166
+ sortByActivity(): void {
167
+ void this.reorder(byActivity(this.list))
168
+ }
169
+
170
+ /** shows the new order at once, then saves it for every hub */
171
+ async reorder(names: string[]): Promise<void> {
172
+ this.orderGen++
173
+ const by = new Map(this.list.map((p) => [p.name, p]))
174
+ this.list = names.map((n) => by.get(n)).filter((p): p is Project => p != null)
175
+ try {
176
+ const r = await fetch('/api/projects/order', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ names }) })
177
+ if (!r.ok) throw new Error(await r.text())
178
+ runInAction(() => { this.error = '' })
179
+ } catch (e) {
180
+ runInAction(() => { this.error = `reorder: ${(e as Error).message}` })
181
+ }
182
+ }
183
+
184
+ /** polls until the returned stop runs. A stopped chain dies at its next
185
+ * await, because the generation it carries is no longer current */
186
+ start(): () => void {
187
+ const gen = ++this.gen
188
+ const tick = async (): Promise<void> => {
189
+ await this.load()
190
+ if (gen === this.gen) timer = setTimeout(() => void tick(), PROJECTS_POLL_MS)
191
+ }
192
+ let timer: ReturnType<typeof setTimeout> | null = null
193
+ void tick()
194
+ return () => {
195
+ this.gen++
196
+ if (timer != null) clearTimeout(timer)
197
+ }
198
+ }
199
+
200
+ async load(): Promise<void> {
201
+ const order = this.orderGen
202
+ try {
203
+ const r = await fetch('/api/projects.json')
204
+ if (!r.ok) throw new Error(`${r.status} ${await r.text()}`)
205
+ const j = (await r.json()) as { projects: Project[] }
206
+ runInAction(() => {
207
+ // a reorder since this left, or a drag in flight: the next poll answers
208
+ if (order !== this.orderGen || this.drag != null) return
209
+ this.list = j.projects
210
+ this.error = ''
211
+ })
212
+ } catch (e) {
213
+ runInAction(() => { this.error = `projects: ${(e as Error).message}` })
214
+ }
215
+ }
216
+
217
+ async open(name: string): Promise<void> {
218
+ try {
219
+ const r = await fetch('/api/projects/open', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name }) })
220
+ if (!r.ok) throw new Error(await r.text())
221
+ runInAction(() => { this.error = '' })
222
+ } catch (e) {
223
+ runInAction(() => { this.error = `open ${name}: ${(e as Error).message}` })
224
+ }
225
+ }
226
+ }
@@ -0,0 +1,124 @@
1
+ // The SK014 page's own state: the hits the server grouped, the filters over
2
+ // them, and the word list form. Component scoped (it lives while the page is
3
+ // open), and every write goes through the ONE config writer the run when
4
+ // switch uses (checks-svc.ts writeGate), so a word list edit is refused for
5
+ // the same reasons a switch is.
6
+ import { makeAutoObservable, observableRef, runInAction } from 'mobx'
7
+ import type { ProseGroup, ProseView } from '../../prose/prose-view.ts'
8
+ import type { HubSt } from './hub-st.ts'
9
+
10
+ export type TierFilter = 'all' | 'blocking' | 'soft'
11
+ export type ListKind = 'allow' | 'ban'
12
+
13
+ /** how many groups the page draws before a "show every word" button */
14
+ export const GROUP_CAP = 40
15
+ /** how many hits one open group draws before its own "show all" */
16
+ export const HIT_CAP = 60
17
+
18
+ export class ProseHitsSt {
19
+ view: ProseView | null = null
20
+ error = ''
21
+ loading = false
22
+ word = ''
23
+ file = ''
24
+ tier: TierFilter = 'all'
25
+ allGroups = false
26
+ /** group keys the reader opened */
27
+ open = new Set<string>()
28
+ /** group keys whose every hit is drawn */
29
+ full = new Set<string>()
30
+ form: { kind: ListKind; word: string; to: string; reason: string } = { kind: 'allow', word: '', to: '', reason: '' }
31
+
32
+ constructor(private st: HubSt) {
33
+ // .ref: the view is swapped whole on every load, never edited in place
34
+ makeAutoObservable<this, 'st'>(this, { st: false, view: observableRef })
35
+ }
36
+
37
+ async load(): Promise<void> {
38
+ runInAction(() => { this.loading = true })
39
+ try {
40
+ const r = await fetch('/api/prose.json')
41
+ if (!r.ok) throw new Error(await r.text())
42
+ const v = (await r.json()) as ProseView
43
+ runInAction(() => { this.view = v; this.error = '' })
44
+ } catch (e) {
45
+ runInAction(() => { this.error = `the hits could not be read: ${(e as Error).message}` })
46
+ }
47
+ runInAction(() => { this.loading = false })
48
+ }
49
+
50
+ setWord(v: string): void { this.word = v }
51
+ setFile(v: string): void { this.file = v }
52
+ setTier(v: TierFilter): void { this.tier = v }
53
+ showAllGroups(): void { this.allGroups = true }
54
+ toggle(key: string): void {
55
+ if (this.open.has(key)) this.open.delete(key)
56
+ else this.open.add(key)
57
+ }
58
+ showAllHits(key: string): void { this.full.add(key) }
59
+ setForm<K extends keyof ProseHitsSt['form']>(k: K, v: ProseHitsSt['form'][K]): void {
60
+ this.form = { ...this.form, [k]: v }
61
+ }
62
+
63
+ /** the groups under the filters, each recounted over the hits it keeps,
64
+ * so a file filter reorders the words by their noise IN that file */
65
+ get groups(): ProseGroup[] {
66
+ const v = this.view
67
+ if (v == null) return []
68
+ const w = this.word.trim().toLowerCase()
69
+ const f = this.file.trim().toLowerCase()
70
+ const out: ProseGroup[] = []
71
+ for (const g of v.groups) {
72
+ if (this.tier === 'blocking' && g.tier === 'soft') continue
73
+ if (this.tier === 'soft' && g.tier !== 'soft') continue
74
+ if (w !== '' && !(g.word ?? g.cls).includes(w) && !g.cls.includes(w)) continue
75
+ const hits = f === '' ? g.hits : g.hits.filter((h) => h.file.toLowerCase().includes(f))
76
+ if (hits.length === 0) continue
77
+ out.push(hits === g.hits ? g : { ...g, hits, count: hits.length })
78
+ }
79
+ return out.sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
80
+ }
81
+
82
+ get shownCount(): number {
83
+ return this.groups.reduce((n, g) => n + g.count, 0)
84
+ }
85
+
86
+ /** every file that carries a hit, for the file filter's suggestions */
87
+ get files(): string[] {
88
+ const s = new Set<string>()
89
+ for (const g of this.view?.groups ?? []) for (const h of g.hits) s.add(h.file)
90
+ return [...s].sort()
91
+ }
92
+
93
+ get busy(): boolean { return this.st.checks.gateBusy }
94
+
95
+ /** after any write: the config moved, so the hits are re-read and the
96
+ * rule reruns, and its row recolours on the tree like any other run */
97
+ private async after(ok: boolean): Promise<void> {
98
+ if (!ok) return
99
+ await this.load()
100
+ void this.st.checks.run(['SK014'])
101
+ }
102
+
103
+ async allow(word: string, reason = ''): Promise<void> {
104
+ const ok = await this.st.checks.writeGate({ words: { allow: [{ word, reason }] } }, `'${word}' is allowed in this repo — repo.config.ts is edited and waiting for your commit`)
105
+ await this.after(ok)
106
+ }
107
+
108
+ async submit(): Promise<void> {
109
+ const f = this.form
110
+ if (f.word.trim() === '') return
111
+ const entry = f.kind === 'allow' ? { allow: [{ word: f.word, reason: f.reason }] } : { ban: [{ word: f.word, to: f.to, reason: f.reason }] }
112
+ const said = f.kind === 'allow'
113
+ ? `'${f.word.trim()}' is allowed in this repo — repo.config.ts is edited and waiting for your commit`
114
+ : `'${f.word.trim()}' is refused in this repo — repo.config.ts is edited and waiting for your commit`
115
+ const ok = await this.st.checks.writeGate({ words: entry }, said)
116
+ if (ok) runInAction(() => { this.form = { kind: this.form.kind, word: '', to: '', reason: '' } })
117
+ await this.after(ok)
118
+ }
119
+
120
+ async remove(word: string): Promise<void> {
121
+ const ok = await this.st.checks.writeGate({ words: { remove: [word] } }, `'${word}' is back to the house dictionary — repo.config.ts is edited and waiting for your commit`)
122
+ await this.after(ok)
123
+ }
124
+ }
@@ -0,0 +1,176 @@
1
+ // THE SK014 PAGE's own sections: what the prose check found, grouped by word
2
+ // with the noisiest on top, each hit a file:line link into the editor and the
3
+ // sentence around it with the word marked; then the repo's own word lists,
4
+ // editable in place. The house dictionary stays the default, a list entry
5
+ // overrides it for this repo only.
6
+ import { observer } from 'mobx-react-lite'
7
+ import { useEffect, useMemo } from 'react'
8
+ import type { ProseGroup, ProseViewHit } from '../../prose/prose-view.ts'
9
+ import type { ProseWords } from '../../prose/ste.ts'
10
+ import { editorHref } from './check-words.ts'
11
+ import { Help } from './help.tsx'
12
+ import type { HubSt } from './hub-st.ts'
13
+ import { GROUP_CAP, HIT_CAP, ProseHitsSt, type ListKind, type TierFilter } from './prose-hits-st.ts'
14
+
15
+ /** blocking tiers read red, the soft tier reads as a warning */
16
+ const TIER_WORD: Record<string, { word: string; tone: string }> = {
17
+ fix: { word: 'blocks, --fix rewrites it', tone: 'red' },
18
+ hard: { word: 'blocks', tone: 'red' },
19
+ soft: { word: 'soft', tone: 'warn' },
20
+ }
21
+
22
+ const TIERS: { v: TierFilter; label: string }[] = [
23
+ { v: 'all', label: 'every hit' },
24
+ { v: 'blocking', label: 'what blocks' },
25
+ { v: 'soft', label: 'soft only' },
26
+ ]
27
+
28
+ /** the sentence around a hit, the hit itself marked */
29
+ const Excerpt = ({ h }: { h: ProseViewHit }) => (
30
+ <span className="pexc">
31
+ {h.excerpt.slice(0, h.mark[0])}
32
+ <mark>{h.excerpt.slice(h.mark[0], h.mark[1])}</mark>
33
+ {h.excerpt.slice(h.mark[1])}
34
+ </span>
35
+ )
36
+
37
+ const HitLine = ({ root, h, shape }: { root: string; h: ProseViewHit; shape: boolean }) => {
38
+ const href = editorHref(root, h.file)
39
+ const at = `${h.file}:${h.line}`
40
+ return (
41
+ <div className="phit">
42
+ {href == null ? <code className="cpath">{at}</code> : <a href={`${href}:${h.line}`} title="open it in the editor"><code className="cpath">{at}</code></a>}
43
+ <Excerpt h={h} />
44
+ {/* a shape's reason names its own numbers (31 words, 8 sentences) */}
45
+ {shape ? <span className="dim pwhy">{h.why}</span> : null}
46
+ </div>
47
+ )
48
+ }
49
+
50
+ const Group = observer(({ app, s, g }: { app: HubSt; s: ProseHitsSt; g: ProseGroup }) => {
51
+ const open = s.open.has(g.key)
52
+ const t = TIER_WORD[g.tier] ?? { word: g.tier, tone: '' }
53
+ const shown = s.full.has(g.key) ? g.hits : g.hits.slice(0, HIT_CAP)
54
+ return (
55
+ <div className="pgroup">
56
+ <div className="phead">
57
+ <button className="ptoggle" onClick={() => s.toggle(g.key)} title={open ? 'hide the hits' : 'show the hits'}>{open ? '▾' : '▸'}</button>
58
+ <span className="pcount dnum">{g.count}</span>
59
+ <b className="pword">{g.word ?? g.cls}</b>
60
+ <span className="stchip">{g.cls}</span>
61
+ <span className={`tl ${t.tone}`}>{t.word}</span>
62
+ <span className="dim pwhy">{g.word == null ? null : g.hits[0]?.why}</span>
63
+ <span className="sp" />
64
+ {g.word == null
65
+ ? null
66
+ : <button disabled={s.busy} title="accept this word in this repo: every hit on it goes away" onClick={() => void s.allow(g.word!)}>allow it here</button>}
67
+ </div>
68
+ {open
69
+ ? <div className="phits">
70
+ {shown.map((h, n) => <HitLine key={`${h.file}:${h.line}:${n}`} root={app.root} h={h} shape={g.word == null} />)}
71
+ {shown.length < g.hits.length
72
+ ? <button className="pmore" onClick={() => s.showAllHits(g.key)}>show the other {g.hits.length - shown.length}</button>
73
+ : null}
74
+ </div>
75
+ : null}
76
+ </div>
77
+ )
78
+ })
79
+
80
+ const Hits = observer(({ app, s }: { app: HubSt; s: ProseHitsSt }) => {
81
+ const v = s.view
82
+ if (v == null) return <div className="dim">{s.error !== '' ? s.error : 'reading the hits…'}</div>
83
+ if (!v.scoped) return <div className="dim">this repo declares no prose scope, so the check reads no file: add `prose: {'{'} include: [...] {'}'}` to repo.config.ts</div>
84
+ const groups = s.groups
85
+ const drawn = s.allGroups ? groups : groups.slice(0, GROUP_CAP)
86
+ const blocking = v.byTier.fix + v.byTier.hard
87
+ return (
88
+ <>
89
+ <div className="wline">
90
+ {v.total} hit(s) in {v.files} file(s): <span className={blocking > 0 ? 'tl red' : 'dim'}>{blocking} block</span>, <span className="tl warn">{v.byTier.soft} soft</span>
91
+ {s.loading ? <span className="dim"> (reading again…)</span> : null}
92
+ </div>
93
+ <div className="pfilters">
94
+ <input placeholder="filter by word or rule" value={s.word} onChange={(e) => s.setWord(e.target.value)} />
95
+ <input placeholder="filter by file" list="prose-files" value={s.file} onChange={(e) => s.setFile(e.target.value)} />
96
+ <datalist id="prose-files">{s.files.map((f) => <option key={f} value={f} />)}</datalist>
97
+ <select className="wsel" value={s.tier} onChange={(e) => s.setTier(e.target.value as TierFilter)}>
98
+ {TIERS.map((t) => <option key={t.v} value={t.v}>{t.label}</option>)}
99
+ </select>
100
+ {s.word !== '' || s.file !== '' || s.tier !== 'all' ? <span className="dim">{s.shownCount} shown</span> : null}
101
+ </div>
102
+ {groups.length === 0 ? <div className="dim">no hit matches the filters</div> : null}
103
+ {drawn.map((g) => <Group key={g.key} app={app} s={s} g={g} />)}
104
+ {drawn.length < groups.length
105
+ ? <button className="pmore" onClick={() => s.showAllGroups()}>show the other {groups.length - drawn.length} words and rules</button>
106
+ : null}
107
+ </>
108
+ )
109
+ })
110
+
111
+ const ListRows = ({ s, words }: { s: ProseHitsSt; words: ProseWords }) => {
112
+ const rows = [
113
+ ...(words.allow ?? []).map((a) => ({ kind: 'allow' as ListKind, word: a.word, to: undefined as string | undefined, reason: a.reason })),
114
+ ...(words.ban ?? []).map((b) => ({ kind: 'ban' as ListKind, word: b.word, to: b.to, reason: b.reason })),
115
+ ]
116
+ if (rows.length === 0) return <div className="dim">no entry: the house dictionary decides every word here</div>
117
+ return (
118
+ <div className="plist">
119
+ {rows.map((r) => (
120
+ <div key={`${r.kind}:${r.word}`} className="prow">
121
+ <span className={`stchip ${r.kind === 'allow' ? 'ok' : 'miss'}`}>{r.kind}</span>
122
+ <b>{r.word}</b>
123
+ {r.to == null || r.to === '' ? null : <span>→ {r.to}</span>}
124
+ {r.reason == null || r.reason === '' ? null : <span className="dim">{r.reason}</span>}
125
+ <span className="sp" />
126
+ <button disabled={s.busy} onClick={() => void s.remove(r.word)}>drop it</button>
127
+ </div>
128
+ ))}
129
+ </div>
130
+ )
131
+ }
132
+
133
+ const Lists = observer(({ app, s }: { app: HubSt; s: ProseHitsSt }) => {
134
+ const f = s.form
135
+ const g = app.checks.gate
136
+ return (
137
+ <>
138
+ <ListRows s={s} words={s.view?.words ?? {}} />
139
+ <div className="pform">
140
+ <select className="wsel" value={f.kind} onChange={(e) => s.setForm('kind', e.target.value as ListKind)}>
141
+ <option value="allow">allow</option>
142
+ <option value="ban">ban</option>
143
+ </select>
144
+ <input placeholder={f.kind === 'allow' ? 'word or phrase this repo accepts' : 'word or phrase this repo refuses'} value={f.word} onChange={(e) => s.setForm('word', e.target.value)} />
145
+ {f.kind === 'ban' ? <input placeholder="write this instead" value={f.to} onChange={(e) => s.setForm('to', e.target.value)} /> : null}
146
+ <input placeholder="why (optional, it rides in the config)" value={f.reason} onChange={(e) => s.setForm('reason', e.target.value)} />
147
+ <button disabled={s.busy || f.word.trim() === ''} onClick={() => void s.submit()}>{f.kind === 'allow' ? 'allow it' : 'ban it'}</button>
148
+ </div>
149
+ {g?.wordsState === 'stale' ? <div className="warnline">the words block in {g.path} was hand edited: reconcile it, then the lists work again</div> : null}
150
+ {g != null && g.dirty ? <div className="note">{g.path} is edited and not committed, review it in your git client</div> : null}
151
+ {app.checks.gateNote === '' ? null : <div className="note">{app.checks.gateNote}</div>}
152
+ </>
153
+ )
154
+ })
155
+
156
+ /** the two sections over one state, the part a test renders headless */
157
+ export const ProseSections = observer(({ app, s }: { app: HubSt; s: ProseHitsSt }) => (
158
+ <>
159
+ <section className="csec">
160
+ <div className="cstitle">hits<Help text="every hit of the last read, grouped by the word or the rule that fired, the noisiest first. A file:line opens the editor on that line" /></div>
161
+ <Hits app={app} s={s} />
162
+ </section>
163
+ <section className="csec">
164
+ <div className="cstitle">this repo's words<Help text="allow drops every hit on a word or phrase, ban refuses an extra one and names what to write instead. The house dictionary stays the default. An edit writes repo.config.ts, reruns the check and leaves the file for you to commit" /></div>
165
+ <Lists app={app} s={s} />
166
+ </section>
167
+ </>
168
+ ))
169
+
170
+ /** mounted by the check page for SK014 only: the state lives while the page
171
+ * is open, and reads the hits once on the way in */
172
+ export const ProseHits = observer(({ app }: { app: HubSt }) => {
173
+ const s = useMemo(() => new ProseHitsSt(app), [app])
174
+ useEffect(() => { void s.load() }, [s])
175
+ return <ProseSections app={app} s={s} />
176
+ })
@@ -0,0 +1,21 @@
1
+ /* the SK014 page: word groups, their hits, the repo's word lists (prose-hits-view.tsx) */
2
+ .pfilters{display:flex;flex-wrap:wrap;gap:var(--s2);align-items:center;margin-bottom:var(--s2)}
3
+ .pfilters input,.pform input{background:var(--bg);border:1px solid var(--line);border-radius:4px;color:var(--ink);
4
+ padding:var(--s1) var(--s2);font:inherit;font-size:12.5px}
5
+ .pfilters input:focus,.pform input:focus{border-color:var(--accent);outline:none}
6
+ .pgroup{border-top:1px solid var(--line)}
7
+ .phead{display:flex;gap:var(--s2);align-items:baseline;padding:var(--s1) 0;min-width:0}
8
+ .phead .sp,.prow .sp{flex:1}
9
+ .phead .pwhy{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
10
+ .ptoggle{background:none;border:0;color:var(--dim);cursor:pointer;padding:0;font:inherit}
11
+ .pcount{min-width:3.5em;text-align:right}
12
+ .pword{font-weight:500}
13
+ .phits{padding:0 0 var(--s2) var(--s4)}
14
+ .phit{display:flex;flex-wrap:wrap;gap:var(--s2);align-items:baseline;padding:var(--s0) 0;font-size:12.5px}
15
+ .phit a{text-decoration:none}
16
+ .pexc{color:var(--dim);word-break:break-word}
17
+ .pexc mark{background:var(--hi2);color:var(--ink);border-bottom:1px solid var(--red);padding:0 var(--s0)}
18
+ .pmore{margin:var(--s1) 0}
19
+ .plist{display:flex;flex-direction:column;gap:var(--s1);margin-bottom:var(--s2)}
20
+ .prow{display:flex;gap:var(--s2);align-items:baseline}
21
+ .pform{display:flex;flex-wrap:wrap;gap:var(--s2);align-items:center}
@@ -0,0 +1,47 @@
1
+ // The hub's own run, as a pure reducer over the run stream: the line the
2
+ // header's run state shows. No mobx, no dom: ChecksSvc holds one RunProgress
3
+ // and feeds it the same NDJSON events that colour the rows, so what the header
4
+ // says and what the tree shows can never come from two readings of one run.
5
+ import type { CheckEvent } from './hub-types.ts'
6
+
7
+ export type RunPhase = 'idle' | 'running' | 'done'
8
+
9
+ export type RunProgress = {
10
+ phase: RunPhase
11
+ /** what is running, in the words the button used */
12
+ label: string
13
+ /** how many checks this run expects. 0 = unknown, and the strip says so
14
+ * by dropping the denominator rather than guessing one */
15
+ total: number
16
+ done: number
17
+ /** blocking failures: the only ones that stop anything */
18
+ reds: number
19
+ /** warnings and failures that cannot block */
20
+ warns: number
21
+ code: number | null
22
+ }
23
+
24
+ export const idleRun = (): RunProgress => ({ phase: 'idle', label: '', total: 0, done: 0, reds: 0, warns: 0, code: null })
25
+
26
+ export const startRun = (label: string, total: number): RunProgress => ({ ...idleRun(), phase: 'running', label, total })
27
+
28
+ /** one stream event, applied. Unknown events are no-ops on purpose: the
29
+ * stream gains kinds over time and a strip is not a reason to fail a run */
30
+ export function applyRunEvent(p: RunProgress, e: Record<string, unknown>): RunProgress {
31
+ if (e.t === 'done') return { ...p, phase: 'done', code: typeof e.code === 'number' ? e.code : null }
32
+ if (e.t !== 'check') return p
33
+ const u = e as unknown as CheckEvent
34
+ const red = u.status === 'fail' && u.advisory !== true
35
+ const warn = u.status === 'warn' || (u.status === 'fail' && u.advisory === true)
36
+ return { ...p, done: p.done + 1, reds: p.reds + (red ? 1 : 0), warns: p.warns + (warn ? 1 : 0) }
37
+ }
38
+
39
+ /** the one line, or nothing. Nothing is what an idle page shows */
40
+ export function stripText(p: RunProgress): string {
41
+ if (p.phase === 'idle') return ''
42
+ const head = p.label === '' ? '' : `${p.label} · `
43
+ if (p.phase === 'done')
44
+ return `${head}${p.reds > 0 ? `done, ${p.reds} red` : p.code != null && p.code !== 0 ? `done, exit ${p.code}` : 'done, green'}`
45
+ const of = p.total > 0 ? ` of ${p.total}` : ''
46
+ return `${head}running ${p.done}${of}${p.reds > 0 ? ` · ${p.reds} red so far` : ''}`
47
+ }
@@ -0,0 +1,24 @@
1
+ // THE RUNS PAGE TABS, and the address each one has. Pure, so the router
2
+ // (hash-route.ts) can name the canonical address of a tab without the page.
3
+ import { RUNS_ID } from '../../collections/collection-types.ts'
4
+ import { CACHED, RAN } from '../../core/outcome-words.ts'
5
+
6
+ export type RunsTab = 'runs' | 'files' | 'slowest' | 'longest'
7
+
8
+ /** the four lists, in tab order. `key` is what the address carries */
9
+ export const RUNS_TABS: { key: RunsTab; label: string; help: string }[] = [
10
+ { key: 'runs', label: 'runs', help: `every run on record, newest first: shipkit ci, shipkit check, the hub's buttons, its save trigger and its daily health pass. One line per run: when, who started it, what ran, its wall time, how many checks ${RAN}, how many were ${CACHED} (answered from the cache, nothing ran), and its reds. Unfold a run for one line per check, named by its title with its id as the small tag, in a box that scrolls: its reds first, then what ${RAN}, slowest first, then what was ${CACHED}. Click a check for its page. Every run in time, and how many ran at once, is the live strip under the header: unfold it and press fit` },
11
+ { key: 'files', label: 'test files to fix', help: "bun test files, not checks: each file's last recorded run inside the check that ran it (test, test:<unit>), kept when a test in it failed or when it broke the quick tier budget per file. The same verdict colours the file in the tree under tests. Click a file for its page, the check tag for the check" },
12
+ { key: 'slowest', label: 'slowest checks', help: 'the checks that cost the most when they really run: the mean and the worst of the runs that ran. A check whose worst run passed half its ceiling is flagged, because the next slow machine trips it' },
13
+ { key: 'longest', label: 'longest runs', help: 'the invocations on record that took the longest, start to end, whatever ran them' },
14
+ ]
15
+
16
+ /** the tab an item id asks for. The bare page id, and a tab this build does
17
+ * not name, is the runs tab */
18
+ export function runsTab(id: string | null | undefined): RunsTab {
19
+ const rest = id != null && id.startsWith(`${RUNS_ID}:`) ? id.slice(RUNS_ID.length + 1) : ''
20
+ return RUNS_TABS.find((t) => t.key === rest)?.key ?? 'runs'
21
+ }
22
+
23
+ /** the item id that opens one tab: the runs tab is the bare page id */
24
+ export const runsTabId = (tab: RunsTab): string => (tab === 'runs' ? RUNS_ID : `${RUNS_ID}:${tab}`)