rvlib-shipkit 1.4.0 → 1.5.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 (161) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +17 -17
  3. package/package.json +3 -2
  4. package/src/check.ts +68 -95
  5. package/src/checks/SK001-package-json-shape.check.ts +6 -2
  6. package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
  7. package/src/checks/SK003-claude-md-shape.check.ts +5 -1
  8. package/src/checks/SK004-gitignore-policy.check.ts +7 -4
  9. package/src/checks/SK005-rvlib-versions.check.ts +53 -21
  10. package/src/checks/SK006-npm-publish-safety.check.ts +4 -0
  11. package/src/checks/SK007-github-visibility.check.ts +4 -2
  12. package/src/checks/SK008-reviewer-agent.check.ts +6 -4
  13. package/src/checks/SK009-commit-ai-tells.check.ts +4 -2
  14. package/src/checks/SK010-undeclared-unit.check.ts +4 -0
  15. package/src/checks/SK011-sibling-unit-ranges.check.ts +4 -0
  16. package/src/checks/SK012-config-barrel-import.check.ts +5 -1
  17. package/src/checks/SK013-one-way-in-policy.check.ts +7 -3
  18. package/src/checks/SK014-prose-ste.check.ts +4 -0
  19. package/src/checks/SK015-nested-repo.check.ts +4 -2
  20. package/src/checks/SK016-legacy-check-shapes.check.ts +13 -12
  21. package/src/checks/SK017-vscode-hub-task.check.ts +22 -7
  22. package/src/checks/SK018-test-files-run.check.ts +6 -2
  23. package/src/checks/SK019-tests-run-in-units.check.ts +8 -4
  24. package/src/checks/SK020-self-spawning-check.check.ts +4 -0
  25. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -4
  26. package/src/checks/SK024-identity-colors.check.ts +5 -3
  27. package/src/checks/SK025-release-drift.check.ts +4 -5
  28. package/src/checks/SK026-asks-autoclose.check.ts +4 -4
  29. package/src/checks/SK027-agent-wiki-links.check.ts +4 -1
  30. package/src/checks/SK028-readme.check.ts +5 -1
  31. package/src/checks/SK029-explicit-check-files.check.ts +95 -0
  32. package/src/cli/cmd_Asks.ts +5 -5
  33. package/src/cli/cmd_Assets.ts +7 -7
  34. package/src/cli/cmd_Bump.ts +12 -11
  35. package/src/cli/cmd_Check.ts +8 -7
  36. package/src/cli/cmd_Checks.ts +7 -6
  37. package/src/cli/cmd_Ci.ts +12 -11
  38. package/src/cli/cmd_Commands.ts +16 -13
  39. package/src/cli/cmd_ConfigKeys.ts +7 -6
  40. package/src/cli/cmd_Contracts.ts +3 -3
  41. package/src/cli/cmd_CrossRepoActivity.ts +6 -5
  42. package/src/cli/cmd_Fleet.ts +9 -7
  43. package/src/cli/cmd_GlobalProcesses.ts +116 -4
  44. package/src/cli/cmd_Home.ts +23 -17
  45. package/src/cli/cmd_Hooks.ts +6 -5
  46. package/src/cli/cmd_Init.ts +5 -4
  47. package/src/cli/cmd_Keys.ts +8 -7
  48. package/src/cli/cmd_Mv.ts +7 -6
  49. package/src/cli/cmd_Needs.ts +4 -3
  50. package/src/cli/cmd_Overview.ts +4 -3
  51. package/src/cli/cmd_Pb.ts +2 -2
  52. package/src/cli/cmd_Planned.ts +6 -5
  53. package/src/cli/cmd_Prose.ts +5 -5
  54. package/src/cli/cmd_Reflect.ts +7 -7
  55. package/src/cli/cmd_Release.ts +6 -5
  56. package/src/cli/cmd_Releases.ts +5 -4
  57. package/src/cli/cmd_Serve.ts +4 -3
  58. package/src/cli/cmd_Smoke.ts +5 -4
  59. package/src/cli/cmd_Start.ts +7 -6
  60. package/src/cli/cmd_Status.ts +4 -3
  61. package/src/cli/cmd_Tui.ts +3 -2
  62. package/src/cli/cmd_When.ts +71 -0
  63. package/src/cli/cmd_Wiki.ts +4 -4
  64. package/src/cli.ts +7 -2
  65. package/src/collections/checks.ts +2 -0
  66. package/src/collections/collection-types.ts +9 -4
  67. package/src/core/canonical-scripts.ts +1 -1
  68. package/src/core/check-runner.ts +37 -20
  69. package/src/core/config-rewrite.ts +37 -24
  70. package/src/core/discover-checks.ts +23 -2
  71. package/src/core/legacy-shapes.ts +39 -10
  72. package/src/core/one-way-in-policy.ts +2 -2
  73. package/src/core/outcome-words.ts +18 -2
  74. package/src/core/port-check-cli.ts +52 -0
  75. package/src/core/port-check-file.ts +295 -0
  76. package/src/core/port-check-files.ts +31 -0
  77. package/src/core/retention.ts +0 -14
  78. package/src/core/run-check.ts +12 -9
  79. package/src/core/run-ci.ts +8 -4
  80. package/src/core/run-fix.ts +30 -23
  81. package/src/core/run-fixers.ts +42 -5
  82. package/src/core/runs-matrix.ts +179 -0
  83. package/src/core/runs.ts +242 -0
  84. package/src/core/update-policy.ts +20 -0
  85. package/src/core/when-block.ts +29 -30
  86. package/src/core/when.ts +41 -103
  87. package/src/core/wire.ts +10 -14
  88. package/src/fleet/scan-fleet.ts +20 -4
  89. package/src/fleet/update-fleet.ts +6 -6
  90. package/src/home/home-launchd.ts +20 -4
  91. package/src/home/home-routes.ts +12 -1
  92. package/src/home/hubs.ts +5 -5
  93. package/src/home/pm.ts +8 -0
  94. package/src/home/run-pm.ts +1 -0
  95. package/src/home/serve-web.ts +2 -2
  96. package/src/home/supervise.ts +24 -1
  97. package/src/hooks/run-hooks.ts +48 -4
  98. package/src/init-repo.ts +6 -6
  99. package/src/ledger/gate-checks.ts +113 -59
  100. package/src/ledger/git-key.ts +5 -1
  101. package/src/ledger/ledger.ts +5 -5
  102. package/src/ledger/toolchain-identity.ts +45 -27
  103. package/src/plan/repo-source.ts +18 -39
  104. package/src/release/run-release.ts +5 -0
  105. package/src/repo-config.ts +30 -55
  106. package/src/rules/rule.ts +2 -2
  107. package/src/scaffold/files/agent-coding.md +1 -1
  108. package/src/scaffold/files/agent-index.md +1 -1
  109. package/src/scaffold/files/verify-skill.md +2 -2
  110. package/src/serve/hub-app/activity-st.ts +5 -4
  111. package/src/serve/hub-app/activity-view.tsx +1 -1
  112. package/src/serve/hub-app/admin-view.tsx +13 -11
  113. package/src/serve/hub-app/check-view.tsx +66 -46
  114. package/src/serve/hub-app/check-words.ts +1 -11
  115. package/src/serve/hub-app/checks-svc.ts +14 -35
  116. package/src/serve/hub-app/col-tables-view.tsx +13 -6
  117. package/src/serve/hub-app/cols-svc.ts +5 -4
  118. package/src/serve/hub-app/dock-st.ts +3 -2
  119. package/src/serve/hub-app/dock-view.tsx +36 -0
  120. package/src/serve/hub-app/dock.css +6 -0
  121. package/src/serve/hub-app/float-cards.tsx +30 -0
  122. package/src/serve/hub-app/floating.tsx +41 -4
  123. package/src/serve/hub-app/git-st.ts +42 -0
  124. package/src/serve/hub-app/git-view.tsx +61 -0
  125. package/src/serve/hub-app/hash-route.ts +45 -1
  126. package/src/serve/hub-app/header-bar.tsx +62 -72
  127. package/src/serve/hub-app/health-svc.ts +3 -2
  128. package/src/serve/hub-app/home-st.ts +14 -2
  129. package/src/serve/hub-app/home-strip.css +43 -8
  130. package/src/serve/hub-app/home-strip.tsx +111 -47
  131. package/src/serve/hub-app/home.html +1 -0
  132. package/src/serve/hub-app/hub-app.tsx +14 -4
  133. package/src/serve/hub-app/hub-types.ts +5 -2
  134. package/src/serve/hub-app/hub.css +65 -43
  135. package/src/serve/hub-app/icons.tsx +11 -0
  136. package/src/serve/hub-app/index.html +1 -0
  137. package/src/serve/hub-app/keys.ts +5 -2
  138. package/src/serve/hub-app/live-svc.ts +3 -2
  139. package/src/serve/hub-app/machine-st.ts +18 -22
  140. package/src/serve/hub-app/plan-view.tsx +51 -0
  141. package/src/serve/hub-app/planned-st.ts +9 -4
  142. package/src/serve/hub-app/processes-view.tsx +38 -0
  143. package/src/serve/hub-app/projects-svc.ts +15 -10
  144. package/src/serve/hub-app/prose-hits-st.ts +3 -2
  145. package/src/serve/hub-app/reach.ts +36 -0
  146. package/src/serve/hub-app/readme-svc.ts +3 -2
  147. package/src/serve/hub-app/repo-sections.ts +117 -0
  148. package/src/serve/hub-app/repo-st.ts +46 -9
  149. package/src/serve/hub-app/sidebar.tsx +12 -37
  150. package/src/serve/hub-app/timeline-svc.ts +3 -2
  151. package/src/serve/hub-app/tree-build.ts +149 -155
  152. package/src/serve/hub-app/tree-row.tsx +39 -9
  153. package/src/serve/hub-app/tree-svc.ts +70 -49
  154. package/src/serve/hub-app/when-view.tsx +89 -0
  155. package/src/serve/hub-app/when.css +17 -0
  156. package/src/serve/hub-runtime.ts +3 -16
  157. package/src/serve/run-serve.ts +1 -1
  158. package/src/serve/serve-hub.ts +4 -14
  159. package/src/serve/hub-app/processes-svc.ts +0 -63
  160. package/src/serve/hub-app/repo-sections.tsx +0 -61
  161. package/src/serve/repo-processes.ts +0 -89
@@ -1,47 +1,31 @@
1
- // A repo page's left pane: its sections (repo-sections.tsx), then the tree of
2
- // every resource of the repo, one row deep in its path, under its filter, its
3
- // two tabs and its severity filter. Only the tree scrolls, so the sections and
4
- // the filter stay in reach. The row itself is tree-row.tsx.
1
+ // A repo page's left pane: the filter with its severity group on one line,
2
+ // then ONE tree whose top rows are the repo's sections (tree-svc.ts) and
3
+ // whose rows under them are every resource of the repo. Only the tree
4
+ // scrolls, so the filter stays in reach. The row itself is tree-row.tsx.
5
5
  import { observer } from 'mobx-react-lite'
6
- import { Help } from './help.tsx'
7
6
  import { SEARCH_KEY } from './keys.ts'
8
- import { Sections } from './repo-sections.tsx'
9
7
  import type { RepoSt } from './repo-st.ts'
10
- import { TYPE_MARK } from './tree-build.ts'
11
8
  import { TreeRowView } from './tree-row.tsx'
12
9
 
13
10
  const Tree = observer(({ app }: { app: RepoSt }) => {
14
11
  const t = app.tree
12
+ const empty = app.cols.loaded && app.filter !== 'all' && app.query.trim() === '' && t.visible.length === 0
15
13
  return (
16
14
  // roving focus: the arrows belong to the tree only while it HAS the
17
15
  // focus, so the atelier keeps its own keys
18
16
  <div className="tree" tabIndex={0} onKeyDown={(e) => { if (t.key(e.key)) e.preventDefault() }}>
19
17
  {t.rows.map((r) => <TreeRowView key={r.key} app={app} row={r} />)}
20
18
  {!app.cols.loaded && !app.cols.cached ? <div className="tempty twait"><span className="spin" />reading the repo…</div> : null}
21
- {app.cols.loaded && t.rows.length === 0 ? <div className="tempty">{app.filter === 'red' ? 'no reds — nothing is blocked ✨' : app.filter === 'needs' ? 'nothing needs you ✨' : 'no resources yet'}</div> : null}
19
+ {empty ? <div className="tempty">{app.filter === 'red' ? 'no reds — nothing is blocked ✨' : 'nothing needs you ✨'}</div> : null}
20
+ {t.hidden > 0 ? (
21
+ <div className="tempty thidden" role="button" title="show every row (a)" onClick={() => app.setFilter('all')}>
22
+ {t.hidden} {t.hidden === 1 ? 'row' : 'rows'} hidden by <b>{app.filter}</b> · show all <kbd className="pjkey">a</kbd>
23
+ </div>
24
+ ) : null}
22
25
  </div>
23
26
  )
24
27
  })
25
28
 
26
- /** what every glyph in the type slot means, behind one `(?)` */
27
- const TypeLegend = () => (
28
- <Help text={
29
- <span className="legend">
30
- {Object.entries(TYPE_MARK).map(([kind, m]) => (
31
- <span key={kind} className="lrow"><i className={`ty ${m.hue}`}>{m.glyph}</i>{m.word}</span>
32
- ))}
33
- </span>
34
- } />
35
- )
36
-
37
- const TreeTabs = observer(({ app }: { app: RepoSt }) => (
38
- <span className="seg">
39
- {(['path', 'type'] as const).map((t) => (
40
- <button key={t} className={app.tree.tab === t ? 'on' : ''} onClick={() => app.tree.setTab(t)}>by {t}</button>
41
- ))}
42
- </span>
43
- ))
44
-
45
29
  // segmented severity filter — all · needs (yellow+red) · red, the address
46
30
  // rides at /?f=… so a filtered view is shareable. The shortcut letter rides
47
31
  // INSIDE its own button, so the binding is discoverable without a legend
@@ -58,24 +42,15 @@ const SegFilter = observer(({ app }: { app: RepoSt }) => (
58
42
 
59
43
  export const Sidebar = observer(({ app }: { app: RepoSt }) => (
60
44
  <aside>
61
- <Sections app={app} />
62
45
  <div className="ssearch">
63
46
  <span className="sfield">
64
- <input ref={(el) => { app.searchEl = el }} placeholder="filter" aria-label="filter the tree" value={app.query}
47
+ <input ref={(el) => { app.searchEl = el }} placeholder="filter" aria-label="filter the tree by name or path" value={app.query}
65
48
  onChange={(e) => app.setQuery(e.target.value)}
66
49
  onKeyDown={(e) => { if (e.key === 'Escape') app.setQuery('') }} />
67
50
  <kbd className="skey">{SEARCH_KEY}</kbd>
68
51
  </span>
69
52
  <SegFilter app={app} />
70
53
  </div>
71
- <div className="stabs">
72
- <TreeTabs app={app} />
73
- <span className="sp" />
74
- <TypeLegend />
75
- <span className="cnt">{app.tree.visible.length}</span>
76
- </div>
77
- {/* its line is held while the repo is read, so the tree never drops under it */}
78
- {app.tree.whenLine === '' && app.cols.loaded ? null : <div className="swhen">{app.tree.whenLine === '' ? ' ' : app.tree.whenLine}</div>}
79
54
  {app.media.dead != null ? <div className="tempty"><span className="warnline">media API down: {app.media.dead}</span></div> : null}
80
55
  <div className="tscroll">
81
56
  <Tree app={app} />
@@ -7,6 +7,7 @@ import { testId } from '../../collections/collection-types.ts'
7
7
  import { makeAutoObservable, observableRef, runInAction } from 'mobx'
8
8
  import type { Timeline } from '../../ledger/run-timeline.ts'
9
9
  import type { RepoSt } from './repo-st.ts'
10
+ import { ownTrouble, reach } from './reach.ts'
10
11
 
11
12
  export type { Timeline }
12
13
 
@@ -50,12 +51,12 @@ export class TimelineSvc {
50
51
  async load(fresh = false): Promise<void> {
51
52
  runInAction(() => { this.loading = true })
52
53
  try {
53
- const r = await fetch(this.st.url(fresh ? '/api/timeline.json?fresh=1' : '/api/timeline.json'))
54
+ const r = await reach(this.st.url(fresh ? '/api/timeline.json?fresh=1' : '/api/timeline.json'))
54
55
  if (!r.ok) throw new Error(await r.text())
55
56
  const t = (await r.json()) as Timeline
56
57
  runInAction(() => { this.data = t; this.error = '' })
57
58
  } catch (e) {
58
- runInAction(() => { this.error = (e as Error).message })
59
+ runInAction(() => { this.error = ownTrouble(e, this.st.home.machine) })
59
60
  } finally {
60
61
  runInAction(() => { this.loading = false })
61
62
  }
@@ -1,23 +1,30 @@
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'
1
+ // The left pane's tree, as pure functions over one flat Resource[]. No react,
2
+ // no mobx, no dom: TreeSvc caches these as computeds and the tests drive them
3
+ // headless. The top rows are the repo's sections (keys.ts SectionId), and
4
+ // every resource kind has exactly one section it lives under (KIND_SECTION),
5
+ // so no kind can fall out of the pane. A section a repo does not draw still
6
+ // gets its row when a resource lands in it.
7
+ import { type Resource, type ResourceState, type ResourceType, type TestFacts } from '../../collections/collection-types.ts'
8
+ import type { ProcessRow, RowDot } from '../../home/process-rows.ts'
6
9
  import { checkVerdictWords, plural } from '../../core/outcome-words.ts'
10
+ import { runsWords } from '../../core/runs-matrix.ts'
7
11
  import { checkName } from './check-words.ts'
8
12
  import { durationTone, fmtMs, type DurTone } from './dur.ts'
9
13
  import type { HashSel } from './hash-route.ts'
14
+ import { SECTION_LETTER, sectionLabel, type SectionId } from './keys.ts'
10
15
 
11
16
  export type TreeNode = {
12
17
  /** stable across reloads: the expansion set and the cursor are keys */
13
18
  key: string
14
19
  label: string
15
20
  state: ResourceState
16
- /** null on a directory */
21
+ /** null on a group and on a section */
17
22
  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 */
23
+ /** a group: the kind it gathers, so its row wears that kind's mark.
24
+ * null on a section and on a leaf */
20
25
  kind: string | null
26
+ /** a top row: the section it is. null everywhere below */
27
+ section: SectionId | null
21
28
  /** leaves under this node, and how many of them are red */
22
29
  total: number
23
30
  reds: number
@@ -26,8 +33,8 @@ export type TreeNode = {
26
33
  /** the archive: everything under it is already done. It stays in the
27
34
  * tree and out of every count above it */
28
35
  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 */
36
+ /** a group with a page of its own: its label opens the page, its
37
+ * chevron folds. null on every other group and on a leaf */
31
38
  page: HashSel | null
32
39
  children: TreeNode[]
33
40
  }
@@ -36,46 +43,101 @@ export type TreeRow = {
36
43
  key: string
37
44
  label: string
38
45
  depth: number
46
+ /** it has children, so it wears a chevron */
39
47
  dir: boolean
40
48
  state: ResourceState
41
49
  res: Resource | null
42
50
  kind: string | null
51
+ section: SectionId | null
43
52
  total: number
44
53
  reds: number
45
54
  checks: string[]
46
55
  page: HashSel | null
47
56
  }
48
57
 
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
58
+ /** the sections, top to bottom */
59
+ export const SECTION_ORDER = Object.keys(SECTION_LETTER) as SectionId[]
60
+
61
+ /** where each resource kind lives. A Record over every kind, so a kind added
62
+ * to RESOURCE_TYPES does not compile until it has a home. Runs, plan, git and
63
+ * admin are pages only, so no kind lives there */
64
+ export const KIND_SECTION: Record<ResourceType, SectionId> = {
65
+ check: 'repo',
66
+ reflection: 'reflections',
67
+ secret: 'secrets',
68
+ contract: 'contracts',
69
+ ask: 'asks',
70
+ test: 'tests',
71
+ asset: 'media',
72
+ workflow: 'media',
73
+ post: 'media',
74
+ wiki: 'wikis',
75
+ process: 'processes',
76
+ }
77
+
78
+ /** a process's colour: up reads green, crashed red, on its way orange,
79
+ * stopped plain, and unknown (pm not answering) out of play */
80
+ const PROCESS_STATE: Record<RowDot, ResourceState> = { up: 'ok', crashed: 'red', starting: 'warn', down: 'idle', unknown: 'quiet' }
81
+
82
+ /** the repo's global processes as tree leaves. pm owns them, not the hub, so
83
+ * they come from the machine and never from /api/resources.json */
84
+ export const processResources = (rows: readonly ProcessRow[]): Resource[] =>
85
+ rows.map((r) => ({
86
+ id: `process:${r.id}`,
87
+ type: 'process',
88
+ collection: 'processes',
89
+ path: `processes/${r.id}`,
90
+ virtual: true,
91
+ title: r.label,
92
+ text: r.description ?? r.label,
93
+ slot: r.status,
94
+ state: PROCESS_STATE[r.dot],
95
+ }))
96
+
97
+ /** a gate check lives under WHOSE it is: shipkit ships the SK checks, a
98
+ * repo owns what it declared. A kind the map does not know (a newer hub)
99
+ * still lands somewhere */
100
+ export const sectionOf = (r: Resource): SectionId =>
101
+ r.check != null ? (r.check.from === 'shipkit' ? 'shipkit' : 'repo') : (KIND_SECTION[r.type as ResourceType] ?? 'repo')
102
+
103
+ /** the severity filter, read off the COLOUR, so what `red` shows is what
104
+ * reads red on the tree. An ask is proactive work, so it is in needs and
105
+ * never in red (collection-types stateOf owns that rule) */
106
+ export const passesFilter = (r: Resource, f: 'all' | 'needs' | 'red'): boolean =>
107
+ f === 'red' ? r.state === 'red' : f === 'needs' ? r.severity != null : true
108
+
109
+ /** the filter input: a part of the title or of the path */
110
+ export const matchesQuery = (r: Resource, q: string): boolean =>
111
+ q === '' || r.title.toLowerCase().includes(q) || r.path.toLowerCase().includes(q)
112
+
113
+ // a group wears the WORST state under it, so a collapsed branch can never
114
+ // hide a red, and OK outranks idle: a group of passing checks read as plain
115
+ // white because one unmeasured row in it outvoted twenty nine greens
52
116
  const RANK: Record<ResourceState, number> = { red: 4, warn: 3, ok: 2, idle: 1, quiet: 0 }
53
117
  const worst = (a: ResourceState, b: ResourceState): ResourceState => (RANK[b] > RANK[a] ? b : a)
54
118
 
55
- /** an ask that is already done: it rides quiet under `done/`, filed away so
119
+ /** an ask that is already done: it rides quiet under `done`, filed away so
56
120
  * past answers stay consultable. Everything else a repo owns is either open
57
121
  * or a check, so this is the one archive the tree has */
58
122
  const isDone = (r: Resource): boolean => r.type === 'ask' && r.slot === 'done'
59
123
 
60
- type Build = { dirs: Map<string, Build>; leaves: Resource[] }
61
- const emptyBuild = (): Build => ({ dirs: new Map(), leaves: [] })
62
-
63
124
  const byLabel = (a: TreeNode, b: TreeNode): number => {
64
125
  const ad = a.res == null
65
126
  const bd = b.res == null
66
- if (ad !== bd) return ad ? -1 : 1 // directories first, then leaves
127
+ if (ad !== bd) return ad ? -1 : 1 // groups first, then leaves
67
128
  return a.label.localeCompare(b.label)
68
129
  }
69
130
 
70
131
  const leafNode = (r: Resource): TreeNode => ({
71
132
  // the resource id, never its path: two resources may share a file
72
133
  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
134
+ // the TITLE, never the path: the path is what the filter matches, the
135
+ // title is what a reader reads
75
136
  label: r.title,
76
137
  state: r.state,
77
138
  res: r,
78
139
  kind: null,
140
+ section: null,
79
141
  total: 1,
80
142
  reds: r.state === 'red' ? 1 : 0,
81
143
  checks: r.check == null ? [] : [r.check.checkId],
@@ -88,10 +150,9 @@ const rollup = (children: TreeNode[]): Pick<TreeNode, 'state' | 'total' | 'reds'
88
150
  const archived = children.length > 0 && children.every((c) => c.archived)
89
151
  return {
90
152
  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
153
+ // an archive is out of the count of the row that holds it: an asks
154
+ // row reading 25 when 9 of them want hands answers a question nobody
155
+ // asked. The archive itself still counts its own
95
156
  total: children.reduce((n, c) => n + (archived || !c.archived ? c.total : 0), 0),
96
157
  reds: children.reduce((n, c) => n + c.reds, 0),
97
158
  checks: children.flatMap((c) => c.checks),
@@ -99,148 +160,90 @@ const rollup = (children: TreeNode[]): Pick<TreeNode, 'state' | 'total' | 'reds'
99
160
  }
100
161
  }
101
162
 
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))
163
+ /** the groups a resource sits in under its section. A check, a contract, a
164
+ * secret, an ask, a test file, a wiki and an asset sit directly under their
165
+ * section, one row each, a done ask under the `done` archive. Every other
166
+ * kind gets one group named for it, and the group opens that collection's
167
+ * page */
168
+ type Seg = { key: string; label: string; kind: string; page: HashSel | null }
118
169
 
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)
170
+ const groupOf = (r: Resource): Seg[] => {
171
+ if (r.test != null) return []
172
+ if (r.check != null || r.type === 'contract' || r.type === 'secret') return []
173
+ if (r.type === 'ask') return isDone(r) ? [{ key: 'type:ask:done', label: 'done', kind: 'done', page: null }] : []
174
+ if (r.type === 'wiki' || r.type === 'asset' || r.type === 'test' || r.type === 'process' || r.type === 'reflection') return []
175
+ return [{ key: `type:${r.type}`, label: r.type, kind: r.type, page: { t: 'col', id: r.collection } }]
155
176
  }
156
177
 
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
- })
178
+ type Group = { label: string; kind: string; page: HashSel | null; rs: Resource[]; subs: Map<string, Group> }
179
+ const emptyGroup = (label: string, kind: string, page: HashSel | null): Group => ({ label, kind, page, rs: [], subs: new Map() })
166
180
 
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 }
181
+ const groupChildren = (g: Group): TreeNode[] =>
182
+ [...[...g.subs.entries()].map(([k, sub]) => groupNode(k, sub)), ...g.rs.map(leafNode)].sort(byLabel)
173
183
 
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' }]
184
+ const groupNode = (key: string, g: Group): TreeNode => {
185
+ const children = groupChildren(g)
186
+ return { key, label: g.label, res: null, kind: g.kind, section: null, ...rollup(children), page: g.page, children }
187
187
  }
188
188
 
189
- type Group = { label: string; kind: string; rs: Resource[]; subs: Map<string, Group> }
189
+ /** the section node's key, stable so its fold survives a reload */
190
+ export const sectionKey = (id: SectionId): string => `sec:${id}`
190
191
 
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>()
192
+ /** the whole pane: one top row per section the repo draws, in the order
193
+ * given, then any other section a resource lives in, so nothing a provider
194
+ * emits is ever left without a row */
195
+ export function buildTree(sections: readonly SectionId[], resources: Resource[]): TreeNode[] {
196
+ const tops = new Map<SectionId, Group>()
201
197
  for (const r of resources) {
202
- let cur = top
203
- let g: Group | null = null
198
+ const id = sectionOf(r)
199
+ const top = tops.get(id) ?? emptyGroup(id, id, null)
200
+ tops.set(id, top)
201
+ let g: Group = top
204
202
  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
- }
203
+ const next: Group = g.subs.get(seg.key) ?? emptyGroup(seg.label, seg.kind, seg.page)
204
+ g.subs.set(seg.key, next)
210
205
  g = next
211
- cur = next.subs
212
206
  }
213
- g!.rs.push(r)
207
+ g.rs.push(r)
214
208
  }
215
- return [...top.entries()].map(([key, g]) => groupTree(key, g)).sort(byLabel)
209
+ const order = [...sections, ...SECTION_ORDER.filter((id) => !sections.includes(id) && tops.has(id))]
210
+ return order.map((id) => {
211
+ const g = tops.get(id)
212
+ const children = g == null ? [] : groupChildren(g)
213
+ // a section is never an archive, even when all it holds is done:
214
+ // a search must still open it, and its count reads what is open
215
+ const up = rollup(children)
216
+ const total = children.reduce((n, c) => n + (c.archived ? 0 : c.total), 0)
217
+ return { key: sectionKey(id), label: sectionLabel(id), res: null, kind: null, section: id, ...up, total, archived: false, page: null, children }
218
+ })
216
219
  }
217
220
 
218
- /** the visible rows, in screen order — a directory nobody expanded stops
219
- * the walk, which is the whole point of the expansion set */
221
+ /** the visible rows, in screen order: a row nobody unfolded stops the walk,
222
+ * which is the whole point of the expansion set */
220
223
  export function flattenTree(nodes: TreeNode[], expanded: Set<string>, depth = 0): TreeRow[] {
221
224
  const out: TreeRow[] = []
222
225
  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 })
226
+ const dir = n.res == null && n.children.length > 0
227
+ out.push({ key: n.key, label: n.label, depth, dir, state: n.state, res: n.res, kind: n.kind, section: n.section, total: n.total, reds: n.reds, checks: n.checks, page: n.page })
225
228
  if (dir && expanded.has(n.key)) out.push(...flattenTree(n.children, expanded, depth + 1))
226
229
  }
227
230
  return out
228
231
  }
229
232
 
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
+ /** the line at the top of the checks page: how much of this repo runs on
234
+ * every gate, and how much does not. Counts CHECKS only, because nothing
235
+ * else declares a place to run */
233
236
  export function whenCounts(resources: Resource[]): Record<'always' | 'release' | 'health' | 'off', number> {
234
237
  const out = { always: 0, release: 0, health: 0, off: 0 }
235
238
  for (const r of resources) if (r.check != null) out[r.check.when]++
236
239
  return out
237
240
  }
238
241
 
239
- /** every directory key in the tree, which is what "expand all" writes.
242
+ /** every key that can unfold, which is what "expand all" writes.
240
243
  * `archive: false` leaves the done branches folded: a search that yields
241
244
  * every fold must not spill what was filed away as answered */
242
245
  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)]))
246
+ return nodes.flatMap((n) => (n.res != null || n.children.length === 0 || (!archive && n.archived) ? [] : [n.key, ...dirKeys(n.children, archive)]))
244
247
  }
245
248
 
246
249
  /** what the label slot says: a check row carries its id as a small tag before
@@ -282,16 +285,6 @@ export type RowSlots = {
282
285
  const DIR_STATE: Record<ResourceState, SlotState> = { red: 'fail', warn: 'warn', ok: 'pass', idle: 'idle', quiet: 'off' }
283
286
  const DIR_TONE: Record<ResourceState, SlotTone> = { red: 'red', warn: 'warn', ok: 'ok', idle: 'idle', quiet: 'quiet' }
284
287
 
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
288
  export function rowSlots(row: TreeRow, running: boolean): RowSlots {
296
289
  if (row.dir)
297
290
  return {
@@ -319,7 +312,7 @@ export function rowSlots(row: TreeRow, running: boolean): RowSlots {
319
312
  }
320
313
  const verdict: [SlotState, SlotTone] = running
321
314
  ? ['running', 'run']
322
- : u.when === 'off'
315
+ : u.when === 'off' || u.runs.off != null
323
316
  ? ['off', 'quiet']
324
317
  : u.status === 'fail'
325
318
  ? ['fail', u.advisory ? 'adv' : 'red']
@@ -336,7 +329,9 @@ export function rowSlots(row: TreeRow, running: boolean): RowSlots {
336
329
  tone: verdict[1],
337
330
  dur: fmtMs(u.lastMs),
338
331
  durTone: durationTone(u.lastMs, u.timeoutMs, u.budgetMs),
339
- note: WHEN_NOTE[u.when],
332
+ // where it runs has seven contexts, too many for a slot: the tooltip
333
+ // lists them, the check page draws them
334
+ note: '',
340
335
  count: null,
341
336
  hint: [
342
337
  `${u.checkId} (${u.build ? 'build step' : u.from === 'shipkit' ? 'from shipkit' : 'from this repo'}) · ${words}`,
@@ -345,7 +340,7 @@ export function rowSlots(row: TreeRow, running: boolean): RowSlots {
345
340
  u.description != null && u.description !== '' ? u.description : null,
346
341
  u.lastMs != null ? `last run ${fmtMs(u.lastMs)}` : null,
347
342
  u.samples > 0 ? `average ${fmtMs(u.avgMs)} over ${plural(u.samples, 'run')}` : null,
348
- `${WHEN_HINT[u.when]}${u.whenReason != null ? `: ${u.whenReason}` : ''}`,
343
+ runsWords(u.runs),
349
344
  ].filter((l) => l != null).join('\n'),
350
345
  }
351
346
  }
@@ -378,12 +373,11 @@ function testSlots(r: Resource, t: TestFacts): RowSlots {
378
373
  // and on the state glyph at the right edge. Hues are tokens (k1..k6 on
379
374
  // :root), reused across kinds on purpose, because thirteen hues nobody can
380
375
  // tell apart is not thirteen answers. The glyph is what identifies, the hue
381
- // only groups.
376
+ // only groups, and the word is the glyph's tooltip.
382
377
 
383
378
  export type TypeMark = { glyph: string; hue: string; word: string }
384
379
 
385
380
  export const TYPE_MARK: Record<string, TypeMark> = {
386
- folder: { glyph: '\u25a3', hue: 'k5', word: 'a folder of this repo' },
387
381
  ask: { glyph: '\u25c9', hue: 'k2', word: 'an ask: something only a human can do' },
388
382
  secret: { glyph: '\u25c8', hue: 'k4', word: 'a secret this repo needs, present or missing' },
389
383
  asset: { glyph: '\u25c6', hue: 'k1', word: 'an asset of the kit, with its rolls and its pick' },
@@ -394,6 +388,7 @@ export const TYPE_MARK: Record<string, TypeMark> = {
394
388
  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' },
395
389
  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' },
396
390
  reflection: { glyph: '\u203b', hue: 'k2', word: 'a reflection: friction this repo logged against the wiki, waiting to be folded in' },
391
+ process: { glyph: '\u223f', hue: 'k1', word: 'a global process this repo declares: its state, its menu, its log' },
397
392
  done: { glyph: '\u2713', hue: 'k5', word: 'a done ask: answered, filed under done, out of every count above it' },
398
393
  }
399
394
 
@@ -401,9 +396,8 @@ export const TYPE_MARK: Record<string, TypeMark> = {
401
396
  * hole, and a new resource kind must never make the tree look broken */
402
397
  const UNKNOWN: TypeMark = { glyph: '\u25cb', hue: 'k5', word: 'a kind this page does not know yet' }
403
398
 
404
- /** a real folder wears the folder mark, a by type group the mark of the kind
405
- * it gathers, and a leaf its own kind */
406
- export const markOf = (res: Resource | null | undefined, dir: boolean, kind: string | null = null): TypeMark =>
407
- dir
408
- ? kind == null ? TYPE_MARK.folder! : TYPE_MARK[kind] ?? UNKNOWN
409
- : res == null ? UNKNOWN : isDone(res) ? TYPE_MARK.done! : TYPE_MARK[res.type] ?? UNKNOWN
399
+ /** a group wears the mark of the kind it gathers, a leaf its own kind */
400
+ export const markOf = (res: Resource | null | undefined, kind: string | null = null): TypeMark =>
401
+ res == null
402
+ ? kind == null ? UNKNOWN : TYPE_MARK[kind] ?? UNKNOWN
403
+ : isDone(res) ? TYPE_MARK.done! : TYPE_MARK[res.type] ?? UNKNOWN