rvlib-shipkit 0.38.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/CHANGELOG.md +367 -16
  2. package/README.md +26 -14
  3. package/package.json +2 -2
  4. package/src/assets/gen/comfyts-gen.ts +27 -10
  5. package/src/assets/gen/generate-roll.ts +1 -1
  6. package/src/assets/kit-config.ts +6 -6
  7. package/src/assets/run-assets.ts +14 -10
  8. package/src/assets/server/studio-server.ts +6 -5
  9. package/src/check.ts +343 -0
  10. package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
  11. package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
  12. package/src/checks/SK003-claude-md-shape.check.ts +66 -0
  13. package/src/checks/SK004-gitignore-policy.check.ts +309 -0
  14. package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
  15. package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
  16. package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
  17. package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
  18. package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
  19. package/src/checks/SK010-undeclared-unit.check.ts +111 -0
  20. package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
  21. package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
  22. package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
  23. package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
  24. package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
  25. package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
  26. package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
  27. package/src/checks/SK018-test-files-run.check.ts +28 -0
  28. package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
  29. package/src/checks/SK020-self-spawning-check.check.ts +147 -0
  30. package/src/checks/contracts.check.ts +20 -0
  31. package/src/cli/cmd_Asks.ts +3 -2
  32. package/src/cli/cmd_Assets.ts +2 -2
  33. package/src/cli/cmd_Bump.ts +1 -1
  34. package/src/cli/cmd_Check.ts +6 -6
  35. package/src/cli/cmd_Checks.ts +95 -0
  36. package/src/cli/cmd_Ci.ts +48 -13
  37. package/src/cli/cmd_Commands.ts +2 -6
  38. package/src/cli/cmd_ConfigKeys.ts +69 -0
  39. package/src/cli/cmd_Contracts.ts +1 -1
  40. package/src/cli/cmd_Fleet.ts +6 -2
  41. package/src/cli/cmd_Hooks.ts +1 -1
  42. package/src/cli/cmd_Keys.ts +43 -18
  43. package/src/cli/cmd_Mv.ts +2 -2
  44. package/src/cli/cmd_Prose.ts +10 -1
  45. package/src/cli/cmd_Releases.ts +2 -2
  46. package/src/cli/cmd_Serve.ts +1 -1
  47. package/src/cli/cmd_Start.ts +6 -6
  48. package/src/cli/cmd_Status.ts +25 -0
  49. package/src/cli/cmd_Tui.ts +2 -2
  50. package/src/cli.ts +7 -4
  51. package/src/collections/actions.ts +4 -4
  52. package/src/collections/checks.ts +258 -0
  53. package/src/collections/collection-types.ts +254 -1
  54. package/src/collections/comfy.ts +23 -0
  55. package/src/collections/md-body.ts +14 -0
  56. package/src/collections/pings.ts +38 -12
  57. package/src/collections/posts.ts +19 -3
  58. package/src/collections/providers.ts +198 -91
  59. package/src/collections/reflections.ts +41 -0
  60. package/src/collections/state-rules.ts +73 -0
  61. package/src/collections/test-time.ts +288 -0
  62. package/src/collections/tests.ts +181 -0
  63. package/src/collections/wikis.ts +66 -0
  64. package/src/comfy/comfy-panel.ts +292 -0
  65. package/src/contracts/apply-contracts.ts +17 -12
  66. package/src/contracts/contract-types.ts +16 -4
  67. package/src/contracts/contracts-engine.ts +78 -42
  68. package/src/contracts/manifests/discord.contract.ts +6 -6
  69. package/src/contracts/manifests/identity.contract.ts +3 -3
  70. package/src/contracts/manifests/itch.contract.ts +12 -15
  71. package/src/contracts/manifests/social.contract.ts +5 -5
  72. package/src/contracts/manifests/steam.contract.ts +16 -16
  73. package/src/contracts/run-contracts.ts +4 -3
  74. package/src/core/attach.ts +96 -0
  75. package/src/core/canonical-scripts.ts +25 -0
  76. package/src/core/check-ctx.ts +204 -14
  77. package/src/core/check-file-names.ts +112 -0
  78. package/src/core/check-pass.ts +84 -0
  79. package/src/core/check-runner.ts +796 -0
  80. package/src/core/config-keys.ts +256 -0
  81. package/src/core/config-rewrite.ts +77 -0
  82. package/src/core/discover-checks.ts +166 -90
  83. package/src/core/discover-legacy-checks.ts +26 -0
  84. package/src/core/doc-block.ts +13 -0
  85. package/src/core/floor-resolve.ts +264 -0
  86. package/src/core/floor.ts +20 -137
  87. package/src/core/git-bin.ts +24 -0
  88. package/src/core/git-root.ts +108 -0
  89. package/src/core/instance.ts +195 -0
  90. package/src/core/jobs.ts +118 -0
  91. package/src/core/layout.ts +52 -0
  92. package/src/core/legacy-shapes.ts +207 -0
  93. package/src/core/lib-root.ts +5 -0
  94. package/src/core/locked-write.ts +110 -39
  95. package/src/core/machine-commit.ts +5 -4
  96. package/src/core/migrate-layout.ts +557 -0
  97. package/src/core/motherhome.ts +2 -8
  98. package/src/core/nesting.ts +4 -12
  99. package/src/core/one-way-in-policy.ts +73 -17
  100. package/src/core/outcome-words.ts +45 -0
  101. package/src/core/package-manager.ts +86 -0
  102. package/src/core/parallel.ts +21 -0
  103. package/src/core/process-tree.ts +44 -0
  104. package/src/core/raw-access.ts +93 -0
  105. package/src/core/read-set.ts +152 -0
  106. package/src/core/run-check.ts +155 -213
  107. package/src/core/run-ci.ts +153 -189
  108. package/src/core/run-fix.ts +96 -0
  109. package/src/core/run-fixers.ts +53 -30
  110. package/src/core/self-update.ts +9 -3
  111. package/src/core/slots.ts +83 -0
  112. package/src/core/spawn-timeout.ts +34 -3
  113. package/src/core/when-block.ts +401 -0
  114. package/src/core/when.ts +135 -0
  115. package/src/core/write-status.ts +5 -2
  116. package/src/hooks/leak-guard.ts +10 -8
  117. package/src/hooks/one-way-in-guard.ts +13 -5
  118. package/src/hooks/root-identity.ts +2 -1
  119. package/src/hooks/run-hooks.ts +35 -23
  120. package/src/init-repo.ts +39 -23
  121. package/src/ledger/check-stats.ts +155 -0
  122. package/src/ledger/check-table.ts +179 -0
  123. package/src/ledger/db.ts +255 -0
  124. package/src/ledger/gate-checks.ts +433 -0
  125. package/src/ledger/git-key.ts +116 -77
  126. package/src/ledger/git-scan.ts +96 -35
  127. package/src/ledger/ledger.ts +116 -42
  128. package/src/ledger/live-runs.ts +111 -0
  129. package/src/ledger/live-timeline.ts +97 -0
  130. package/src/ledger/repo-view.ts +73 -0
  131. package/src/ledger/run-log.ts +448 -0
  132. package/src/ledger/run-timeline.ts +173 -0
  133. package/src/ledger/store.ts +23 -12
  134. package/src/ledger/suite-log.ts +161 -0
  135. package/src/ledger/toolchain-identity.ts +7 -1
  136. package/src/ledger/world-cache.ts +111 -0
  137. package/src/mv/mv-md.ts +16 -6
  138. package/src/problems/extract.ts +182 -0
  139. package/src/problems/problem-board.ts +37 -0
  140. package/src/problems/problem-line.ts +77 -0
  141. package/src/prose/prose-view.ts +90 -0
  142. package/src/prose/run-prose.ts +60 -19
  143. package/src/prose/ste.ts +140 -24
  144. package/src/reflections/reflections-queue.ts +81 -0
  145. package/src/release/git-facts.ts +15 -9
  146. package/src/release/run-bump.ts +6 -5
  147. package/src/release/run-release.ts +25 -11
  148. package/src/release/run-releases.ts +2 -2
  149. package/src/release/unit-gate.ts +7 -54
  150. package/src/release/unit-resolve.ts +282 -0
  151. package/src/release/units.ts +33 -217
  152. package/src/repo-config.ts +155 -85
  153. package/src/rules/rule.ts +36 -96
  154. package/src/rules/untrack-generated.ts +5 -4
  155. package/src/scaffold/files/vscode-tasks.json +10 -31
  156. package/src/scaffold/jsonc-spans.ts +113 -0
  157. package/src/scaffold/project-scaffold.ts +16 -43
  158. package/src/scan.ts +98 -0
  159. package/src/serve/gate-trigger.ts +148 -0
  160. package/src/serve/health-schedule.ts +102 -0
  161. package/src/serve/hub-app/chart-view.ts +179 -0
  162. package/src/serve/hub-app/check-view.tsx +345 -0
  163. package/src/serve/hub-app/check-words.ts +164 -0
  164. package/src/serve/hub-app/checks-svc.ts +280 -0
  165. package/src/serve/hub-app/clock-svc.ts +33 -0
  166. package/src/serve/hub-app/col-card.tsx +23 -39
  167. package/src/serve/hub-app/cols-svc.ts +44 -20
  168. package/src/serve/hub-app/comfy-svc.ts +171 -0
  169. package/src/serve/hub-app/comfy-view.tsx +81 -0
  170. package/src/serve/hub-app/dur.ts +41 -0
  171. package/src/serve/hub-app/hash-route.ts +121 -0
  172. package/src/serve/hub-app/header-bar.tsx +117 -0
  173. package/src/serve/hub-app/health-svc.ts +23 -28
  174. package/src/serve/hub-app/help.tsx +29 -0
  175. package/src/serve/hub-app/hub-app.tsx +122 -119
  176. package/src/serve/hub-app/hub-st.ts +115 -42
  177. package/src/serve/hub-app/hub-types.ts +117 -2
  178. package/src/serve/hub-app/hub.css +534 -192
  179. package/src/serve/hub-app/index.html +2 -0
  180. package/src/serve/hub-app/live-svc.ts +174 -0
  181. package/src/serve/hub-app/md.ts +15 -0
  182. package/src/serve/hub-app/media-svc.ts +4 -1
  183. package/src/serve/hub-app/media-view.tsx +15 -0
  184. package/src/serve/hub-app/next-run.ts +68 -0
  185. package/src/serve/hub-app/project-gutter.css +65 -0
  186. package/src/serve/hub-app/project-gutter.tsx +128 -0
  187. package/src/serve/hub-app/projects-svc.ts +226 -0
  188. package/src/serve/hub-app/prose-hits-st.ts +124 -0
  189. package/src/serve/hub-app/prose-hits-view.tsx +176 -0
  190. package/src/serve/hub-app/prose-hits.css +21 -0
  191. package/src/serve/hub-app/run-strip.ts +47 -0
  192. package/src/serve/hub-app/runs-tabs.ts +24 -0
  193. package/src/serve/hub-app/test-view.tsx +107 -0
  194. package/src/serve/hub-app/tests-svc.ts +62 -0
  195. package/src/serve/hub-app/tests-view.tsx +238 -0
  196. package/src/serve/hub-app/time-ago.tsx +34 -0
  197. package/src/serve/hub-app/timeline-chart.tsx +274 -0
  198. package/src/serve/hub-app/timeline-geometry.ts +314 -0
  199. package/src/serve/hub-app/timeline-svc.ts +62 -0
  200. package/src/serve/hub-app/timeline-view.tsx +245 -0
  201. package/src/serve/hub-app/tree-build.ts +410 -0
  202. package/src/serve/hub-app/tree-row.tsx +42 -0
  203. package/src/serve/hub-app/tree-svc.ts +174 -0
  204. package/src/serve/hub-app/wikis-view.tsx +53 -0
  205. package/src/serve/other-repos.ts +79 -0
  206. package/src/serve/project-gutter-route.ts +350 -0
  207. package/src/serve/project-gutter.ts +449 -0
  208. package/src/serve/run-serve.ts +245 -16
  209. package/src/serve/same-origin.ts +14 -0
  210. package/src/serve/serve-hub.ts +213 -18
  211. package/src/shipkit.ts +19 -12
  212. package/src/smoke/run-smoke.ts +3 -2
  213. package/src/start-repo.ts +75 -36
  214. package/src/suite/judge-suites.ts +209 -15
  215. package/src/suite/kept-timings.ts +58 -0
  216. package/src/suite/runner-shapes.ts +88 -0
  217. package/src/suite/suite-cli.ts +147 -24
  218. package/src/suite/suite-cpu-preload.ts +92 -0
  219. package/src/suite/test-units.ts +243 -0
  220. package/src/ts/config.ts +4 -69
  221. package/src/ts/jsonc.ts +69 -0
  222. package/src/ts/lex.ts +143 -0
  223. package/src/ts/rename.ts +73 -10
  224. package/src/ts/source-graph.ts +28 -8
  225. package/src/ts/tsconfig-projects.ts +6 -97
  226. package/src/ts/tsconfig-resolve.ts +108 -0
  227. package/src/tui/build-steps-panel.ts +61 -0
  228. package/src/tui/checks-panel.ts +24 -16
  229. package/src/tui/run-tui.ts +2 -2
  230. package/src/tui/tui-panel.ts +5 -4
  231. package/src/wiki/graph-colors.ts +68 -0
  232. package/src/wiki/graph-mount.ts +7 -9
  233. package/src/wiki/graph-page.ts +42 -25
  234. package/src/wiki/graph-positions.ts +105 -0
  235. package/src/wiki/run-wiki.ts +1 -1
  236. package/src/wiki/serve-graph.ts +33 -3
  237. package/src/cli/cmd_Rules.ts +0 -51
  238. package/src/cli/cmd_Units.ts +0 -82
  239. package/src/collections/graphs.ts +0 -32
  240. package/src/contracts/contracts-once.ts +0 -43
  241. package/src/core/check-suite.ts +0 -302
  242. package/src/core/repair-contract-dirs.ts +0 -136
  243. package/src/core/task-runner.ts +0 -339
  244. package/src/ledger/gate-units.ts +0 -138
  245. package/src/ledger/unit-table.ts +0 -105
  246. package/src/rules/SK002-contracts-fulfillness.ts +0 -24
  247. package/src/rules/SK003-claude-md-shape.ts +0 -70
  248. package/src/rules/SK004-gitignore-policy.ts +0 -238
  249. package/src/rules/SK010-undeclared-release-unit.ts +0 -127
  250. package/src/rules/rule-registry.ts +0 -64
  251. package/src/rules/run-fix.ts +0 -72
  252. package/src/tui/tasks-panel.ts +0 -60
@@ -0,0 +1,245 @@
1
+ // THE RUNS PAGE: four lists behind four tabs, one visible at a time: every
2
+ // invocation on record as a tree of one line runs whose checks unfold as
3
+ // child lines, the test files to fix, the slowest checks, the longest runs.
4
+ // The tab rides in the address (`#item:runs:runs:files`), so a reload restores it.
5
+ // Every line sits on a grid of fixed columns (the .rline grids in hub.css):
6
+ // numbers right aligned in tabular figures, ran and cached each a column of
7
+ // their own told apart by colour, and no separator glyph on any line. The
8
+ // shaping is the server's (ledger/run-timeline.ts). The chart of every run in
9
+ // time is the live strip under the header, not this page.
10
+ import { observer } from 'mobx-react-lite'
11
+ import { useEffect } from 'react'
12
+ import { CACHED, RAN } from '../../core/outcome-words.ts'
13
+ import type { InvocationSummary, SlowCheck, SuiteToFix, Timeline, TimelineBar, TimelineInvocation } from '../../ledger/run-timeline.ts'
14
+ import { byWords, verdictWords } from './timeline-chart.tsx'
15
+ import { durationTone, fmtMs } from './dur.ts'
16
+ import { Help } from './help.tsx'
17
+ import type { HubSt } from './hub-st.ts'
18
+ import { TimeAgo, stampWords } from './time-ago.tsx'
19
+ import { checkName, editorHref } from './check-words.ts'
20
+ import { RUNS_TABS, runsTab, runsTabId, type RunsTab } from './runs-tabs.ts'
21
+ export { RUNS_TABS, runsTab, runsTabId, type RunsTab }
22
+ import type { Resource } from './hub-types.ts'
23
+
24
+ export type TabBadge = { n: number; tone: 'red' | 'warn' | 'zero' }
25
+
26
+ /** a tab's count, or null where a count says nothing: the red runs, the test
27
+ * files to fix (a zero is news too), the flagged checks */
28
+ export function tabBadge(tab: RunsTab, d: Timeline): TabBadge | null {
29
+ if (tab === 'runs') {
30
+ const n = d.invocations.filter((i) => i.reds > 0).length
31
+ return n > 0 ? { n, tone: 'red' } : null
32
+ }
33
+ if (tab === 'files') return d.suitesRecorded ? { n: d.testFiles.length, tone: d.testFiles.length > 0 ? 'red' : 'zero' } : null
34
+ if (tab === 'slowest') {
35
+ const n = d.slowest.filter((r) => r.flag != null).length
36
+ return n > 0 ? { n, tone: d.slowest.some((r) => r.flag === 'over') ? 'red' : 'warn' } : null
37
+ }
38
+ return null
39
+ }
40
+
41
+ /** who started a run, as a tag short enough for one fixed column. A starter
42
+ * this build does not name reads as the word the record holds */
43
+ export const BY_TAG: Record<string, string> = { cli: 'terminal', hub: 'hub', schedule: 'daily', save: 'save', unknown: 'unknown' }
44
+ export const byTag = (by: string): string => BY_TAG[by] ?? by
45
+
46
+ /** a check's verdict, as the label's colour class: the tree's `tl` tones */
47
+ const verdictTone = (b: TimelineBar): string => (b.status === 'fail' ? 'red' : b.status === 'warn' ? 'warn' : b.status === 'skip' ? 'quiet' : 'ok')
48
+
49
+ const Who = ({ by }: { by: string }) => <span className="rwho" title={`started from ${byWords(by)}`}>{byTag(by)}</span>
50
+
51
+ const Reds = ({ n }: { n: number }) => <span className="rred">{n > 0 ? `${n} red` : ''}</span>
52
+
53
+ /** the ran and the cached columns of a run, a count and its word each */
54
+ const Counts = ({ checks, cached }: { checks: number; cached: number }) => {
55
+ const ran = checks - cached
56
+ return (
57
+ <>
58
+ <span className={`rran${ran === 0 ? ' zero' : ''}`}>{`${ran} ${RAN}`}</span>
59
+ <span className={`rcached${cached === 0 ? ' zero' : ''}`}>{`${cached} ${CACHED}`}</span>
60
+ </>
61
+ )
62
+ }
63
+
64
+ /** a check's name on a child line: its title, with its id as the small tag
65
+ * the tree gives it, and its description for the tooltip */
66
+ export type ChildName = { tag: string | null; title: string; description: string | null }
67
+
68
+ /** every check the hub lists, by id, named the way the tree names it */
69
+ export function checkNames(resources: readonly Resource[]): Map<string, ChildName> {
70
+ const out = new Map<string, ChildName>()
71
+ for (const r of resources) {
72
+ if (r.check == null) continue
73
+ out.set(r.check.checkId, { ...checkName(r.check.checkId, r.title), description: r.check.description })
74
+ }
75
+ return out
76
+ }
77
+
78
+ /** a check the hub no longer lists (removed since the run) is named by its id */
79
+ export const nameOf = (names: Map<string, ChildName>, id: string): ChildName => names.get(id) ?? { tag: null, title: id, description: null }
80
+
81
+ /** an unfolded run's order: its reds, then what ran, slowest first, then what
82
+ * the cache answered, so what matters shows without scrolling */
83
+ export function childOrder(bars: readonly TimelineBar[]): TimelineBar[] {
84
+ const rank = (b: TimelineBar): number => (b.status === 'fail' ? 0 : b.replayed ? 2 : 1)
85
+ return bars
86
+ .map((b, i) => ({ b, i }))
87
+ .sort((x, y) => rank(x.b) - rank(y.b) || (rank(x.b) === 2 ? 0 : y.b.ms - x.b.ms) || x.i - y.i)
88
+ .map((x) => x.b)
89
+ }
90
+
91
+ /** one check of a run, a child line on the run's grid: its word sits in the
92
+ * column its run counts it in */
93
+ const CheckLine = ({ app, b, name }: { app: HubSt; b: TimelineBar; name: ChildName }) => (
94
+ <div className={`rline rchild${b.status === 'fail' ? ' red' : ''}`} onClick={() => app.timeline.openCheck(b.id)}
95
+ title={[name.description ?? '', `${b.id}: ${verdictWords(b)}`, stampWords(b.start)].filter((x) => x !== '').join('\n')}>
96
+ <span />
97
+ <span className="rid"><span className={`rname tl ${verdictTone(b)}`}>{name.tag == null ? null : <span className="tid">{name.tag}</span>}{name.title}</span></span>
98
+ <span className={`rnum ${b.replayed ? 'dim' : durationTone(b.ms) ?? ''}`}>{b.replayed ? '' : fmtMs(b.ms)}</span>
99
+ <span className="rran">{b.replayed ? '' : RAN}</span>
100
+ <span className="rcached">{b.replayed ? CACHED : ''}</span>
101
+ <span className={`rflag ${b.status === 'fail' ? 'red' : 'warn'}`}>{b.status === 'fail' ? 'red' : b.status === 'warn' ? 'warn' : ''}</span>
102
+ </div>
103
+ )
104
+
105
+ const RunLine = observer(({ app, inv, n }: { app: HubSt; inv: TimelineInvocation; n: number }) => {
106
+ const open = app.timeline.isOpen(inv.runId, n)
107
+ return (
108
+ <>
109
+ <div className={`rline rrun${inv.reds > 0 ? ' red' : ''}`} onClick={() => app.timeline.toggle(inv.runId, n)}>
110
+ <span className="rcaret">{open ? '▾' : '▸'}</span>
111
+ <TimeAgo clock={app.clock} at={inv.started} />
112
+ <Who by={inv.by} />
113
+ <span className="rwhat">{inv.label}</span>
114
+ <span className="rnum">{fmtMs(inv.wallMs)}</span>
115
+ <Counts checks={inv.bars.length} cached={inv.replays} />
116
+ <Reds n={inv.reds} />
117
+ </div>
118
+ {open ? <RunChecks app={app} bars={inv.bars} /> : null}
119
+ </>
120
+ )
121
+ })
122
+
123
+ /** the checks of an unfolded run, in a box of their own that scrolls: a full
124
+ * gate is fifty checks, which would push every other run off the page */
125
+ const RunChecks = observer(({ app, bars }: { app: HubSt; bars: TimelineBar[] }) => {
126
+ const names = checkNames(app.cols.resources)
127
+ return <div className="rkids">{childOrder(bars).map((b) => <CheckLine key={`${b.id}:${b.start}`} app={app} b={b} name={nameOf(names, b.id)} />)}</div>
128
+ })
129
+
130
+ const Runs = ({ app, rows }: { app: HubSt; rows: TimelineInvocation[] }) => (
131
+ rows.length === 0 ? <div className="rempty">no run on record yet</div> : <>{rows.map((inv, n) => <RunLine key={inv.runId} app={app} inv={inv} n={n} />)}</>
132
+ )
133
+
134
+ /** what a test file to fix did: `red`, or its time against the budget */
135
+ export const suiteWords = (r: SuiteToFix): string => (r.red ? 'red' : `over ${fmtMs(r.budgetMs)}`)
136
+
137
+ /** a sub-unit's file, named from its unit's dir: the tag beside it already
138
+ * says which unit, and the repo path is the tooltip */
139
+ const inUnit = (r: SuiteToFix): string => {
140
+ if (r.unit === '' || r.unit === 'root') return r.file
141
+ const at = r.file.indexOf(`/${r.unit}/`)
142
+ return at < 0 ? r.file : r.file.slice(at + r.unit.length + 2)
143
+ }
144
+
145
+ const TestFiles = ({ app, rows, recorded }: { app: HubSt; rows: SuiteToFix[]; recorded: boolean }) => {
146
+ if (!recorded) return <div className="rempty">no test file run recorded yet: the next run of a test check records one</div>
147
+ if (rows.length === 0) return <div className="rempty">every test file passed and fits the budget</div>
148
+ return (
149
+ <>
150
+ {rows.map((r) => {
151
+ return (
152
+ <div key={`${r.check}:${r.file}`} className={`rline rtest${r.red ? ' red' : ''}`} title={r.failLine ?? undefined} onClick={() => app.timeline.openTest(r.file)}>
153
+ <span className={`rtag${r.check === '' ? ' dim' : ''}`} title={r.check === '' ? 'recorded before the record named its check' : `the check that ran it: ${r.check}`}
154
+ onClick={(e) => { if (r.check === '') return; e.stopPropagation(); app.timeline.openCheck(r.check) }}>{r.check === '' ? 'unnamed' : r.check}</span>
155
+ <span className="rwhat" title={r.file}>{inUnit(r)}</span>
156
+ <span className={`rnum ${r.slow ? 'bad' : ''}`}>{fmtMs(r.ms)}</span>
157
+ <span className={r.red ? 'rflag red' : 'rflag warn'}>{suiteWords(r)}</span>
158
+ <TimeAgo clock={app.clock} at={r.at} />
159
+ </div>
160
+ )
161
+ })}
162
+ </>
163
+ )
164
+ }
165
+
166
+ const FLAG_WORD = { over: 'at its ceiling', near: 'past half its ceiling' } as const
167
+
168
+ /** two numbers side by side need their names once, above the column */
169
+ const Slowest = ({ app, rows }: { app: HubSt; rows: SlowCheck[] }) => {
170
+ if (rows.length === 0) return <div className="rempty">nothing ran yet</div>
171
+ return (
172
+ <>
173
+ <div className="rhead rslow"><span /><span>mean</span><span>worst</span><span /></div>
174
+ {rows.map((r) => (
175
+ <div key={r.id} className="rline rslow" onClick={() => app.timeline.openCheck(r.id)}>
176
+ <span className="rtag">{r.id}</span>
177
+ <span className={`rnum ${durationTone(r.meanMs, r.ceilingMs) ?? ''}`} title="mean of the runs that ran">{fmtMs(r.meanMs)}</span>
178
+ <span className={`rnum ${durationTone(r.worstMs, r.ceilingMs) ?? ''}`} title="the worst run that ran">{fmtMs(r.worstMs)}</span>
179
+ <span className={r.flag === 'over' ? 'rflag red' : 'rflag warn'} title={r.ceilingMs == null ? undefined : `ceiling ${fmtMs(r.ceilingMs)}`}>{r.flag == null ? '' : FLAG_WORD[r.flag]}</span>
180
+ </div>
181
+ ))}
182
+ </>
183
+ )
184
+ }
185
+
186
+ const Longest = ({ app, rows }: { app: HubSt; rows: InvocationSummary[] }) => {
187
+ if (rows.length === 0) return <div className="rempty">no run on record yet</div>
188
+ return (
189
+ <>
190
+ {rows.map((r) => (
191
+ <div key={r.runId} className={`rline rrun${r.reds > 0 ? ' red' : ''}`} title={stampWords(r.started)}>
192
+ <span />
193
+ <TimeAgo clock={app.clock} at={r.started} />
194
+ <Who by={r.by} />
195
+ <span className="rwhat">{r.label}</span>
196
+ <span className="rnum">{fmtMs(r.wallMs)}</span>
197
+ <Counts checks={r.checks} cached={r.replays} />
198
+ <Reds n={r.reds} />
199
+ </div>
200
+ ))}
201
+ </>
202
+ )
203
+ }
204
+
205
+ const Tabs = observer(({ app, on }: { app: HubSt; on: RunsTab }) => {
206
+ const t = app.timeline
207
+ const d = t.data
208
+ return (
209
+ <div className="rtabs">
210
+ {RUNS_TABS.map((tab) => {
211
+ const b = d == null ? null : tabBadge(tab.key, d)
212
+ return (
213
+ <button key={tab.key} className={`rtab${tab.key === on ? ' on' : ''}`} onClick={() => app.cols.select('runs', runsTabId(tab.key))}>
214
+ {tab.label}
215
+ {b == null ? null : <span className={`rbadge ${b.tone}`}>{b.n}</span>}
216
+ </button>
217
+ )
218
+ })}
219
+ <Help text={RUNS_TABS.find((x) => x.key === on)!.help} />
220
+ <span className="sp" />
221
+ <button disabled={t.loading} onClick={() => void t.load()}>{t.loading ? 'reading…' : 'reload'}</button>
222
+ </div>
223
+ )
224
+ })
225
+
226
+ export const TimelineView = observer(({ app }: { app: HubSt }) => {
227
+ const t = app.timeline
228
+ useEffect(() => { void t.load() }, [t])
229
+ const d = t.data
230
+ const on = runsTab(app.sel?.t === 'item' ? app.sel.id : null)
231
+ return (
232
+ <div className="colcard tlcard">
233
+ <Tabs app={app} on={on} />
234
+ {t.error !== '' ? <div className="warnline">{t.error}</div> : null}
235
+ {d == null ? <div className="dim">reading the run record…</div> : (
236
+ <div className={`rlist ${on}`}>
237
+ {on === 'runs' ? <Runs app={app} rows={d.invocations} />
238
+ : on === 'files' ? <TestFiles app={app} rows={d.testFiles} recorded={d.suitesRecorded} />
239
+ : on === 'slowest' ? <Slowest app={app} rows={d.slowest} />
240
+ : <Longest app={app} rows={d.longest} />}
241
+ </div>
242
+ )}
243
+ </div>
244
+ )
245
+ })
@@ -0,0 +1,410 @@
1
+ // The trie, as pure functions over one flat Resource[]. No react, no mobx,
2
+ // no dom: TreeSvc caches these as computeds and the tests drive them
3
+ // headless. Both tabs (by path, by type) are the SAME array grouped twice,
4
+ // never a second list.
5
+ import { NO_CHECK, type Resource, type ResourceState, type TestFacts } from '../../collections/collection-types.ts'
6
+ import { checkVerdictWords, plural } from '../../core/outcome-words.ts'
7
+ import { checkName } from './check-words.ts'
8
+ import { durationTone, fmtMs, type DurTone } from './dur.ts'
9
+ import type { HashSel } from './hash-route.ts'
10
+
11
+ export type TreeNode = {
12
+ /** stable across reloads: the expansion set and the cursor are keys */
13
+ key: string
14
+ label: string
15
+ state: ResourceState
16
+ /** null on a directory */
17
+ res: Resource | null
18
+ /** a by type group: the kind it gathers, so its row wears that kind's
19
+ * mark. null on a real folder and on a leaf */
20
+ kind: string | null
21
+ /** leaves under this node, and how many of them are red */
22
+ total: number
23
+ reds: number
24
+ /** every gate check under this node: the row pulses while one of them runs */
25
+ checks: string[]
26
+ /** the archive: everything under it is already done. It stays in the
27
+ * tree and out of every count above it */
28
+ archived: boolean
29
+ /** a folder with a page of its own: its label opens the page, its
30
+ * chevron folds. null on every other folder and on a leaf */
31
+ page: HashSel | null
32
+ children: TreeNode[]
33
+ }
34
+
35
+ export type TreeRow = {
36
+ key: string
37
+ label: string
38
+ depth: number
39
+ dir: boolean
40
+ state: ResourceState
41
+ res: Resource | null
42
+ kind: string | null
43
+ total: number
44
+ reds: number
45
+ checks: string[]
46
+ page: HashSel | null
47
+ }
48
+
49
+ // a directory wears the WORST state under it, so a collapsed branch can
50
+ // never hide a red, and OK outranks idle: a folder of passing checks read as
51
+ // plain white because one unmeasured row in it outvoted twenty nine greens
52
+ const RANK: Record<ResourceState, number> = { red: 4, warn: 3, ok: 2, idle: 1, quiet: 0 }
53
+ const worst = (a: ResourceState, b: ResourceState): ResourceState => (RANK[b] > RANK[a] ? b : a)
54
+
55
+ /** an ask that is already done: it rides quiet under `done/`, filed away so
56
+ * past answers stay consultable. Everything else a repo owns is either open
57
+ * or a check, so this is the one archive the tree has */
58
+ const isDone = (r: Resource): boolean => r.type === 'ask' && r.slot === 'done'
59
+
60
+ type Build = { dirs: Map<string, Build>; leaves: Resource[] }
61
+ const emptyBuild = (): Build => ({ dirs: new Map(), leaves: [] })
62
+
63
+ const byLabel = (a: TreeNode, b: TreeNode): number => {
64
+ const ad = a.res == null
65
+ const bd = b.res == null
66
+ if (ad !== bd) return ad ? -1 : 1 // directories first, then leaves
67
+ return a.label.localeCompare(b.label)
68
+ }
69
+
70
+ const leafNode = (r: Resource): TreeNode => ({
71
+ // the resource id, never its path: two resources may share a file
72
+ key: r.id,
73
+ // the TITLE, never the path segment: the path decides where a row sits,
74
+ // the title is what a reader reads
75
+ label: r.title,
76
+ state: r.state,
77
+ res: r,
78
+ kind: null,
79
+ total: 1,
80
+ reds: r.state === 'red' ? 1 : 0,
81
+ checks: r.check == null ? [] : [r.check.checkId],
82
+ archived: isDone(r),
83
+ page: null,
84
+ children: [],
85
+ })
86
+
87
+ const rollup = (children: TreeNode[]): Pick<TreeNode, 'state' | 'total' | 'reds' | 'checks' | 'archived'> => {
88
+ const archived = children.length > 0 && children.every((c) => c.archived)
89
+ return {
90
+ state: children.reduce<ResourceState>((acc, c) => worst(acc, c.state), 'quiet'),
91
+ // an archive is out of the count of the folder that holds it: an
92
+ // asks row reading 25 when 9 of them want hands answers a question
93
+ // nobody asked. The archive itself still counts its own, so the row
94
+ // says how much is filed in there
95
+ total: children.reduce((n, c) => n + (archived || !c.archived ? c.total : 0), 0),
96
+ reds: children.reduce((n, c) => n + c.reds, 0),
97
+ checks: children.flatMap((c) => c.checks),
98
+ archived,
99
+ }
100
+ }
101
+
102
+ /** a chain of directories with one child and nothing of its own renders as
103
+ * ONE row (wiki/build/shipkit rather than three nested rows), and keeps the
104
+ * DEEPEST path as its key so an expansion survives a reload */
105
+ const dirNode = (prefix: string, label: string, b: Build): TreeNode => {
106
+ if (b.leaves.length === 0 && b.dirs.size === 1) {
107
+ const [seg, only] = [...b.dirs.entries()][0]!
108
+ return dirNode(`${prefix}/${seg}`, `${label}/${seg}`, only)
109
+ }
110
+ const children = [
111
+ ...[...b.dirs.entries()].map(([seg, sub]) => dirNode(`${prefix}/${seg}`, seg, sub)),
112
+ ...b.leaves.map(leafNode),
113
+ ].sort(byLabel)
114
+ return { key: prefix, label, res: null, kind: null, ...rollup(children), page: pathPage(prefix, children), children }
115
+ }
116
+
117
+ const holdsTests = (nodes: TreeNode[]): boolean => nodes.some((n) => n.res?.test != null || holdsTests(n.children))
118
+
119
+ /** the virtual `tests/` root of the path tab, and each `tests/<check>` under
120
+ * it, open the pages of the same groups on the by type tab. A real `tests/`
121
+ * folder that holds no test resource stays a plain folder */
122
+ function pathPage(prefix: string, children: TreeNode[]): HashSel | null {
123
+ if (!holdsTests(children)) return null
124
+ if (prefix === 'tests') return { t: 'tests', check: null }
125
+ const m = prefix.match(/^tests\/([^/]+)$/)
126
+ return m == null || m[1] === NO_CHECK ? null : { t: 'tests', check: m[1]! }
127
+ }
128
+
129
+ /** the by type groups of test files: `tests` itself, and one group per test
130
+ * check. The files no check reaches have no time to show */
131
+ function typePage(key: string, label: string): HashSel | null {
132
+ if (key === 'type:test') return { t: 'tests', check: null }
133
+ return key.startsWith('type:test:') && label !== NO_CHECK ? { t: 'tests', check: label } : null
134
+ }
135
+
136
+ export function buildPathTree(resources: Resource[]): TreeNode[] {
137
+ const root = emptyBuild()
138
+ for (const r of resources) {
139
+ const segs = r.path.split('/').filter((s) => s !== '')
140
+ let cur = root
141
+ for (const seg of segs.slice(0, -1)) {
142
+ let next = cur.dirs.get(seg)
143
+ if (next == null) {
144
+ next = emptyBuild()
145
+ cur.dirs.set(seg, next)
146
+ }
147
+ cur = next
148
+ }
149
+ cur.leaves.push(r)
150
+ }
151
+ return [
152
+ ...[...root.dirs.entries()].map(([seg, sub]) => dirNode(seg, seg, sub)),
153
+ ...root.leaves.map(leafNode),
154
+ ].sort(byLabel)
155
+ }
156
+
157
+ const groupNode = (key: string, label: string, kind: string, children: TreeNode[]): TreeNode => ({
158
+ key,
159
+ label,
160
+ res: null,
161
+ kind,
162
+ ...rollup(children),
163
+ page: typePage(key, label),
164
+ children: [...children].sort(byLabel),
165
+ })
166
+
167
+ /** a gate check is filed by WHOSE it is: shipkit ships the SK checks, a repo
168
+ * owns everything it declared itself. There is no second axis, because what
169
+ * a check does and when it runs are already slots on its own row. A test
170
+ * file is filed under the check that runs it, since a repo holds a hundred
171
+ * of them. Every other resource keeps its type word. */
172
+ type Seg = { key: string; label: string; kind: string }
173
+
174
+ const groupOf = (r: Resource): Seg[] => {
175
+ const u = r.check
176
+ if (r.test != null) {
177
+ const by = r.test.standing === 'orphan' || r.test.checkId == null ? NO_CHECK : r.test.checkId
178
+ return [{ key: 'type:test', label: 'tests', kind: 'test' }, { key: `type:test:${by}`, label: by, kind: by === NO_CHECK ? 'test' : 'check' }]
179
+ }
180
+ if (u == null)
181
+ return isDone(r)
182
+ ? [{ key: `type:${r.type}`, label: r.type, kind: r.type }, { key: `type:${r.type}:done`, label: 'done', kind: 'done' }]
183
+ : [{ key: `type:${r.type}`, label: r.type, kind: r.type }]
184
+ return u.from === 'repo'
185
+ ? [{ key: 'type:check:repo', label: 'from this repo', kind: 'check' }]
186
+ : [{ key: 'type:check:shipkit', label: 'from shipkit', kind: 'check' }]
187
+ }
188
+
189
+ type Group = { label: string; kind: string; rs: Resource[]; subs: Map<string, Group> }
190
+
191
+ const groupTree = (key: string, g: Group): TreeNode =>
192
+ groupNode(key, g.label, g.kind, [...[...g.subs.entries()].map(([k, sub]) => groupTree(k, sub)), ...g.rs.map(leafNode)])
193
+
194
+ /** by type: one level per group, plus the `done` archive under the kind
195
+ * that has one, which mirrors the `asks/done/` folder the by path tab
196
+ * shows. Where a check RUNS is already on the row (its note slot) and
197
+ * counted above the tree, so a level for it costs a row to open and answers
198
+ * nothing again. */
199
+ export function buildTypeTree(resources: Resource[]): TreeNode[] {
200
+ const top = new Map<string, Group>()
201
+ for (const r of resources) {
202
+ let cur = top
203
+ let g: Group | null = null
204
+ for (const seg of groupOf(r)) {
205
+ let next = cur.get(seg.key)
206
+ if (next == null) {
207
+ next = { label: seg.label, kind: seg.kind, rs: [], subs: new Map() }
208
+ cur.set(seg.key, next)
209
+ }
210
+ g = next
211
+ cur = next.subs
212
+ }
213
+ g!.rs.push(r)
214
+ }
215
+ return [...top.entries()].map(([key, g]) => groupTree(key, g)).sort(byLabel)
216
+ }
217
+
218
+ /** the visible rows, in screen order — a directory nobody expanded stops
219
+ * the walk, which is the whole point of the expansion set */
220
+ export function flattenTree(nodes: TreeNode[], expanded: Set<string>, depth = 0): TreeRow[] {
221
+ const out: TreeRow[] = []
222
+ for (const n of nodes) {
223
+ const dir = n.res == null
224
+ out.push({ key: n.key, label: n.label, depth, dir, state: n.state, res: n.res, kind: n.kind, total: n.total, reds: n.reds, checks: n.checks, page: n.page })
225
+ if (dir && expanded.has(n.key)) out.push(...flattenTree(n.children, expanded, depth + 1))
226
+ }
227
+ return out
228
+ }
229
+
230
+ /** the one line above the tree: how much of this repo runs on every gate,
231
+ * and how much does not. Counts CHECKS only, because nothing else declares a
232
+ * place to run */
233
+ export function whenCounts(resources: Resource[]): Record<'always' | 'release' | 'health' | 'off', number> {
234
+ const out = { always: 0, release: 0, health: 0, off: 0 }
235
+ for (const r of resources) if (r.check != null) out[r.check.when]++
236
+ return out
237
+ }
238
+
239
+ /** every directory key in the tree, which is what "expand all" writes.
240
+ * `archive: false` leaves the done branches folded: a search that yields
241
+ * every fold must not spill what was filed away as answered */
242
+ export function dirKeys(nodes: TreeNode[], archive = true): string[] {
243
+ return nodes.flatMap((n) => (n.res != null || (!archive && n.archived) ? [] : [n.key, ...dirKeys(n.children, archive)]))
244
+ }
245
+
246
+ /** what the label slot says: a check row carries its id as a small tag before
247
+ * its title, every other row its label alone */
248
+ export function rowName(row: Pick<TreeRow, 'label' | 'res'>): { tag: string | null; title: string } {
249
+ const u = row.res?.check
250
+ return u == null ? { tag: null, title: row.label } : checkName(u.checkId, row.label)
251
+ }
252
+
253
+ // ── the row slots ────────────────────────────────────────────────────────
254
+ // A row is a fixed grid, so the slots line up down the whole column at any
255
+ // depth. Everything a row says in WORDS lives in `hint`, which is the
256
+ // tooltip: a label carries a name, never a verdict.
257
+
258
+ /** the six states a slot can show, one glyph each, everywhere */
259
+ export type SlotState = 'pass' | 'fail' | 'warn' | 'running' | 'idle' | 'off'
260
+ export const GLYPH: Record<SlotState, string> = { pass: '✓', fail: '✕', warn: '!', running: '◍', idle: '·', off: '○' }
261
+
262
+ /** the glyph's colour class. `adv` is a failure that cannot block: a muted
263
+ * fail rather than a word, and `run` is the one that pulses */
264
+ export type SlotTone = 'red' | 'warn' | 'adv' | 'ok' | 'quiet' | 'idle' | 'run'
265
+
266
+ export type RowSlots = {
267
+ state: SlotState
268
+ tone: SlotTone
269
+ /** right aligned, tabular, blank when nothing was measured */
270
+ dur: string
271
+ /** the same tone table the check page reads, so a row and the page it
272
+ * opens can never disagree about a number */
273
+ durTone: DurTone | null
274
+ /** the one short fact this kind has to offer */
275
+ note: string
276
+ /** directories only: what is under it, and how much of it is red */
277
+ count: { total: number; reds: number } | null
278
+ /** every word the row does not show, as the row's tooltip */
279
+ hint: string
280
+ }
281
+
282
+ const DIR_STATE: Record<ResourceState, SlotState> = { red: 'fail', warn: 'warn', ok: 'pass', idle: 'idle', quiet: 'off' }
283
+ const DIR_TONE: Record<ResourceState, SlotTone> = { red: 'red', warn: 'warn', ok: 'ok', idle: 'idle', quiet: 'quiet' }
284
+
285
+ /** the four values, short enough for a 48px slot. The tooltip says them in
286
+ * full, because these are abbreviations and nobody was born knowing them */
287
+ const WHEN_NOTE = { always: 'ci', release: 'release', health: 'health', off: 'off' } as const
288
+ const WHEN_HINT = {
289
+ always: 'runs on every gate',
290
+ release: 'runs on the release path only',
291
+ health: 'runs in the health pass only, never on a gate',
292
+ off: 'does not run',
293
+ } as const
294
+
295
+ export function rowSlots(row: TreeRow, running: boolean): RowSlots {
296
+ if (row.dir)
297
+ return {
298
+ state: DIR_STATE[row.state],
299
+ tone: DIR_TONE[row.state],
300
+ dur: '',
301
+ durTone: null,
302
+ note: '',
303
+ count: { total: row.total, reds: row.reds },
304
+ hint: `${row.label} · ${row.total} under it${row.reds > 0 ? `, ${row.reds} red` : ''}`,
305
+ }
306
+ const r = row.res
307
+ if (r == null) return { state: 'idle', tone: 'idle', dur: '', durTone: null, note: '', count: null, hint: row.label }
308
+ if (r.test != null) return testSlots(r, r.test)
309
+ const u = r.check
310
+ if (u == null)
311
+ return {
312
+ state: DIR_STATE[r.state],
313
+ tone: DIR_TONE[r.state],
314
+ dur: '',
315
+ durTone: null,
316
+ note: r.slot ?? '',
317
+ count: null,
318
+ hint: [r.text, r.virtual === true ? `${r.path} (no file of its own)` : r.path].join('\n'),
319
+ }
320
+ const verdict: [SlotState, SlotTone] = running
321
+ ? ['running', 'run']
322
+ : u.when === 'off'
323
+ ? ['off', 'quiet']
324
+ : u.status === 'fail'
325
+ ? ['fail', u.advisory ? 'adv' : 'red']
326
+ : u.status === 'warn'
327
+ ? ['warn', 'warn']
328
+ : u.status === 'skip'
329
+ ? ['off', 'quiet']
330
+ : u.status === 'pass'
331
+ ? ['pass', u.replayed ? 'quiet' : 'ok']
332
+ : ['idle', 'idle']
333
+ const words = running ? 'running now' : checkVerdictWords(u.status, u)
334
+ return {
335
+ state: verdict[0],
336
+ tone: verdict[1],
337
+ dur: fmtMs(u.lastMs),
338
+ durTone: durationTone(u.lastMs, u.timeoutMs),
339
+ note: WHEN_NOTE[u.when],
340
+ count: null,
341
+ hint: [
342
+ `${u.checkId} (${u.build ? 'build step' : u.from === 'shipkit' ? 'from shipkit' : 'from this repo'}) · ${words}`,
343
+ // the sentence the check wrote about itself, so a row says what it
344
+ // is about without being opened
345
+ u.description != null && u.description !== '' ? u.description : null,
346
+ u.lastMs != null ? `last run ${fmtMs(u.lastMs)}` : null,
347
+ u.samples > 0 ? `average ${fmtMs(u.avgMs)} over ${plural(u.samples, 'run')}` : null,
348
+ `${WHEN_HINT[u.when]}${u.whenReason != null ? `: ${u.whenReason}` : ''}`,
349
+ ].filter((l) => l != null).join('\n'),
350
+ }
351
+ }
352
+
353
+ /** a test file row: its verdict, its last time against its budget, its
354
+ * tier. Over budget is red on the duration, whatever the table says, unless
355
+ * it is declared slow and the budget is not its to break */
356
+ function testSlots(r: Resource, t: TestFacts): RowSlots {
357
+ const last = t.runs[0]
358
+ const over = last != null && last.tier === 'quick' && t.tier === 'quick' && last.ms > t.budgetMs
359
+ return {
360
+ state: DIR_STATE[r.state],
361
+ tone: DIR_TONE[r.state],
362
+ dur: last == null ? '' : fmtMs(last.ms),
363
+ durTone: last == null ? null : over ? 'bad' : durationTone(last.ms),
364
+ note: r.slot ?? '',
365
+ count: null,
366
+ hint: [
367
+ t.file,
368
+ r.text,
369
+ last == null ? null : `last run ${fmtMs(last.ms)} of a ${fmtMs(t.budgetMs)} budget`,
370
+ ].filter((l) => l != null).join('\n'),
371
+ }
372
+ }
373
+
374
+ // ── the KIND of a row ────────────────────────────────────────────────────
375
+ // A tree of a hundred rows said nothing about what any of them WAS. One
376
+ // glyph and one hue per kind, in the slot right after the caret, and the hue
377
+ // is the KIND's identity: it never carries state, which stays on the label
378
+ // and on the state glyph at the right edge. Hues are tokens (k1..k6 on
379
+ // :root), reused across kinds on purpose, because thirteen hues nobody can
380
+ // tell apart is not thirteen answers. The glyph is what identifies, the hue
381
+ // only groups.
382
+
383
+ export type TypeMark = { glyph: string; hue: string; word: string }
384
+
385
+ export const TYPE_MARK: Record<string, TypeMark> = {
386
+ folder: { glyph: '\u25a3', hue: 'k5', word: 'a folder of this repo' },
387
+ ask: { glyph: '\u25c9', hue: 'k2', word: 'an ask: something only a human can do' },
388
+ secret: { glyph: '\u25c8', hue: 'k4', word: 'a secret this repo needs, present or missing' },
389
+ asset: { glyph: '\u25c6', hue: 'k1', word: 'an asset of the kit, with its rolls and its pick' },
390
+ post: { glyph: '\u25a4', hue: 'k2', word: 'a post waiting in the social queue' },
391
+ workflow: { glyph: '\u29c9', hue: 'k6', word: 'a comfy workflow module' },
392
+ wiki: { glyph: '\u2b21', hue: 'k3', word: 'a markdown wiki, opened as its link graph' },
393
+ contract: { glyph: '\u00a7', hue: 'k4', word: 'a release contract of a venue' },
394
+ health: { glyph: '\u229e', hue: 'k5', word: 'a health row that belongs to no other kind' },
395
+ check: { glyph: '\u2299', hue: 'k6', word: 'a gate check: one question `shipkit ci` or `shipkit check` asks, whether it builds, guards a repo shape or probes the world. same page and same switch for all of them' },
396
+ test: { glyph: '\u2263', hue: 'k5', word: 'a test file: the check that runs it, its last runs against its budget, the command that runs it alone' },
397
+ reflection: { glyph: '\u203b', hue: 'k2', word: 'a reflection: friction this repo logged against the wiki, waiting to be folded in' },
398
+ done: { glyph: '\u2713', hue: 'k5', word: 'a done ask: answered, filed under done, out of every count above it' },
399
+ }
400
+
401
+ /** a kind nobody mapped still gets a mark: a blank slot in a fixed grid is a
402
+ * hole, and a new resource kind must never make the tree look broken */
403
+ const UNKNOWN: TypeMark = { glyph: '\u25cb', hue: 'k5', word: 'a kind this page does not know yet' }
404
+
405
+ /** a real folder wears the folder mark, a by type group the mark of the kind
406
+ * it gathers, and a leaf its own kind */
407
+ export const markOf = (res: Resource | null | undefined, dir: boolean, kind: string | null = null): TypeMark =>
408
+ dir
409
+ ? kind == null ? TYPE_MARK.folder! : TYPE_MARK[kind] ?? UNKNOWN
410
+ : res == null ? UNKNOWN : isDone(res) ? TYPE_MARK.done! : TYPE_MARK[res.type] ?? UNKNOWN