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,108 @@
1
+ // which tsconfig files a typecheck must actually visit.
2
+ //
3
+ // `tsc --noEmit -p <solution>` on a solution-style config (`files: []` plus
4
+ // `references`) compiles ZERO files and exits 0. The gate then prints
5
+ // `ok: typecheck` while a type error sits in src, which is the single worst
6
+ // output this repo can produce: an unmeasured green nobody can tell from a
7
+ // measured one. tsc will not warn about it, so the caller has to resolve the
8
+ // leaves itself and refuse when there are none.
9
+ //
10
+ // No disk access of its own: every read goes through the reader it is handed,
11
+ // plain fs outside a check (ts/tsconfig-projects.ts), the check context
12
+ // inside one, so a check that asks how typecheck resolves is keyed on the
13
+ // configs it read.
14
+ import { dirname, isAbsolute, join, relative, resolve } from 'path'
15
+ import type { UnitReader } from '../release/unit-resolve.ts'
16
+ import { parseJsonc } from './jsonc.ts'
17
+
18
+ export type TypecheckProjects = {
19
+ /** absolute paths, in declaration order, of the configs that compile files */
20
+ projects: string[]
21
+ /** nothing would be compiled: the check would pass without checking */
22
+ vacuous: boolean
23
+ /** references pointing at a path that does not exist, verbatim as declared */
24
+ missing: string[]
25
+ }
26
+
27
+ type RawConfig = {
28
+ files?: unknown
29
+ include?: unknown
30
+ references?: { path?: unknown }[]
31
+ }
32
+
33
+ /** the reader, over absolute paths: it answers by the path relative to `root` */
34
+ type At = { text: (abs: string) => string | null; exists: (abs: string, kind?: 'file' | 'dir') => boolean }
35
+
36
+ /** a reference may name a directory, a config file, or a dir with no config */
37
+ function configPathFor(at: At, base: string, declared: string): string | null {
38
+ const p = isAbsolute(declared) ? declared : resolve(base, declared)
39
+ if (at.exists(p, 'dir')) return at.exists(join(p, 'tsconfig.json')) ? join(p, 'tsconfig.json') : null
40
+ return at.exists(p) ? p : null
41
+ }
42
+
43
+ /**
44
+ * whether tsc would compile anything for THIS config, ignoring its references.
45
+ *
46
+ * tsc's rule, and every branch here is a shape found in the field:
47
+ * - `files` non-empty, or `include` non-empty: compiles those
48
+ * - neither key present: the default `include` is `**\/*`, so it compiles the
49
+ * whole directory tree, which is why a bare `{ references: [...] }` is NOT
50
+ * a solution config
51
+ * - `files: []` with no `include`: the solution-style marker, compiles nothing
52
+ * - `include: []`: compiles nothing whatever `files` says, unless `files` lists
53
+ * something
54
+ */
55
+ function compilesOwnFiles(cfg: RawConfig): boolean {
56
+ const files = Array.isArray(cfg.files) ? cfg.files : null
57
+ const include = Array.isArray(cfg.include) ? cfg.include : null
58
+ if (files != null && files.length > 0) return true
59
+ if (include != null) return include.length > 0
60
+ return files == null
61
+ }
62
+
63
+ function read(at: At, path: string): RawConfig {
64
+ const text = at.text(path)
65
+ if (text == null) throw new Error(`tsconfig unreadable at ${path}: no file there`)
66
+ try { return (parseJsonc(text) ?? {}) as RawConfig }
67
+ catch (e) { throw new Error(`tsconfig unparseable at ${path}: ${String(e).slice(0, 120)}`) }
68
+ }
69
+
70
+ /**
71
+ * Walk `references` to the leaves. A leaf that declares `files: []` with no
72
+ * `include` compiles nothing and is dropped, so a solution whose every leaf is
73
+ * empty comes back `vacuous` rather than as a command that passes for free.
74
+ */
75
+ export function resolveTypecheckProjects(tsconfigPath: string, io: UnitReader, root: string): TypecheckProjects {
76
+ const rel = (abs: string): string => relative(root, abs).replace(/\\/g, '/')
77
+ const at: At = { text: (abs) => io.text(rel(abs)), exists: (abs, kind) => io.exists(rel(abs), kind) }
78
+ const start = resolve(root, tsconfigPath)
79
+ const projects: string[] = []
80
+ const missing: string[] = []
81
+ // a reference loop is legal to WRITE and would otherwise recurse forever
82
+ const seen = new Set<string>()
83
+
84
+ const visit = (path: string): void => {
85
+ if (seen.has(path)) return
86
+ seen.add(path)
87
+ const cfg = read(at, path)
88
+ // references are checked FIRST so the list reads dependencies before
89
+ // dependents, the order `tsc -b` would build in
90
+ for (const r of Array.isArray(cfg.references) ? cfg.references : []) {
91
+ const declared = typeof r?.path === 'string' ? r.path : null
92
+ if (declared == null) continue
93
+ const next = configPathFor(at, dirname(path), declared)
94
+ if (next == null) missing.push(declared)
95
+ else visit(next)
96
+ }
97
+ // `references` does NOT suppress a config's own inputs: only an empty
98
+ // `files` with no `include` does, and that is what makes a config
99
+ // solution-style. Skipping a referencing config outright is how the
100
+ // first version of this file reintroduced the false green it exists to
101
+ // kill, on `{ include: ['src'], references: [...] }`, which is the
102
+ // ordinary monorepo package shape
103
+ if (compilesOwnFiles(cfg)) projects.push(path)
104
+ }
105
+
106
+ visit(start)
107
+ return { projects, vacuous: projects.length === 0, missing }
108
+ }
@@ -0,0 +1,61 @@
1
+ // built-in panel: the gate's BUILD STEPS through runCi, results streaming in
2
+ // via the onCheck tap. A build step is what `shipkit ci` runs and the health
3
+ // pass leaves out, which is the ONE axis that splits this panel from the one
4
+ // beside it. Every other check is skipped here on purpose: the checks panel
5
+ // runs and renders them, and running them twice per refresh proves the same
6
+ // thing twice.
7
+ // no panel writes STATUS.md: a dashboard refresh runs the build steps alone,
8
+ // so stamping a verdict from it would overwrite what a real gate proved with
9
+ // a narrower claim, just by looking at the repo.
10
+ import type { RepoConfig } from '../repo-config.ts'
11
+ import { allTasks } from '../ledger/gate-checks.ts'
12
+ import { runCi } from '../core/run-ci.ts'
13
+ import { formatLine } from '../core/wire.ts'
14
+ import type { CheckEvent } from '../core/check-runner.ts'
15
+ import type { TuiPanelDef, TuiRow } from './tui-panel.ts'
16
+
17
+ const checkRow = (e: CheckEvent): TuiRow => ({
18
+ key: e.id,
19
+ label: e.id,
20
+ status: e.status === 'pass' ? 'pass' : e.status === 'fail' ? 'fail' : e.status === 'warn' ? 'warn' : 'skip',
21
+ hint: `${(e.ms / 1000).toFixed(1)}s`,
22
+ detail: [
23
+ ...e.lines.map((l) => formatLine(l, false)),
24
+ ...(e.note != null && e.note !== '' ? [`skip: ${e.note}`] : []),
25
+ ...(e.logPath != null ? [`full log: ${e.logPath}`] : []),
26
+ ],
27
+ })
28
+
29
+ export function buildStepsPanel(root: string, config: RepoConfig): TuiPanelDef {
30
+ let rows: TuiRow[] = []
31
+ return {
32
+ id: 'build',
33
+ title: 'build steps',
34
+ rows: () => rows,
35
+ refresh: async (update) => {
36
+ const { tasks, disabled, unfilled } = await allTasks(config, root)
37
+ rows = [
38
+ ...tasks.map((t): TuiRow => ({ key: t.id, label: t.id, status: 'pending', detail: [] })),
39
+ ...disabled.map((id): TuiRow => ({ key: id, label: id, status: 'skip', detail: ['skip: disabled by repo.config defaults'] })),
40
+ ...unfilled.map((u): TuiRow => ({ key: u.id, label: u.id, status: 'skip', detail: [`skip: ${u.reason}`] })),
41
+ ]
42
+ const byKey = new Map(rows.map((r, i) => [r.key, i]))
43
+ update()
44
+ await runCi(root, {
45
+ config,
46
+ buildOnly: true,
47
+ status: false,
48
+ print: () => {}, // rows carry everything the wire lines would
49
+ onCheck: (e) => {
50
+ const i = byKey.get(e.id)
51
+ if (i == null) return
52
+ rows[i] = checkRow(e)
53
+ update()
54
+ },
55
+ })
56
+ // bail/interrupt leftovers never got a finish tap — visible, never a fake pass
57
+ rows = rows.map((r) => (r.status === 'pending' ? { ...r, status: 'skip', detail: ['skip: never ran (bailed or interrupted)'] } : r))
58
+ update()
59
+ },
60
+ }
61
+ }
@@ -1,35 +1,43 @@
1
- // built-in panel: contracts + every discovered *.check.ts through runCheck
2
- // (both TTL cache layers intact) — per-check results stream in via the
3
- // onResult tap. status:false: no TUI panel writes STATUS.md (tasks-panel.ts).
4
- import { discoverChecks } from '../core/discover-checks.ts'
1
+ // built-in panel: every check the health pass asks for, through runCheck,
2
+ // results streaming in via the onCheck tap. The rows are drawn from the same
3
+ // inventory every other surface reads, so a check cannot exist for the gate
4
+ // and not for the dashboard. status:false: no TUI panel writes STATUS.md
5
+ // (build-steps-panel.ts).
6
+ import { gateInventory } from '../ledger/gate-checks.ts'
7
+ import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
5
8
  import { runCheck } from '../core/run-check.ts'
6
9
  import { formatLine } from '../core/wire.ts'
7
10
  import type { TuiPanelDef, TuiRow } from './tui-panel.ts'
11
+ import { CACHED } from '../core/outcome-words.ts'
8
12
 
9
- export function checksPanel(root: string): TuiPanelDef {
13
+ export function checksPanel(root: string, config?: RepoConfig): TuiPanelDef {
10
14
  let rows: TuiRow[] = []
11
15
  return {
12
16
  id: 'checks',
13
- title: 'Checks',
17
+ title: 'checks',
14
18
  rows: () => rows,
15
19
  refresh: async (update) => {
16
- rows = ['contracts', ...discoverChecks(root).map((d) => d.name)]
17
- .map((n): TuiRow => ({ key: n, label: n, status: 'pending', detail: [] }))
20
+ const c = config ?? (await loadRepoConfig(root))
21
+ // the checks the HEALTH pass asks for: everything that is not a
22
+ // build step, which is the panel beside this one
23
+ rows = (await gateInventory(root, c))
24
+ .filter((r) => !r.build)
25
+ .map((r): TuiRow => ({ key: r.decl.id, label: r.decl.id, status: 'pending', detail: [r.description] }))
18
26
  const byKey = new Map(rows.map((r, i) => [r.key, i]))
19
27
  update()
20
28
  await runCheck(root, {
21
29
  status: false,
22
30
  print: () => {},
23
- onResult: (name, r) => {
31
+ onCheck: (e) => {
24
32
  const row: TuiRow = {
25
- key: name,
26
- label: name,
27
- status: r.status,
28
- ...(r.cached ? { hint: 'cached' } : {}),
29
- detail: r.lines.map((l) => formatLine(l, false)),
33
+ key: e.name,
34
+ label: e.name,
35
+ status: e.status,
36
+ ...(e.replayed ? { hint: CACHED } : {}),
37
+ detail: e.lines.map((l) => formatLine(l, false)),
30
38
  }
31
- const i = byKey.get(name)
32
- if (i == null) { byKey.set(name, rows.length); rows.push(row) } // suite-side names (secret checks) still land
39
+ const i = byKey.get(e.name)
40
+ if (i == null) { byKey.set(e.name, rows.length); rows.push(row) }
33
41
  else rows[i] = row
34
42
  update()
35
43
  },
@@ -6,7 +6,7 @@ import { render } from 'ink'
6
6
  import { configure } from 'mobx'
7
7
  import { loadRepoConfig } from '../repo-config.ts'
8
8
  import { checksPanel } from './checks-panel.ts'
9
- import { tasksPanel } from './tasks-panel.ts'
9
+ import { buildStepsPanel } from './build-steps-panel.ts'
10
10
  import { TuiApp } from './tui-app.tsx'
11
11
  import { TuiStore } from './tui-store.ts'
12
12
 
@@ -16,7 +16,7 @@ export async function runTui(root: string): Promise<number> {
16
16
  // only signal. Read-warning flags stay off: useInput handler reads are
17
17
  // legitimately untracked and would false-positive.
18
18
  configure({ enforceActions: 'always' })
19
- const store = new TuiStore(c.name, [tasksPanel(root, c), checksPanel(root), ...(c.tui?.panels ?? [])])
19
+ const store = new TuiStore(c.name, [buildStepsPanel(root, c), checksPanel(root, c), ...(c.tui?.panels ?? [])])
20
20
  store.refresh() // instant static paint; results stream in
21
21
  const app = render(React.createElement(TuiApp, { store }))
22
22
  await app.waitUntilExit()
@@ -1,7 +1,7 @@
1
1
  // TUI panel contract — render-free and mobx-free on purpose: a panel is
2
2
  // DATA (rows + wire-format detail lines), the generic view (tui-app.tsx)
3
- // owns all ink. Built-ins: tasks-panel.ts (the ci gate) + checks-panel.ts
4
- // (contracts + discovered *.check.ts); a repo injects extras via
3
+ // owns all ink. Built-ins: build-steps-panel.ts (the ci gate) + checks-panel.ts
4
+ // (the gate's build steps, then every other check); a repo injects extras via
5
5
  // repo.config `tui: { panels: [...] }`.
6
6
 
7
7
  export type TuiRowStatus = 'pass' | 'fail' | 'warn' | 'skip' | 'pending' | 'info'
@@ -21,14 +21,15 @@ export type TuiRow = {
21
21
  }
22
22
 
23
23
  export type TuiPanelDef = {
24
+ /** stable panel id, used for the tab order and the key binding */
24
25
  id: string
25
26
  /** tab label */
26
27
  title: string
27
- /** shown green when rows() is empty after refresh (e.g. "no open asks …") */
28
+ /** shown green when rows() is empty after refresh, as in "no open asks" */
28
29
  emptyText?: string
29
30
  /** current rows — the store re-reads after every update() */
30
31
  rows: () => TuiRow[]
31
- /** (re)load data; call update() after each mutation so the view repaints.
32
+ /** (re)load data. Call update() after each mutation so the view repaints.
32
33
  * The spinner runs until every panel's returned promise settled. */
33
34
  refresh: (update: () => void) => void | Promise<void>
34
35
  }
@@ -0,0 +1,68 @@
1
+ // one hue per node, so linked pages share a colour family and distant ones
2
+ // differ. a spectral layout on the colour wheel: the two smoothest eigenvectors
3
+ // of the normalized link graph (hub weight damped, so an index linking
4
+ // everything cannot pull every node to one colour) place each node on a plane,
5
+ // its angle is its hue. then hues are rank equalized: the order around the
6
+ // wheel survives, the whole wheel is used. starts are hashed from ids, so a
7
+ // reload keeps every colour.
8
+ import type { MdGraph } from './md-graph.ts'
9
+
10
+ const ROUNDS = 300
11
+ /** an @ edge binds two pages harder than a lazy ref */
12
+ const EAGER_WEIGHT = 3
13
+
14
+ /** fnv-1a, 32 bit: a stable pseudo random start per id */
15
+ export function hashUnit(s: string): number {
16
+ let h = 0x811c9dc5
17
+ for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)
18
+ return (h >>> 0) / 2 ** 32
19
+ }
20
+
21
+ /** hue in degrees [0, 360) per node id */
22
+ export function nodeHues(g: MdGraph): Record<string, number> {
23
+ const ids = g.nodes.map((n) => n.id)
24
+ const index = new Map(ids.map((id, i) => [id, i]))
25
+ const deg = new Array<number>(ids.length).fill(0)
26
+ const links: { a: number; b: number; w: number }[] = []
27
+ for (const e of g.edges) {
28
+ const a = index.get(e.from), b = index.get(e.to)
29
+ if (a == null || b == null || a === b) continue
30
+ const w = e.eager ? EAGER_WEIGHT : 1
31
+ links.push({ a, b, w })
32
+ deg[a]! += w
33
+ deg[b]! += w
34
+ }
35
+ // power iteration on (I + D^-1/2 W D^-1/2) / 2, deflated against its top
36
+ // eigenvector sqrt(deg): what survives are the two smoothest directions
37
+ const top = deg.map((d) => Math.sqrt(d))
38
+ const topNorm = Math.hypot(...top) || 1
39
+ const mul = (v: number[]): number[] => {
40
+ const out = v.map((x) => x / 2)
41
+ for (const { a, b, w } of links) {
42
+ const k = w / Math.sqrt(deg[a]! * deg[b]!) / 2
43
+ out[a]! += v[b]! * k
44
+ out[b]! += v[a]! * k
45
+ }
46
+ return out
47
+ }
48
+ const dot = (u: number[], v: number[]) => u.reduce((s, x, i) => s + x * v[i]!, 0)
49
+ const orth = (v: number[], against: number[][]) => {
50
+ for (const u of against) { const k = dot(v, u); v = v.map((x, i) => x - k * u[i]!) }
51
+ const len = Math.hypot(...v) || 1
52
+ return v.map((x) => x / len)
53
+ }
54
+ const unitTop = top.map((x) => x / topNorm)
55
+ let u = ids.map((id) => hashUnit(id) - 0.5)
56
+ let v = ids.map((id) => hashUnit(`${id}#`) - 0.5)
57
+ for (let r = 0; r < ROUNDS; r++) {
58
+ u = orth(mul(u), [unitTop])
59
+ v = orth(mul(v), [unitTop, u])
60
+ }
61
+ // a node with no link sits at the origin, its hashed start gives its angle
62
+ const angle = ids.map((id, i) =>
63
+ deg[i] === 0 ? hashUnit(id) * 2 * Math.PI : Math.atan2(v[i]! / Math.sqrt(deg[i]!), u[i]! / Math.sqrt(deg[i]!)))
64
+ const order = ids.map((_, i) => i).sort((i, j) => angle[i]! - angle[j]!)
65
+ const out: Record<string, number> = {}
66
+ order.forEach((i, rank) => { out[ids[i]!] = (rank * 360) / ids.length })
67
+ return out
68
+ }
@@ -1,21 +1,19 @@
1
- // one MOUNTED md graph, as the hub serves it and as the graphs section shows
1
+ // one MOUNTED md wiki graph, as the hub serves it and as the wikis page shows
2
2
  // it. ONE list per repo (serve/run-serve builds it): the route, the card and
3
3
  // the lint verdict all read the same entries, so a card can never link at a
4
4
  // route that is not served.
5
5
  import type { MdGraph } from 'rvlib-shipkit/src/wiki/md-graph.ts'
6
6
 
7
7
  export type GraphMount = {
8
- /** route + card id: /graph/<id>, instance graph:<id> */
8
+ /** route + card id: /graph/<id>, instance wiki:<id> */
9
9
  id: string
10
- /** card text and tool label, e.g. `wiki graph`, `<repo> wiki` */
10
+ /** its tab on the wikis page and its tool label, e.g. `this repo's wiki`, `<repo> wiki`, `agent/` */
11
11
  label: string
12
12
  /** graph page title */
13
13
  title: string
14
14
  load: () => MdGraph
15
- /** null = someone else owns this graph's lint (a repo-owned provider, or a
16
- * wiki living in another repo): the card links, it never judges */
17
- lint: { root: string; index?: string } | null
18
- /** parenthesized on the CARD only, never in the terminal banner: why this
19
- * graph carries no verdict here */
20
- note?: string
15
+ /** what judges this graph: the built-in md lint over a dir, the repo's own
16
+ * checks by id (their last verdicts), or null for a wiki living in another
17
+ * repo, which the card links and never judges */
18
+ lint: { root: string; index?: string } | { checks: string[] } | null
21
19
  }
@@ -1,19 +1,23 @@
1
1
  // Self-contained HTML: a force-directed canvas graph (no deps, no CDN).
2
- // Small balls (group-colored, eager layer highlighted), directed edges with
3
- // arrowheads — solid = eager auto-include, dashed = lazy ref. Drag balls,
4
- // drag background to pan, wheel to zoom, hover to focus a node's links.
2
+ // Balls sized by hops from the index and coloured by link neighbourhood
3
+ // (graph-colors.ts), eager layer ringed. Directed solid edges with arrowheads,
4
+ // a thick purple one = eager auto-include, a thin grey one = lazy ref. Drag
5
+ // balls, drag background to pan, wheel to zoom, hover to focus a node's links.
6
+ import { nodeHues } from 'rvlib-shipkit/src/wiki/graph-colors.ts'
7
+ import { SAVE_AFTER_MS, saveOnceAfter, type Seed } from 'rvlib-shipkit/src/wiki/graph-positions.ts'
5
8
  import type { MdGraph } from 'rvlib-shipkit/src/wiki/md-graph.ts'
6
9
 
7
- const PALETTE = ['#e05252', '#e0a552', '#c6d34f', '#5ec95e', '#52c9b0', '#529fe0', '#8f6fe8', '#d46fd4', '#9a8a72']
8
-
9
- export function graphHtml(g: MdGraph, opts?: { title?: string }): string {
10
+ /** `seed` = where nodes start (graph-positions.ts). `saveUrl` = where the page
11
+ * POSTs its node positions once per display; absent = it never does */
12
+ export function graphHtml(g: MdGraph, opts?: { title?: string; seed?: Seed; saveUrl?: string }): string {
10
13
  // arbitrary wikis can hold hostile-looking titles/labels — keep the page unbreakable
11
14
  const title = (opts?.title ?? 'wiki graph').replace(/&/g, '&amp;').replace(/</g, '&lt;')
12
- const groups = [...new Set(g.nodes.map((n) => n.group ?? ''))].sort()
13
- const color = (grp: string | null) => PALETTE[groups.indexOf(grp ?? '') % PALETTE.length]!
15
+ const hues = nodeHues(g)
14
16
  const data = {
15
- nodes: g.nodes.map((n) => ({ ...n, color: n.eager ? '#8b5cf6' : color(n.group) })),
17
+ nodes: g.nodes.map((n) => ({ ...n, color: `oklch(0.74 0.15 ${Math.round(hues[n.id]!)})`, ...opts?.seed?.start[n.id] })),
16
18
  edges: g.edges,
19
+ alpha: opts?.seed?.alpha ?? 1,
20
+ saveUrl: opts?.saveUrl ?? null,
17
21
  }
18
22
  const dataJson = JSON.stringify(data).replaceAll('<', '\\u003c')
19
23
  return `<!doctype html>
@@ -33,7 +37,7 @@ export function graphHtml(g: MdGraph, opts?: { title?: string }): string {
33
37
  </style>
34
38
  </head>
35
39
  <body>
36
- <header><b>${title}</b><span>solid → @ co-load · dashed → lazy ref · purple = eager layer · number = hops from index · drag / pan / zoom · reload = regenerate</span></header>
40
+ <header><b>${title}</b><span>thick purple → @ co-load · thin → lazy ref · ringed = eager layer · number = hops from index, bigger = closer · colour = link neighbourhood · drag / pan / zoom · reload = regenerate</span></header>
37
41
  <canvas id="c"></canvas>
38
42
  <script>
39
43
  const DATA = ${dataJson}
@@ -42,14 +46,19 @@ const ctx = canvas.getContext('2d')
42
46
  const dark = matchMedia('(prefers-color-scheme: dark)').matches
43
47
  const N = DATA.nodes, byId = new Map(N.map(n => [n.id, n]))
44
48
  const E = DATA.edges.filter(e => byId.has(e.from) && byId.has(e.to))
49
+ const SIZE_BY_DEPTH = [3, 2, 1.5]
45
50
  const deg = new Map()
46
51
  for (const e of E) { deg.set(e.from, (deg.get(e.from) ?? 0) + 1); deg.set(e.to, (deg.get(e.to) ?? 0) + 1) }
47
52
  for (const n of N) {
48
- const a = Math.random() * Math.PI * 2, d = 120 + Math.random() * 240
49
- n.x = Math.cos(a) * d; n.y = Math.sin(a) * d; n.vx = 0; n.vy = 0
50
- n.r = 4 + Math.min(6, Math.sqrt(deg.get(n.id) ?? 0) * 1.6)
53
+ if (n.x == null || n.y == null) {
54
+ const a = Math.random() * Math.PI * 2, d = 120 + Math.random() * 240
55
+ n.x = Math.cos(a) * d; n.y = Math.sin(a) * d
56
+ }
57
+ n.vx = 0; n.vy = 0
58
+ // hops from the index set the size: 0 → x3, 1 → x2, 2 → x1.5, deeper or unreachable → x1
59
+ n.r = 6 * (SIZE_BY_DEPTH[n.depth] ?? 1) + Math.min(4, Math.sqrt(deg.get(n.id) ?? 0))
51
60
  }
52
- let scale = 1, ox = 0, oy = 0, alpha = 1, hover = null, drag = null, panning = null
61
+ let scale = 1, ox = 0, oy = 0, alpha = DATA.alpha, hover = null, drag = null, panning = null
53
62
  function resize() {
54
63
  const dpr = devicePixelRatio || 1
55
64
  canvas.width = innerWidth * dpr; canvas.height = innerHeight * dpr
@@ -66,8 +75,9 @@ function step() {
66
75
  const a = N[i], b = N[j]
67
76
  let dx = a.x - b.x, dy = a.y - b.y
68
77
  let d2 = dx * dx + dy * dy; if (d2 < 1) { dx = Math.random() - .5; dy = Math.random() - .5; d2 = 1 }
69
- const f = 1200 / d2
70
78
  const d = Math.sqrt(d2)
79
+ // plus a hard push while two balls overlap, big balls need room
80
+ const f = 4000 / d2 + Math.max(0, a.r + b.r + 24 - d) * 0.3
71
81
  dx /= d; dy /= d
72
82
  a.vx += dx * f; a.vy += dy * f; b.vx -= dx * f; b.vy -= dy * f
73
83
  }
@@ -75,12 +85,12 @@ function step() {
75
85
  const a = byId.get(e.from), b = byId.get(e.to)
76
86
  const dx = b.x - a.x, dy = b.y - a.y
77
87
  const d = Math.sqrt(dx * dx + dy * dy) || 1
78
- const rest = e.eager ? 60 : 110
88
+ const rest = (e.eager ? 90 : 180) + a.r + b.r
79
89
  const f = (d - rest) * (e.eager ? 0.02 : 0.008)
80
90
  a.vx += dx / d * f; a.vy += dy / d * f; b.vx -= dx / d * f; b.vy -= dy / d * f
81
91
  }
82
92
  for (const n of N) {
83
- n.vx -= n.x * 0.0015; n.vy -= n.y * 0.0015
93
+ n.vx -= n.x * 0.0008; n.vy -= n.y * 0.0008
84
94
  if (n === drag) { n.vx = 0; n.vy = 0; continue }
85
95
  n.x += n.vx * alpha; n.y += n.vy * alpha
86
96
  n.vx *= 0.85; n.vy *= 0.85
@@ -95,15 +105,13 @@ function draw() {
95
105
  for (const e of E) {
96
106
  const a = byId.get(e.from), b = byId.get(e.to)
97
107
  const lit = focus == null || e.from === hover.id || e.to === hover.id
98
- ctx.globalAlpha = lit ? (e.eager ? 1 : 0.55) : 0.08
108
+ ctx.globalAlpha = lit ? (e.eager ? 1 : 0.45) : 0.06
99
109
  ctx.strokeStyle = e.eager ? '#8b5cf6' : (dark ? '#8a8a95' : '#777')
100
- ctx.lineWidth = (e.eager ? 3.5 : 1) / scale
101
- ctx.setLineDash(e.eager ? [] : [4 / scale, 4 / scale])
110
+ ctx.lineWidth = (e.eager ? 5 : 1.2) / scale
102
111
  const dx = b.x - a.x, dy = b.y - a.y, d = Math.sqrt(dx * dx + dy * dy) || 1
103
- const tx = b.x - dx / d * (b.r + 3), ty = b.y - dy / d * (b.r + 3)
112
+ const gap = b.r + (b.eager ? 6 : 3) / scale, tx = b.x - dx / d * gap, ty = b.y - dy / d * gap
104
113
  ctx.beginPath(); ctx.moveTo(a.x + dx / d * a.r, a.y + dy / d * a.r); ctx.lineTo(tx, ty); ctx.stroke()
105
- ctx.setLineDash([])
106
- const ah = 5 / scale
114
+ const ah = (e.eager ? 7 : 4.5) / scale
107
115
  ctx.fillStyle = ctx.strokeStyle
108
116
  ctx.beginPath()
109
117
  ctx.moveTo(tx, ty)
@@ -116,9 +124,9 @@ function draw() {
116
124
  ctx.globalAlpha = lit ? 1 : 0.15
117
125
  ctx.fillStyle = n.color
118
126
  ctx.beginPath(); ctx.arc(n.x, n.y, n.r, 0, 7); ctx.fill()
119
- if (n.eager) { ctx.strokeStyle = '#8b5cf6'; ctx.lineWidth = 2 / scale; ctx.beginPath(); ctx.arc(n.x, n.y, n.r + 2.5 / scale, 0, 7); ctx.stroke() }
127
+ if (n.eager) { ctx.strokeStyle = '#8b5cf6'; ctx.lineWidth = 3 / scale; ctx.beginPath(); ctx.arc(n.x, n.y, n.r + 3 / scale, 0, 7); ctx.stroke() }
120
128
  if (n.depth != null && scale * n.r > 3) {
121
- ctx.fillStyle = '#fff'
129
+ ctx.fillStyle = '#16161c'
122
130
  ctx.font = '600 ' + n.r * 1.1 + 'px system-ui'
123
131
  ctx.textAlign = 'center'; ctx.textBaseline = 'middle'
124
132
  ctx.fillText(n.depth, n.x, n.y + n.r * 0.08)
@@ -138,6 +146,15 @@ function draw() {
138
146
  function loop() { step(); draw(); requestAnimationFrame(loop) }
139
147
  loop()
140
148
 
149
+ const saveOnceAfter = ${saveOnceAfter.toString()}
150
+ if (DATA.saveUrl != null) saveOnceAfter(${SAVE_AFTER_MS}, setTimeout, () => {
151
+ const pos = {}
152
+ for (const n of N) pos[n.id] = [Math.round(n.x), Math.round(n.y)]
153
+ fetch(DATA.saveUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(pos) })
154
+ .then(r => { if (!r.ok) console.warn('graph positions not saved: ' + r.status) })
155
+ .catch(e => console.warn('graph positions not saved: ' + e.message))
156
+ })()
157
+
141
158
  const world = (px, py) => [(px - ox) / scale, (py - oy) / scale]
142
159
  const nodeAt = (px, py) => {
143
160
  const [x, y] = world(px, py)
@@ -0,0 +1,105 @@
1
+ // where a graph's nodes sat last time, so the next display starts close to it.
2
+ // one json file per graph per checkout, `<git-common-dir>/shipkit/graph-positions/<id>.json`,
3
+ // never tracked. a start, never a pin: the layout still runs from there.
4
+ import { mkdirSync, readFileSync } from 'fs'
5
+ import { dirname, join } from 'path'
6
+ import { atomicWrite } from '../core/locked-write.ts'
7
+ import { shipkitRoot } from '../ledger/store.ts'
8
+ import type { MdGraph } from './md-graph.ts'
9
+
10
+ export type Positions = Record<string, [number, number]>
11
+
12
+ /** how long a display lays out before the page sends its positions, once */
13
+ export const SAVE_AFTER_MS = 3000
14
+ /** a POST body past this is refused: a few thousand nodes fit well under it */
15
+ export const MAX_POSITIONS_BYTES = 512 * 1024
16
+ /** from this share of seeded nodes on, the layout starts calm */
17
+ const CALM_FROM_SHARE = 0.5
18
+ const CALM_ALPHA = 0.25
19
+ const JITTER = 12
20
+
21
+ const safeId = (id: string): string => id.replace(/[^a-zA-Z0-9._-]/g, '_')
22
+
23
+ export const positionsFile = (root: string, graphId: string): string => join(shipkitRoot(root), 'graph-positions', `${safeId(graphId)}.json`)
24
+
25
+ const isPoint = (p: unknown): p is [number, number] =>
26
+ Array.isArray(p) && p.length === 2 && Number.isFinite(p[0]) && Number.isFinite(p[1])
27
+
28
+ /** keeps the well formed points of the ids in `keep`, rounded. anything else
29
+ * (a vanished node, a malformed entry) is dropped, never an error */
30
+ export function cleanPositions(raw: unknown, keep: ReadonlySet<string>): Positions {
31
+ const out: Positions = {}
32
+ if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) return out
33
+ for (const [id, p] of Object.entries(raw as Record<string, unknown>)) {
34
+ if (keep.has(id) && isPoint(p)) out[id] = [Math.round(p[0]), Math.round(p[1])]
35
+ }
36
+ return out
37
+ }
38
+
39
+ /** a missing or unreadable file is no memory, and the graph lays out as new */
40
+ export function readPositions(file: string): Positions {
41
+ try {
42
+ const doc = JSON.parse(readFileSync(file, 'utf8')) as { positions?: unknown }
43
+ const raw = doc.positions
44
+ if (raw == null || typeof raw !== 'object') return {}
45
+ return cleanPositions(raw, new Set(Object.keys(raw)))
46
+ } catch {
47
+ return {}
48
+ }
49
+ }
50
+
51
+ export function writePositions(file: string, positions: Positions): void {
52
+ mkdirSync(dirname(file), { recursive: true })
53
+ atomicWrite(file, `${JSON.stringify({ v: 1, positions }, null, 1)}\n`)
54
+ }
55
+
56
+ export type Seed = { start: Record<string, { x: number; y: number }>; alpha: number }
57
+
58
+ /** the starting point of every node that has one: its saved position, else
59
+ * the mean of its saved neighbours (links either way) plus a small jitter.
60
+ * a node with neither is absent, and the page places it as a new node */
61
+ export function seedPositions(g: MdGraph, saved: Positions, rand: () => number = Math.random): Seed {
62
+ const start: Seed['start'] = {}
63
+ for (const n of g.nodes) {
64
+ const p = saved[n.id]
65
+ if (p != null) start[n.id] = { x: p[0], y: p[1] }
66
+ }
67
+ for (const n of g.nodes) {
68
+ if (start[n.id] != null) continue
69
+ let sx = 0, sy = 0, k = 0
70
+ for (const e of g.edges) {
71
+ const other = e.from === n.id ? e.to : e.to === n.id ? e.from : null
72
+ const p = other == null ? undefined : saved[other]
73
+ if (p != null) { sx += p[0]; sy += p[1]; k++ }
74
+ }
75
+ if (k > 0) start[n.id] = { x: sx / k + (rand() - 0.5) * 2 * JITTER, y: sy / k + (rand() - 0.5) * 2 * JITTER }
76
+ }
77
+ const seeded = g.nodes.filter((n) => saved[n.id] != null).length
78
+ return { start, alpha: g.nodes.length > 0 && seeded / g.nodes.length >= CALM_FROM_SHARE ? CALM_ALPHA : 1 }
79
+ }
80
+
81
+ /** arms ONE delayed save per display: every later call is a no op. inlined
82
+ * into the graph page by its source text, so it must stay self contained */
83
+ export function saveOnceAfter(ms: number, later: (fn: () => void, ms: number) => unknown, save: () => void): () => void {
84
+ let armed = false
85
+ return () => {
86
+ if (armed) return
87
+ armed = true
88
+ later(save, ms)
89
+ }
90
+ }
91
+
92
+ /** the POST handler both servers mount: bounded body, ids checked against the
93
+ * graph as it is now, so a vanished node leaves the file on this save */
94
+ export async function acceptPositions(req: Request, file: string, g: () => MdGraph): Promise<Response> {
95
+ if (req.method !== 'POST') return new Response('POST only', { status: 405 })
96
+ const len = Number(req.headers.get('content-length') ?? 0)
97
+ if (len > MAX_POSITIONS_BYTES) return new Response('too large', { status: 413 })
98
+ const text = await req.text()
99
+ if (text.length > MAX_POSITIONS_BYTES) return new Response('too large', { status: 413 })
100
+ let body: unknown
101
+ try { body = JSON.parse(text) } catch { return new Response('not json', { status: 400 }) }
102
+ const positions = cleanPositions(body, new Set(g().nodes.map((n) => n.id)))
103
+ writePositions(file, positions)
104
+ return new Response(null, { status: 204 })
105
+ }
@@ -28,7 +28,7 @@ export async function runWikiServe(o: WikiScanOpts & { port: number; title?: str
28
28
  const rel = relative(process.cwd(), cfg.root)
29
29
  const title = o.title ?? (rel === '' ? basename(cfg.root) : `${basename(process.cwd())} ${rel}`)
30
30
  try {
31
- serveMdGraph({ port: o.port, title, load: () => scanMdGraph(cfg) })
31
+ serveMdGraph({ port: o.port, title, root: cfg.root, load: () => scanMdGraph(cfg) })
32
32
  say(`wiki graph → http://localhost:${o.port} (open in browser, or VSCode "Simple Browser: Show")`)
33
33
  // keep the server alive — the command must not resolve
34
34
  await new Promise(() => {})