rvlib-shipkit 0.38.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/CHANGELOG.md +371 -16
  2. package/README.md +26 -14
  3. package/package.json +2 -2
  4. package/src/assets/gen/comfyts-gen.ts +27 -10
  5. package/src/assets/gen/generate-roll.ts +1 -1
  6. package/src/assets/kit-config.ts +6 -6
  7. package/src/assets/run-assets.ts +14 -10
  8. package/src/assets/server/studio-server.ts +6 -5
  9. package/src/check.ts +343 -0
  10. package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
  11. package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
  12. package/src/checks/SK003-claude-md-shape.check.ts +66 -0
  13. package/src/checks/SK004-gitignore-policy.check.ts +309 -0
  14. package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
  15. package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
  16. package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
  17. package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
  18. package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
  19. package/src/checks/SK010-undeclared-unit.check.ts +111 -0
  20. package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
  21. package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
  22. package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
  23. package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
  24. package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
  25. package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
  26. package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
  27. package/src/checks/SK018-test-files-run.check.ts +28 -0
  28. package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
  29. package/src/checks/SK020-self-spawning-check.check.ts +147 -0
  30. package/src/checks/contracts.check.ts +20 -0
  31. package/src/cli/cmd_Asks.ts +3 -2
  32. package/src/cli/cmd_Assets.ts +2 -2
  33. package/src/cli/cmd_Bump.ts +1 -1
  34. package/src/cli/cmd_Check.ts +6 -6
  35. package/src/cli/cmd_Checks.ts +95 -0
  36. package/src/cli/cmd_Ci.ts +48 -13
  37. package/src/cli/cmd_Commands.ts +2 -6
  38. package/src/cli/cmd_ConfigKeys.ts +69 -0
  39. package/src/cli/cmd_Contracts.ts +1 -1
  40. package/src/cli/cmd_Fleet.ts +6 -2
  41. package/src/cli/cmd_Hooks.ts +1 -1
  42. package/src/cli/cmd_Keys.ts +43 -18
  43. package/src/cli/cmd_Mv.ts +2 -2
  44. package/src/cli/cmd_Prose.ts +10 -1
  45. package/src/cli/cmd_Releases.ts +2 -2
  46. package/src/cli/cmd_Serve.ts +1 -1
  47. package/src/cli/cmd_Start.ts +6 -6
  48. package/src/cli/cmd_Status.ts +25 -0
  49. package/src/cli/cmd_Tui.ts +2 -2
  50. package/src/cli.ts +7 -4
  51. package/src/collections/actions.ts +4 -4
  52. package/src/collections/checks.ts +258 -0
  53. package/src/collections/collection-types.ts +254 -1
  54. package/src/collections/comfy.ts +23 -0
  55. package/src/collections/md-body.ts +14 -0
  56. package/src/collections/pings.ts +38 -12
  57. package/src/collections/posts.ts +19 -3
  58. package/src/collections/providers.ts +198 -91
  59. package/src/collections/reflections.ts +41 -0
  60. package/src/collections/state-rules.ts +73 -0
  61. package/src/collections/test-time.ts +288 -0
  62. package/src/collections/tests.ts +181 -0
  63. package/src/collections/wikis.ts +66 -0
  64. package/src/comfy/comfy-panel.ts +292 -0
  65. package/src/contracts/apply-contracts.ts +17 -12
  66. package/src/contracts/contract-types.ts +16 -4
  67. package/src/contracts/contracts-engine.ts +78 -42
  68. package/src/contracts/manifests/discord.contract.ts +6 -6
  69. package/src/contracts/manifests/identity.contract.ts +3 -3
  70. package/src/contracts/manifests/itch.contract.ts +12 -15
  71. package/src/contracts/manifests/social.contract.ts +5 -5
  72. package/src/contracts/manifests/steam.contract.ts +16 -16
  73. package/src/contracts/run-contracts.ts +4 -3
  74. package/src/core/attach.ts +96 -0
  75. package/src/core/canonical-scripts.ts +25 -0
  76. package/src/core/check-ctx.ts +204 -14
  77. package/src/core/check-file-names.ts +112 -0
  78. package/src/core/check-pass.ts +84 -0
  79. package/src/core/check-runner.ts +796 -0
  80. package/src/core/config-keys.ts +256 -0
  81. package/src/core/config-rewrite.ts +77 -0
  82. package/src/core/discover-checks.ts +166 -90
  83. package/src/core/discover-legacy-checks.ts +26 -0
  84. package/src/core/doc-block.ts +13 -0
  85. package/src/core/floor-resolve.ts +264 -0
  86. package/src/core/floor.ts +20 -137
  87. package/src/core/git-bin.ts +24 -0
  88. package/src/core/git-root.ts +108 -0
  89. package/src/core/instance.ts +195 -0
  90. package/src/core/jobs.ts +118 -0
  91. package/src/core/layout.ts +52 -0
  92. package/src/core/legacy-shapes.ts +207 -0
  93. package/src/core/lib-root.ts +5 -0
  94. package/src/core/locked-write.ts +110 -39
  95. package/src/core/machine-commit.ts +5 -4
  96. package/src/core/migrate-layout.ts +557 -0
  97. package/src/core/motherhome.ts +2 -8
  98. package/src/core/nesting.ts +4 -12
  99. package/src/core/one-way-in-policy.ts +73 -17
  100. package/src/core/outcome-words.ts +45 -0
  101. package/src/core/package-manager.ts +86 -0
  102. package/src/core/parallel.ts +21 -0
  103. package/src/core/process-tree.ts +44 -0
  104. package/src/core/raw-access.ts +93 -0
  105. package/src/core/read-set.ts +152 -0
  106. package/src/core/run-check.ts +155 -213
  107. package/src/core/run-ci.ts +153 -189
  108. package/src/core/run-fix.ts +96 -0
  109. package/src/core/run-fixers.ts +53 -30
  110. package/src/core/self-update.ts +9 -3
  111. package/src/core/slots.ts +83 -0
  112. package/src/core/spawn-timeout.ts +34 -3
  113. package/src/core/when-block.ts +401 -0
  114. package/src/core/when.ts +135 -0
  115. package/src/core/write-status.ts +5 -2
  116. package/src/hooks/leak-guard.ts +10 -8
  117. package/src/hooks/one-way-in-guard.ts +13 -5
  118. package/src/hooks/root-identity.ts +2 -1
  119. package/src/hooks/run-hooks.ts +35 -23
  120. package/src/init-repo.ts +39 -23
  121. package/src/ledger/check-stats.ts +155 -0
  122. package/src/ledger/check-table.ts +179 -0
  123. package/src/ledger/db.ts +255 -0
  124. package/src/ledger/gate-checks.ts +433 -0
  125. package/src/ledger/git-key.ts +116 -77
  126. package/src/ledger/git-scan.ts +96 -35
  127. package/src/ledger/ledger.ts +116 -42
  128. package/src/ledger/live-runs.ts +111 -0
  129. package/src/ledger/live-timeline.ts +97 -0
  130. package/src/ledger/repo-view.ts +73 -0
  131. package/src/ledger/run-log.ts +448 -0
  132. package/src/ledger/run-timeline.ts +173 -0
  133. package/src/ledger/store.ts +23 -12
  134. package/src/ledger/suite-log.ts +161 -0
  135. package/src/ledger/toolchain-identity.ts +7 -1
  136. package/src/ledger/world-cache.ts +111 -0
  137. package/src/mv/mv-md.ts +16 -6
  138. package/src/problems/extract.ts +182 -0
  139. package/src/problems/problem-board.ts +37 -0
  140. package/src/problems/problem-line.ts +77 -0
  141. package/src/prose/prose-view.ts +90 -0
  142. package/src/prose/run-prose.ts +60 -19
  143. package/src/prose/ste.ts +140 -24
  144. package/src/reflections/reflections-queue.ts +81 -0
  145. package/src/release/git-facts.ts +15 -9
  146. package/src/release/run-bump.ts +6 -5
  147. package/src/release/run-release.ts +25 -11
  148. package/src/release/run-releases.ts +2 -2
  149. package/src/release/unit-gate.ts +7 -54
  150. package/src/release/unit-resolve.ts +282 -0
  151. package/src/release/units.ts +33 -217
  152. package/src/repo-config.ts +155 -85
  153. package/src/rules/rule.ts +36 -96
  154. package/src/rules/untrack-generated.ts +5 -4
  155. package/src/scaffold/files/vscode-tasks.json +10 -31
  156. package/src/scaffold/jsonc-spans.ts +113 -0
  157. package/src/scaffold/project-scaffold.ts +16 -43
  158. package/src/scan.ts +98 -0
  159. package/src/serve/gate-trigger.ts +148 -0
  160. package/src/serve/health-schedule.ts +102 -0
  161. package/src/serve/hub-app/chart-view.ts +179 -0
  162. package/src/serve/hub-app/check-view.tsx +345 -0
  163. package/src/serve/hub-app/check-words.ts +164 -0
  164. package/src/serve/hub-app/checks-svc.ts +280 -0
  165. package/src/serve/hub-app/clock-svc.ts +33 -0
  166. package/src/serve/hub-app/col-card.tsx +23 -39
  167. package/src/serve/hub-app/cols-svc.ts +44 -20
  168. package/src/serve/hub-app/comfy-svc.ts +171 -0
  169. package/src/serve/hub-app/comfy-view.tsx +81 -0
  170. package/src/serve/hub-app/dur.ts +41 -0
  171. package/src/serve/hub-app/hash-route.ts +121 -0
  172. package/src/serve/hub-app/header-bar.tsx +117 -0
  173. package/src/serve/hub-app/health-svc.ts +23 -28
  174. package/src/serve/hub-app/help.tsx +29 -0
  175. package/src/serve/hub-app/hub-app.tsx +122 -119
  176. package/src/serve/hub-app/hub-st.ts +115 -42
  177. package/src/serve/hub-app/hub-types.ts +117 -2
  178. package/src/serve/hub-app/hub.css +534 -192
  179. package/src/serve/hub-app/index.html +2 -0
  180. package/src/serve/hub-app/live-svc.ts +174 -0
  181. package/src/serve/hub-app/md.ts +15 -0
  182. package/src/serve/hub-app/media-svc.ts +4 -1
  183. package/src/serve/hub-app/media-view.tsx +15 -0
  184. package/src/serve/hub-app/next-run.ts +68 -0
  185. package/src/serve/hub-app/project-gutter.css +65 -0
  186. package/src/serve/hub-app/project-gutter.tsx +128 -0
  187. package/src/serve/hub-app/projects-svc.ts +226 -0
  188. package/src/serve/hub-app/prose-hits-st.ts +124 -0
  189. package/src/serve/hub-app/prose-hits-view.tsx +176 -0
  190. package/src/serve/hub-app/prose-hits.css +21 -0
  191. package/src/serve/hub-app/run-strip.ts +47 -0
  192. package/src/serve/hub-app/runs-tabs.ts +24 -0
  193. package/src/serve/hub-app/test-view.tsx +107 -0
  194. package/src/serve/hub-app/tests-svc.ts +62 -0
  195. package/src/serve/hub-app/tests-view.tsx +238 -0
  196. package/src/serve/hub-app/time-ago.tsx +34 -0
  197. package/src/serve/hub-app/timeline-chart.tsx +274 -0
  198. package/src/serve/hub-app/timeline-geometry.ts +314 -0
  199. package/src/serve/hub-app/timeline-svc.ts +62 -0
  200. package/src/serve/hub-app/timeline-view.tsx +245 -0
  201. package/src/serve/hub-app/tree-build.ts +410 -0
  202. package/src/serve/hub-app/tree-row.tsx +42 -0
  203. package/src/serve/hub-app/tree-svc.ts +174 -0
  204. package/src/serve/hub-app/wikis-view.tsx +53 -0
  205. package/src/serve/other-repos.ts +79 -0
  206. package/src/serve/project-gutter-route.ts +350 -0
  207. package/src/serve/project-gutter.ts +449 -0
  208. package/src/serve/run-serve.ts +245 -16
  209. package/src/serve/same-origin.ts +14 -0
  210. package/src/serve/serve-hub.ts +213 -18
  211. package/src/shipkit.ts +19 -12
  212. package/src/smoke/run-smoke.ts +3 -2
  213. package/src/start-repo.ts +75 -36
  214. package/src/suite/judge-suites.ts +216 -18
  215. package/src/suite/kept-timings.ts +58 -0
  216. package/src/suite/runner-shapes.ts +88 -0
  217. package/src/suite/suite-cli.ts +147 -24
  218. package/src/suite/suite-cpu-preload.ts +92 -0
  219. package/src/suite/test-units.ts +243 -0
  220. package/src/ts/config.ts +4 -69
  221. package/src/ts/jsonc.ts +69 -0
  222. package/src/ts/lex.ts +143 -0
  223. package/src/ts/rename.ts +73 -10
  224. package/src/ts/source-graph.ts +28 -8
  225. package/src/ts/tsconfig-projects.ts +6 -97
  226. package/src/ts/tsconfig-resolve.ts +108 -0
  227. package/src/tui/build-steps-panel.ts +61 -0
  228. package/src/tui/checks-panel.ts +24 -16
  229. package/src/tui/run-tui.ts +2 -2
  230. package/src/tui/tui-panel.ts +5 -4
  231. package/src/wiki/graph-colors.ts +68 -0
  232. package/src/wiki/graph-mount.ts +7 -9
  233. package/src/wiki/graph-page.ts +42 -25
  234. package/src/wiki/graph-positions.ts +105 -0
  235. package/src/wiki/run-wiki.ts +1 -1
  236. package/src/wiki/serve-graph.ts +33 -3
  237. package/src/cli/cmd_Rules.ts +0 -51
  238. package/src/cli/cmd_Units.ts +0 -82
  239. package/src/collections/graphs.ts +0 -32
  240. package/src/contracts/contracts-once.ts +0 -43
  241. package/src/core/check-suite.ts +0 -302
  242. package/src/core/repair-contract-dirs.ts +0 -136
  243. package/src/core/task-runner.ts +0 -339
  244. package/src/ledger/gate-units.ts +0 -138
  245. package/src/ledger/unit-table.ts +0 -105
  246. package/src/rules/SK002-contracts-fulfillness.ts +0 -24
  247. package/src/rules/SK003-claude-md-shape.ts +0 -70
  248. package/src/rules/SK004-gitignore-policy.ts +0 -238
  249. package/src/rules/SK010-undeclared-release-unit.ts +0 -127
  250. package/src/rules/rule-registry.ts +0 -64
  251. package/src/rules/run-fix.ts +0 -72
  252. package/src/tui/tasks-panel.ts +0 -60
@@ -0,0 +1,41 @@
1
+ // reflections collection — this repo's own `.shipkit/reflections/` queue: the
2
+ // friction it logged against the wiki it follows, one row per file still
3
+ // waiting to be folded into its owning page and deleted. Orange, always: the
4
+ // file waits on a human process and nothing here is broken, so it reaches
5
+ // the needs feed and never the red pings. The seed README is not a row.
6
+ import { join } from 'path'
7
+ import { listReflections, REFLECTIONS_DIR } from '../reflections/reflections-queue.ts'
8
+ import type { Collection, Instance } from './collection-types.ts'
9
+ import { mdBody } from './md-body.ts'
10
+
11
+ export function reflectionsCollection(root: string): Collection {
12
+ return {
13
+ id: 'reflections',
14
+ title: 'reflections',
15
+ media: 'text',
16
+ list: async () =>
17
+ listReflections(root).map((r): Instance => {
18
+ const path = `${REFLECTIONS_DIR}/${r.file}`
19
+ const shape = r.unreadable
20
+ ? 'UNREADABLE'
21
+ : !r.isRequest
22
+ ? 'note'
23
+ : r.missing.length === 0
24
+ ? 'change request'
25
+ : `change request, MISSING: ${r.missing.join(', ')}`
26
+ return {
27
+ id: `reflection:${r.file}`,
28
+ text: `${path} — ${shape}: ${r.title}`,
29
+ // the tree row is the line whoever logged it wrote, and
30
+ // the shape sits in the slot beside it
31
+ label: r.title,
32
+ slot: r.unreadable ? 'unreadable' : r.isRequest ? 'request' : 'note',
33
+ type: 'reflection',
34
+ path,
35
+ file: path,
36
+ severity: 'yellow',
37
+ md: mdBody(join(root, path)),
38
+ }
39
+ }),
40
+ }
41
+ }
@@ -0,0 +1,73 @@
1
+ // WHAT COLOUR A ROW IS, one pure rule per kind, in ONE file. The answer used
2
+ // to be spread over the providers that happened to build the instance, so a
3
+ // kind could be red on the tree and yellow in the feed, and a kind nobody
4
+ // wrote a rule for came out white.
5
+ //
6
+ // Two axes, and they are not the same axis. `state` is the colour on the
7
+ // tree. `severity` is what reaches `shipkit needs` and the once ever ping
8
+ // (red pings, yellow never does). A rule that wants colour WITHOUT a feed
9
+ // line returns a bare `state`, which is how "nothing measured this" says so
10
+ // on screen and stays out of a queue of things asking for his hands.
11
+ import type { Instance } from './collection-types.ts'
12
+
13
+ export type StateRule = Pick<Instance, 'state' | 'severity'>
14
+
15
+ /** what a hub row needs to know about one asset */
16
+ export type AssetFacts = { manual: boolean; rolls: number; picked: boolean; stale: boolean }
17
+
18
+ /** THE asset rule: a pick is a decision made, so it reads green. Rolls with
19
+ * nothing picked is taste pending, a stale pick is the same question asked
20
+ * again, a manual asset with nothing uploaded is broken, and an asset nobody
21
+ * has generated yet is plain */
22
+ export const assetState = (f: AssetFacts): StateRule => {
23
+ if (f.manual && f.rolls === 0) return { severity: 'red' }
24
+ if (f.rolls === 0) return {}
25
+ if (!f.picked || f.stale) return { severity: 'yellow' }
26
+ return { state: 'ok' }
27
+ }
28
+
29
+ /** what the run record says about ONE secret, through the check that
30
+ * probes it (`creds[].probedBy`):
31
+ * pass its last run passed, inside its ttl
32
+ * fail its last run failed
33
+ * warn its last run warned, or it names a check this repo lacks
34
+ * stale it passed, and the pass is older than the check's ttl
35
+ * unmeasured it never ran here, was skipped, or said it did not measure
36
+ * none no check probes this secret */
37
+ export type SecretProbe = 'pass' | 'fail' | 'warn' | 'stale' | 'unmeasured' | 'none'
38
+
39
+ /** THE secret rule. Being there is half the answer and working is the other
40
+ * half, so present plus a probe that passed is the only green. A value that
41
+ * is there and fails its probe is red on the tree, and its feed line stays
42
+ * yellow: the probing check does not block, and its own row already carries
43
+ * the same failure. A pass past its ttl, or a probe that never measured, is
44
+ * plain: nothing is known to be wrong. A secret no check probes is dim,
45
+ * because there is no verdict to wait for. Missing while a page requires it
46
+ * is the broken case, whatever any probe said */
47
+ export const secretState = (f: { missing: boolean; probe: SecretProbe }): StateRule => {
48
+ if (f.missing) return { severity: 'red' }
49
+ if (f.probe === 'pass') return { state: 'ok' }
50
+ if (f.probe === 'fail') return { state: 'red', severity: 'yellow' }
51
+ if (f.probe === 'warn') return { severity: 'yellow' }
52
+ if (f.probe === 'none') return { state: 'quiet' }
53
+ return { state: 'idle' }
54
+ }
55
+
56
+ /** THE graph rule: a graph is valid or it is not, and lint says which. Clean
57
+ * is a measured pass, anything else is red on the tree. The feed line is
58
+ * yellow, because a dangling link is work and never a reason to ping. A graph
59
+ * whose lint belongs to another repo is never double judged here, so it
60
+ * claims no colour at all */
61
+ export const graphState = (f: { lint: boolean; issues: number }): StateRule => {
62
+ if (!f.lint) return {}
63
+ return f.issues > 0 ? { state: 'red', severity: 'yellow' } : { state: 'ok' }
64
+ }
65
+
66
+ /** a graph judged by the repo's own checks: red when one of them last
67
+ * failed, green when every one has a verdict and none failed. Each check
68
+ * carries its own feed line already, so the graph adds none. Nothing
69
+ * measured yet claims no colour */
70
+ export const graphChecksState = (last: (string | null)[]): StateRule => {
71
+ if (last.some((s) => s === 'fail')) return { state: 'red' }
72
+ return last.length > 0 && last.every((s) => s != null) ? { state: 'ok' } : {}
73
+ }
@@ -0,0 +1,288 @@
1
+ // WHERE A TEST CHECK'S TIME GOES: the page behind a `test` / `test:<unit>`
2
+ // group of the tree, and the `tests` page above them. Pure over what the
3
+ // caller read: the test files as the tests collection shaped them, the run
4
+ // record (the check's own wall time) and the suite runner's own record (its
5
+ // phases, its workers).
6
+ //
7
+ // Nothing here decides what is red: a file is failing when its TestFacts
8
+ // carry the testFilesToFix verdict (ledger/run-timeline.ts), the same one the
9
+ // tree and the runs page tab read.
10
+ import type { RunRow } from '../ledger/run-log.ts'
11
+ import type { SuiteRunRow } from '../ledger/suite-log.ts'
12
+ import { testId, type TestFacts, type TestRun } from './collection-types.ts'
13
+
14
+ /** past this share of the parallel phase, one file IS the wall */
15
+ export const TAIL_SHARE = 0.6
16
+ /** under this share of the wall, the file phases are not where it goes */
17
+ export const FILES_SHARE = 0.5
18
+ /** past this share of the workers, the files keep the pool busy */
19
+ export const BUSY_SHARE = 0.6
20
+ /** how far after the runner wrote its rows the check that hosted it may end
21
+ * and still be that run */
22
+ const HOST_WINDOW_MS = 120_000
23
+ /** a trend arrow needs a real move: this share of the average, and this much */
24
+ const TREND_SHARE = 0.2
25
+ const TREND_MIN_MS = 50
26
+
27
+ export type Trend = 'up' | 'down' | 'flat'
28
+
29
+ export type FileTime = {
30
+ file: string
31
+ /** the resource id its page opens by */
32
+ id: string
33
+ inUnit: string
34
+ /** the last recorded duration in the shared run, null when it never ran
35
+ * here */
36
+ ms: number | null
37
+ /** the time the budget judged, when an alone rerun replaced it (its own
38
+ * CPU, or its wall alone). null when it is `ms` */
39
+ judgedMs: number | null
40
+ at: number | null
41
+ /** CPU it burned alone, when a process ran it alone */
42
+ cpuMs: number | null
43
+ /** it ran in the check's last run, so it has a share of it */
44
+ inLastRun: boolean
45
+ /** of the sum of file times of that last run, 0..1 */
46
+ share: number | null
47
+ budgetMs: number
48
+ /** a quick run over its budget, on a file the quick tier holds to one */
49
+ over: boolean
50
+ /** mean of its earlier recorded runs in the same tier */
51
+ avgMs: number | null
52
+ trend: Trend | null
53
+ tier: 'quick' | 'full'
54
+ phase: string | null
55
+ status: 'pass' | 'fail' | null
56
+ }
57
+
58
+ export type FailingFile = {
59
+ file: string
60
+ id: string
61
+ inUnit: string
62
+ check: string | null
63
+ at: number | null
64
+ ms: number | null
65
+ failLine: string | null
66
+ }
67
+
68
+ export type Shape = 'tail' | 'volume' | 'overhead'
69
+
70
+ export type Phases = {
71
+ /** the runner before its first phase: probing bun, the bunfig, the pool */
72
+ setupMs: number
73
+ parallelMs: number
74
+ parallelCpuMs: number | null
75
+ /** 0 = one file at a time */
76
+ workers: number
77
+ serialMs: number | null
78
+ serialFiles: number
79
+ /** the over budget files, run again alone before they can fail */
80
+ aloneMs: number
81
+ aloneFiles: number
82
+ /** the runner, first line to the record write */
83
+ runnerMs: number
84
+ /** the check's wall minus the runner's: the process that hosts it, its
85
+ * start and its module load. null when the check's run is not on record */
86
+ hostMs: number | null
87
+ load1: number | null
88
+ cores: number | null
89
+ }
90
+
91
+ export type LastRun = {
92
+ at: number
93
+ files: number
94
+ /** the sum of every file's time in that run */
95
+ sumMs: number
96
+ /** the check's own wall time for that run, else the runner's */
97
+ wallMs: number | null
98
+ wallFrom: 'check' | 'runner' | null
99
+ workers: number | null
100
+ /** sum / wall: how many files ran at once on average */
101
+ ratio: number | null
102
+ shape: Shape | null
103
+ /** the verdict, in one sentence */
104
+ shapeWords: string
105
+ top: { file: string; id: string; inUnit: string; ms: number; share: number }[]
106
+ phases: Phases | null
107
+ /** the whole summary as one line, for a terminal or an agent */
108
+ line: string
109
+ }
110
+
111
+ export type TestCheckView = {
112
+ checkId: string
113
+ last: LastRun | null
114
+ failing: FailingFile[]
115
+ /** every file the check runs, slowest first, the never ran ones last */
116
+ files: FileTime[]
117
+ }
118
+
119
+ export type TestCheckRow = {
120
+ checkId: string
121
+ files: number
122
+ failing: number
123
+ at: number | null
124
+ wallMs: number | null
125
+ sumMs: number | null
126
+ slowest: { file: string; id: string; inUnit: string; ms: number } | null
127
+ shape: Shape | null
128
+ }
129
+
130
+ export type TestsRootView = {
131
+ /** slowest wall first, the never ran ones last */
132
+ checks: TestCheckRow[]
133
+ failing: FailingFile[]
134
+ }
135
+
136
+ export type TestTimeInput = {
137
+ facts: TestFacts[]
138
+ runs: RunRow[]
139
+ suiteRuns: SuiteRunRow[]
140
+ }
141
+
142
+ const ms0 = (n: number): string => `${Math.round(n)}ms`
143
+ const pct = (n: number): string => `${Math.round(n * 100)}%`
144
+
145
+ /** what a file cost the shared run: its phase wall, where the record kept it */
146
+ const costOf = (r: TestRun): number => r.phaseMs ?? r.ms
147
+
148
+ const ranIn = (checkId: string) => (f: TestFacts): boolean => f.checkId === checkId && f.standing === 'runs'
149
+
150
+ function trendOf(f: TestFacts): { avgMs: number | null; trend: Trend | null } {
151
+ const [last, ...rest] = f.runs
152
+ if (last == null) return { avgMs: null, trend: null }
153
+ const same = rest.filter((r) => r.tier === last.tier)
154
+ if (same.length === 0) return { avgMs: null, trend: null }
155
+ const avgMs = same.reduce((n, r) => n + costOf(r), 0) / same.length
156
+ const d = costOf(last) - avgMs
157
+ const moved = Math.abs(d) > avgMs * TREND_SHARE && Math.abs(d) > TREND_MIN_MS
158
+ return { avgMs: Math.round(avgMs), trend: !moved ? 'flat' : d > 0 ? 'up' : 'down' }
159
+ }
160
+
161
+ export function failingOf(facts: TestFacts[]): FailingFile[] {
162
+ return facts
163
+ .filter((f) => f.toFix?.red === true)
164
+ .map((f) => ({ file: f.file, id: testId(f.file), inUnit: f.inUnit, check: f.checkId, at: f.runs[0]?.at ?? null, ms: f.runs[0]?.ms ?? null, failLine: f.runs[0]?.failLine ?? null }))
165
+ .sort((a, b) => (a.check ?? '').localeCompare(b.check ?? '') || a.file.localeCompare(b.file))
166
+ }
167
+
168
+ /** the check's own measured run that hosted the runner run ending at `at` */
169
+ function hostRun(runs: RunRow[], checkId: string, at: number): RunRow | null {
170
+ let best: RunRow | null = null
171
+ for (const r of runs) {
172
+ if (r.id !== checkId || r.replayed || r.status === 'skip') continue
173
+ if (r.at < at || r.at - at > HOST_WINDOW_MS) continue
174
+ if (best == null || r.at < best.at) best = r
175
+ }
176
+ return best
177
+ }
178
+
179
+ /** the verdict, and the one sentence that says it. In order: the file
180
+ * phases are not where the wall goes (then the biggest other part is named),
181
+ * one file sets the phase, the files keep the workers busy, or the workers
182
+ * mostly wait */
183
+ function verdict(o: { top: { inUnit: string; ms: number }; sumMs: number; wallMs: number | null; workers: number | null; sr: SuiteRunRow | null; hostMs: number | null }): { shape: Shape; words: string } | null {
184
+ const files = o.sr == null ? null : o.sr.parallelMs + (o.sr.serialMs ?? 0)
185
+ if (o.sr != null && files != null && o.wallMs != null && files < o.wallMs * FILES_SHARE) {
186
+ const parts: [string, number][] = [
187
+ [`running ${o.sr.aloneFiles} over budget files again alone takes ${ms0(o.sr.aloneMs)}`, o.sr.aloneMs],
188
+ [`the runner's setup takes ${ms0(o.sr.setupMs)}`, o.sr.setupMs],
189
+ [`the process around the runner takes ${ms0(o.hostMs ?? 0)}`, o.hostMs ?? 0],
190
+ [`the runner's own bookkeeping takes ${ms0(o.sr.wallMs - o.sr.setupMs - files - o.sr.aloneMs)}`, o.sr.wallMs - o.sr.setupMs - files - o.sr.aloneMs],
191
+ ]
192
+ const [what] = parts.sort((a, b) => b[1] - a[1])[0]!
193
+ return { shape: 'overhead', words: `outside the files: they run in ${ms0(files)} of the ${ms0(o.wallMs)} wall, and ${what}` }
194
+ }
195
+ const span = o.sr?.parallelMs ?? o.wallMs
196
+ if (span == null || span <= 0) return null
197
+ if (o.top.ms >= span * TAIL_SHARE) return { shape: 'tail', words: `a long tail: ${o.top.inUnit} alone takes most of the wall, so faster others change nothing` }
198
+ const workers = Math.max(1, o.workers ?? 1)
199
+ if (o.sumMs / span >= workers * BUSY_SHARE)
200
+ return { shape: 'volume', words: `volume: the files keep ${o.workers == null || o.workers === 0 ? 'the runner' : `all ${o.workers} workers`} busy, so the wall is the sum, cut the sum` }
201
+ return { shape: 'overhead', words: `the ${workers} workers mostly wait: the files are few or short, and the wall is fixed cost around them` }
202
+ }
203
+
204
+ export function shapeTestCheck(checkId: string, o: TestTimeInput): TestCheckView {
205
+ const mine = o.facts.filter(ranIn(checkId))
206
+ const lastAt = mine.reduce<number | null>((m, f) => (f.runs[0] == null ? m : m == null ? f.runs[0].at : Math.max(m, f.runs[0].at)), null)
207
+ const inLast = lastAt == null ? [] : mine.filter((f) => f.runs[0]?.at === lastAt)
208
+ const sumMs = inLast.reduce((n, f) => n + costOf(f.runs[0]!), 0)
209
+
210
+ const files: FileTime[] = mine.map((f) => {
211
+ const r = f.runs[0]
212
+ const inLastRun = r != null && r.at === lastAt
213
+ return {
214
+ file: f.file,
215
+ id: testId(f.file),
216
+ inUnit: f.inUnit,
217
+ ms: r == null ? null : costOf(r),
218
+ judgedMs: r == null || r.phaseMs == null || r.phaseMs === r.ms ? null : r.ms,
219
+ at: r?.at ?? null,
220
+ cpuMs: r?.cpuMs ?? null,
221
+ inLastRun,
222
+ share: inLastRun && sumMs > 0 ? costOf(r) / sumMs : null,
223
+ budgetMs: f.budgetMs,
224
+ over: r != null && r.tier === 'quick' && f.tier === 'quick' && r.ms > f.budgetMs,
225
+ ...trendOf(f),
226
+ tier: f.tier,
227
+ phase: r?.phase == null || r.phase === '' ? null : r.phase,
228
+ status: r?.status ?? null,
229
+ }
230
+ }).sort((a, b) => (b.ms ?? -1) - (a.ms ?? -1) || a.file.localeCompare(b.file))
231
+
232
+ return { checkId, last: lastAt == null ? null : lastRunOf(checkId, lastAt, files, sumMs, o), failing: failingOf(mine), files }
233
+ }
234
+
235
+ function lastRunOf(checkId: string, at: number, files: FileTime[], sumMs: number, o: TestTimeInput): LastRun {
236
+ const ran = files.filter((f) => f.inLastRun)
237
+ const sr = [...o.suiteRuns].reverse().find((r) => r.check === checkId && r.at === at) ?? null
238
+ const host = hostRun(o.runs, checkId, at)
239
+ const wallMs = host?.ms ?? sr?.wallMs ?? null
240
+ const wallFrom = host != null ? 'check' : sr != null ? 'runner' : null
241
+ const workers = sr?.workers ?? null
242
+ const ratio = wallMs != null && wallMs > 0 ? sumMs / wallMs : null
243
+ const top = ran.slice(0, 3).map((f) => ({ file: f.file, id: f.id, inUnit: f.inUnit, ms: f.ms!, share: f.share ?? 0 }))
244
+ const hostMs = host == null || sr == null ? null : Math.max(0, host.ms - sr.wallMs)
245
+ const v = top[0] == null ? null : verdict({ top: top[0], sumMs, wallMs, workers, sr, hostMs })
246
+ const shape = v?.shape ?? null
247
+ const shapeWords = v?.words ?? 'not enough recorded to say'
248
+ const phases: Phases | null = sr == null ? null : {
249
+ setupMs: sr.setupMs,
250
+ parallelMs: sr.parallelMs,
251
+ parallelCpuMs: sr.parallelCpuMs,
252
+ workers: sr.workers,
253
+ serialMs: sr.serialMs,
254
+ serialFiles: sr.serialFiles,
255
+ aloneMs: sr.aloneMs,
256
+ aloneFiles: sr.aloneFiles,
257
+ runnerMs: sr.wallMs,
258
+ hostMs,
259
+ load1: sr.load1,
260
+ cores: sr.cores,
261
+ }
262
+ const line = [
263
+ `${ran.length} files, sum ${ms0(sumMs)}`,
264
+ wallMs == null ? 'wall not recorded' : `wall ${ms0(wallMs)}${wallFrom === 'runner' ? ' (runner)' : ''}`,
265
+ workers == null ? null : workers === 0 ? 'one at a time' : `${workers} workers`,
266
+ ratio == null ? null : `sum/wall ${ratio.toFixed(1)}x`,
267
+ ].filter((x) => x != null).join(', ') + `: ${shapeWords}. top ${top.length}: ${top.map((t) => `${t.inUnit} ${ms0(t.ms)} (${pct(t.share)})`).join(', ')}`
268
+ return { at, files: ran.length, sumMs, wallMs, wallFrom, workers, ratio, shape, shapeWords, top, phases, line }
269
+ }
270
+
271
+ export function shapeTestsRoot(o: TestTimeInput): TestsRootView {
272
+ const ids = [...new Set(o.facts.filter((f) => f.standing === 'runs' && f.checkId != null).map((f) => f.checkId!))]
273
+ const checks = ids.map((checkId): TestCheckRow => {
274
+ const v = shapeTestCheck(checkId, o)
275
+ const top = v.files.find((f) => f.ms != null)
276
+ return {
277
+ checkId,
278
+ files: v.files.length,
279
+ failing: v.failing.length,
280
+ at: v.last?.at ?? null,
281
+ wallMs: v.last?.wallMs ?? null,
282
+ sumMs: v.last?.sumMs ?? null,
283
+ slowest: top == null ? null : { file: top.file, id: top.id, inUnit: top.inUnit, ms: top.ms! },
284
+ shape: v.last?.shape ?? null,
285
+ }
286
+ }).sort((a, b) => (b.wallMs ?? -1) - (a.wallMs ?? -1) || a.checkId.localeCompare(b.checkId))
287
+ return { checks, failing: failingOf(o.facts) }
288
+ }
@@ -0,0 +1,181 @@
1
+ // THE TEST FILES, one resource each: every file bun would discover, filed
2
+ // under the test check that runs it (`test`, `test:<unit>`), plus the files a
3
+ // bunfig ignore keeps out on purpose and the ones no check reaches.
4
+ //
5
+ // Everything here is READ. Which check runs a file is suite/test-units.ts
6
+ // testPlanOf, the same plan rule SK018 judges, and whether a file needs fixing
7
+ // is ledger/run-timeline.ts testFilesToFix, the same function the runs page
8
+ // tab lists. A second answer to either would drift from the first.
9
+ import { posix } from 'path'
10
+ import { CheckCtx } from '../core/check-ctx.ts'
11
+ import { testFilesToFix } from '../ledger/run-timeline.ts'
12
+ import { runsPath } from '../ledger/run-log.ts'
13
+ import { readSuites, type SuiteRow } from '../ledger/suite-log.ts'
14
+ import type { RepoConfig } from '../repo-config.ts'
15
+ import { rawName } from '../core/canonical-scripts.ts'
16
+ import { DEFAULT_BUDGET_MS, normSuitePath } from '../suite/judge-suites.ts'
17
+ import { declaredSlowFiles, isTestFile, TEST_GLOB, testCheckId, testPlanOf, type TestUnitDecl } from '../suite/test-units.ts'
18
+ import { NO_CHECK, testId, type Collection, type Instance, type ResourceState, type SuiteFacts, type TestFacts, type TestStanding } from './collection-types.ts'
19
+
20
+ /** how many recorded runs a test file's page lists */
21
+ export const TEST_RUNS_KEPT = 10
22
+
23
+ export type TestsOpts = {
24
+ /** injected by tests; production lists what git sees */
25
+ files?: string[]
26
+ /** injected by tests; production reads the disk. null = absent */
27
+ read?: (rel: string) => string | null
28
+ /** injected by tests; production reads the suite record */
29
+ suites?: SuiteRow[]
30
+ }
31
+
32
+ /** the colour of a file's row: red when its last run failed or no check
33
+ * reaches it, warn when it broke its budget, green only when a run passed */
34
+ function stateOf(f: TestFacts): ResourceState {
35
+ if (f.standing === 'orphan' || f.toFix?.red === true) return 'red'
36
+ if (f.standing === 'missing' || f.toFix?.slow === true) return 'warn'
37
+ if (f.standing === 'ignored') return 'quiet'
38
+ if (f.runs.length > 0 && f.runs[0]!.status === 'pass') return 'ok'
39
+ return 'idle'
40
+ }
41
+
42
+ /** the one short fact on the row: the tier it runs in, or why it runs in none */
43
+ const SLOT: Record<TestStanding, (f: TestFacts) => string> = {
44
+ runs: (f) => (f.tier === 'full' ? 'release only' : 'quick'),
45
+ ignored: () => 'ignored',
46
+ orphan: () => 'no check',
47
+ opaque: () => 'unknown',
48
+ missing: () => 'missing',
49
+ }
50
+
51
+ /** the feed line: a terminal has no slots */
52
+ function sentence(f: TestFacts): string {
53
+ const last = f.runs[0]
54
+ const what = last == null ? 'never ran here' : `${last.status === 'fail' ? 'failed' : 'passed'} in ${Math.round(last.ms)}ms of a ${f.budgetMs}ms budget`
55
+ if (f.standing === 'orphan') return `${f.file} runs in no check: ${f.why}`
56
+ if (f.standing === 'ignored') return `${f.file} is ignored on purpose by ${f.why}`
57
+ if (f.standing === 'opaque') return `${f.file}: ${f.why}`
58
+ if (f.standing === 'missing') return `${f.file} is declared slow but is not on disk`
59
+ return `${f.file} runs in ${f.checkId}, ${what}`
60
+ }
61
+
62
+ export function testsCollection(root: string, c: RepoConfig, o: TestsOpts = {}): Collection {
63
+ return {
64
+ id: 'tests',
65
+ title: 'tests',
66
+ media: 'health',
67
+ list: async () => {
68
+ let files = o.files
69
+ let read = o.read
70
+ if (files == null || read == null) {
71
+ const disk = CheckCtx.open({ root, config: c })
72
+ files ??= disk.list(TEST_GLOB)
73
+ read ??= (rel) => disk.read(rel)
74
+ }
75
+ let suites = o.suites
76
+ if (suites == null) {
77
+ try { suites = readSuites(runsPath(root)) } catch { suites = [] }
78
+ }
79
+ return testInstances({ config: c, files: files.filter(isTestFile), read, suites })
80
+ },
81
+ }
82
+ }
83
+
84
+ /** every test file as a resource, pure over what the caller read */
85
+ export function testInstances(o: { config: RepoConfig; files: string[]; read: (rel: string) => string | null; suites: SuiteRow[] }): Instance[] {
86
+ const plan = testPlanOf({ config: o.config, files: o.files, read: o.read })
87
+ const slow = declaredSlowFiles(o.config)
88
+ const toFix = new Map(testFilesToFix(o.suites, slow).map((r) => [r.file, r]))
89
+ const byFile = new Map<string, SuiteRow[]>()
90
+ for (const s of o.suites) {
91
+ const list = byFile.get(s.file) ?? []
92
+ list.push(s)
93
+ byFile.set(s.file, list)
94
+ }
95
+ const decl = slowDeclarations(o.config)
96
+ const unitOf = (file: string): TestUnitDecl | null =>
97
+ plan.units.filter((u) => file === u.rel || file.startsWith(`${u.rel}/`)).sort((a, b) => b.rel.length - a.rel.length)[0] ?? null
98
+ const hasRaw = (dir: string): boolean => {
99
+ try {
100
+ const pkg = JSON.parse(o.read(dir === '' ? 'package.json' : posix.join(dir, 'package.json')) ?? '{}') as { scripts?: Record<string, string> }
101
+ return pkg.scripts?.[rawName('test')] != null
102
+ } catch {
103
+ return false
104
+ }
105
+ }
106
+
107
+ const facts = (file: string, standing: TestStanding, checkId: string | null, why: string | null): TestFacts => {
108
+ const u = unitOf(file)
109
+ const unitDir = u?.rel ?? ''
110
+ const inUnit = unitDir === '' ? file : file.slice(unitDir.length + 1)
111
+ const budgetMs = (u == null ? o.config.tests?.budgetMs : u.tests?.budgetMs) ?? DEFAULT_BUDGET_MS
112
+ const runs = (byFile.get(file) ?? [])
113
+ .slice()
114
+ .sort((a, b) => b.at - a.at)
115
+ .slice(0, TEST_RUNS_KEPT)
116
+ .map((s): TestFacts['runs'][number] => ({
117
+ at: s.at, ms: s.ms, status: s.status, tier: s.tier, budgetMs: s.budgetMs,
118
+ ...(s.cpuMs == null ? {} : { cpuMs: s.cpuMs }),
119
+ ...(s.phase == null || s.phase === '' ? {} : { phase: s.phase }),
120
+ ...(s.phaseMs == null ? {} : { phaseMs: s.phaseMs }),
121
+ ...(s.failLine == null || s.failLine === '' ? {} : { failLine: s.failLine }),
122
+ }))
123
+ const fix = toFix.get(file)
124
+ const suite = decl.get(file)
125
+ return {
126
+ file,
127
+ inUnit,
128
+ unit: u?.id ?? 'root',
129
+ unitDir,
130
+ checkId,
131
+ standing,
132
+ why,
133
+ tier: suite != null ? 'full' : 'quick',
134
+ budgetMs,
135
+ toFix: fix == null ? null : { red: fix.red, slow: fix.slow },
136
+ runs,
137
+ cmd: { cwd: unitDir, line: `${hasRaw(unitDir) ? `bun run ${rawName('test')}` : 'bun test'} ./${inUnit}` },
138
+ ...(suite == null ? {} : { suite }),
139
+ }
140
+ }
141
+
142
+ const all: TestFacts[] = []
143
+ for (const [checkId, list] of plan.runs) for (const file of list) all.push(facts(file, 'runs', checkId, null))
144
+ for (const i of plan.ignored) all.push(facts(i.file, 'ignored', i.checkId, i.pattern))
145
+ for (const x of plan.orphans) all.push(facts(x.file, 'orphan', null, x.why))
146
+ for (const x of plan.unread) all.push(facts(x.file, 'opaque', x.checkId, `${x.checkId} runs a script that is not a plain \`bun test\`, so which files it runs cannot be read`))
147
+ const seen = new Set(all.map((f) => f.file))
148
+ for (const [file, suite] of decl) {
149
+ if (seen.has(file)) continue
150
+ const u = unitOf(file)
151
+ all.push(facts(file, 'missing', testCheckId(u?.id ?? 'root'), `${suite.configPath} declares it slow`))
152
+ }
153
+
154
+ return all.map((f): Instance => {
155
+ const folder = f.checkId == null || f.standing === 'orphan' ? NO_CHECK : f.checkId
156
+ return {
157
+ id: testId(f.file),
158
+ type: 'test',
159
+ path: `tests/${folder}/${f.inUnit}`,
160
+ virtual: true,
161
+ label: posix.basename(f.inUnit),
162
+ text: sentence(f),
163
+ slot: SLOT[f.standing](f),
164
+ state: stateOf(f),
165
+ ...(f.standing === 'missing' ? {} : { file: f.file }),
166
+ test: f,
167
+ }
168
+ })
169
+ }
170
+
171
+ /** every slow declaration, by the repo relative file it names */
172
+ function slowDeclarations(c: RepoConfig): Map<string, SuiteFacts> {
173
+ const out = new Map<string, SuiteFacts>()
174
+ const add = (dir: string | null, s: { file: string; catches: string; whyNotFast: string }, budgetMs: number): void => {
175
+ const rel = normSuitePath(dir == null ? s.file : posix.join(dir, s.file))
176
+ out.set(rel, { file: s.file, catches: s.catches, whyNotFast: s.whyNotFast, configPath: 'repo.config.ts', unit: dir, budgetMs })
177
+ }
178
+ for (const s of c.tests?.slow ?? []) add(null, s, c.tests?.budgetMs ?? DEFAULT_BUDGET_MS)
179
+ for (const u of c.units ?? []) if (typeof u.dir === 'string') for (const s of u.tests?.slow ?? []) add(u.dir, s, u.tests?.budgetMs ?? DEFAULT_BUDGET_MS)
180
+ return out
181
+ }
@@ -0,0 +1,66 @@
1
+ // wikis collection — the MOUNTED md wikis as CHECKED section entries. The
2
+ // mounts arrive as data from the hub's own route resolution (serve/run-serve),
3
+ // never re-derived here: a card linking at an unmounted route renders the 404
4
+ // body inside its iframe. Each instance carries its lint state (orphans +
5
+ // dangling refs = red with counts; clean = green), or the last verdicts of
6
+ // the repo checks its mount names. A graph whose lint belongs to another repo
7
+ // is link-only, never double-judged here.
8
+ import { checkStatsFor, type CheckStat } from '../ledger/check-stats.ts'
9
+ import { lintMd } from '../wiki/lint-md.ts'
10
+ import type { GraphMount } from '../wiki/graph-mount.ts'
11
+ import type { Collection, Instance } from './collection-types.ts'
12
+ import { graphChecksState, graphState } from './state-rules.ts'
13
+
14
+ export type WikisOpts = {
15
+ /** injected by tests; production reads the store plus the run record */
16
+ stats?: Map<string, CheckStat>
17
+ }
18
+
19
+ // a wiki's graph is a VIEW, never a place: it files under wikis/<id> whatever
20
+ // dir it scans, so the wiki and the agent graphs sit as two sibling rows.
21
+ // Filed at the dir it scans, the agent graph read as the agent folder itself
22
+ const wikiPath = (m: GraphMount): Pick<Instance, 'path' | 'virtual'> => ({ path: `wikis/${m.id}`, virtual: true })
23
+
24
+ export function wikisCollection(mounts: GraphMount[], root: string | null = null, o: WikisOpts = {}): Collection {
25
+ return {
26
+ id: 'wikis',
27
+ title: 'wikis',
28
+ media: 'data',
29
+ list: async () =>
30
+ mounts.map((m): Instance => {
31
+ const id = `wiki:${m.id}`
32
+ const href = `/graph/${m.id}`
33
+ const label = m.label
34
+ const place = { type: 'wiki' as const, ...wikiPath(m) }
35
+ if (m.lint == null) return { id, text: m.label, label: m.label, md: label, href, ...place }
36
+ if ('checks' in m.lint) {
37
+ const ids = m.lint.checks
38
+ const stats = o.stats ?? (root == null ? new Map<string, CheckStat>() : checkStatsFor(root, ids.map((x) => ({ id: x }))))
39
+ const last = ids.map((x) => stats.get(x)?.last?.status ?? null)
40
+ const red = ids.filter((_, n) => last[n] === 'fail')
41
+ return {
42
+ id,
43
+ text: red.length === 0 ? m.label : `${m.label}: ${red.join(', ')} red`,
44
+ label: m.label,
45
+ slot: red.length === 0 ? 'clean' : `${red.length} red`,
46
+ md: `${label}, judged by ${ids.join(', ')}`,
47
+ ...graphChecksState(last),
48
+ href,
49
+ ...place,
50
+ }
51
+ }
52
+ const r = lintMd({ root: m.lint.root, index: m.lint.index })
53
+ const issues = r.orphans.length + r.dangling.length
54
+ return {
55
+ id,
56
+ text: issues === 0 ? m.label : `${m.label} — ${r.orphans.length} orphan(s), ${r.dangling.length} dangling ref(s)`,
57
+ label: m.label,
58
+ slot: issues === 0 ? 'clean' : `${issues} issue${issues === 1 ? '' : 's'}`,
59
+ md: label,
60
+ ...graphState({ lint: true, issues }),
61
+ href,
62
+ ...place,
63
+ }
64
+ }),
65
+ }
66
+ }