rvlib-shipkit 0.38.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/CHANGELOG.md +367 -16
  2. package/README.md +26 -14
  3. package/package.json +2 -2
  4. package/src/assets/gen/comfyts-gen.ts +27 -10
  5. package/src/assets/gen/generate-roll.ts +1 -1
  6. package/src/assets/kit-config.ts +6 -6
  7. package/src/assets/run-assets.ts +14 -10
  8. package/src/assets/server/studio-server.ts +6 -5
  9. package/src/check.ts +343 -0
  10. package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
  11. package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
  12. package/src/checks/SK003-claude-md-shape.check.ts +66 -0
  13. package/src/checks/SK004-gitignore-policy.check.ts +309 -0
  14. package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
  15. package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
  16. package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
  17. package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
  18. package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
  19. package/src/checks/SK010-undeclared-unit.check.ts +111 -0
  20. package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
  21. package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
  22. package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
  23. package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
  24. package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
  25. package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
  26. package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
  27. package/src/checks/SK018-test-files-run.check.ts +28 -0
  28. package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
  29. package/src/checks/SK020-self-spawning-check.check.ts +147 -0
  30. package/src/checks/contracts.check.ts +20 -0
  31. package/src/cli/cmd_Asks.ts +3 -2
  32. package/src/cli/cmd_Assets.ts +2 -2
  33. package/src/cli/cmd_Bump.ts +1 -1
  34. package/src/cli/cmd_Check.ts +6 -6
  35. package/src/cli/cmd_Checks.ts +95 -0
  36. package/src/cli/cmd_Ci.ts +48 -13
  37. package/src/cli/cmd_Commands.ts +2 -6
  38. package/src/cli/cmd_ConfigKeys.ts +69 -0
  39. package/src/cli/cmd_Contracts.ts +1 -1
  40. package/src/cli/cmd_Fleet.ts +6 -2
  41. package/src/cli/cmd_Hooks.ts +1 -1
  42. package/src/cli/cmd_Keys.ts +43 -18
  43. package/src/cli/cmd_Mv.ts +2 -2
  44. package/src/cli/cmd_Prose.ts +10 -1
  45. package/src/cli/cmd_Releases.ts +2 -2
  46. package/src/cli/cmd_Serve.ts +1 -1
  47. package/src/cli/cmd_Start.ts +6 -6
  48. package/src/cli/cmd_Status.ts +25 -0
  49. package/src/cli/cmd_Tui.ts +2 -2
  50. package/src/cli.ts +7 -4
  51. package/src/collections/actions.ts +4 -4
  52. package/src/collections/checks.ts +258 -0
  53. package/src/collections/collection-types.ts +254 -1
  54. package/src/collections/comfy.ts +23 -0
  55. package/src/collections/md-body.ts +14 -0
  56. package/src/collections/pings.ts +38 -12
  57. package/src/collections/posts.ts +19 -3
  58. package/src/collections/providers.ts +198 -91
  59. package/src/collections/reflections.ts +41 -0
  60. package/src/collections/state-rules.ts +73 -0
  61. package/src/collections/test-time.ts +288 -0
  62. package/src/collections/tests.ts +181 -0
  63. package/src/collections/wikis.ts +66 -0
  64. package/src/comfy/comfy-panel.ts +292 -0
  65. package/src/contracts/apply-contracts.ts +17 -12
  66. package/src/contracts/contract-types.ts +16 -4
  67. package/src/contracts/contracts-engine.ts +78 -42
  68. package/src/contracts/manifests/discord.contract.ts +6 -6
  69. package/src/contracts/manifests/identity.contract.ts +3 -3
  70. package/src/contracts/manifests/itch.contract.ts +12 -15
  71. package/src/contracts/manifests/social.contract.ts +5 -5
  72. package/src/contracts/manifests/steam.contract.ts +16 -16
  73. package/src/contracts/run-contracts.ts +4 -3
  74. package/src/core/attach.ts +96 -0
  75. package/src/core/canonical-scripts.ts +25 -0
  76. package/src/core/check-ctx.ts +204 -14
  77. package/src/core/check-file-names.ts +112 -0
  78. package/src/core/check-pass.ts +84 -0
  79. package/src/core/check-runner.ts +796 -0
  80. package/src/core/config-keys.ts +256 -0
  81. package/src/core/config-rewrite.ts +77 -0
  82. package/src/core/discover-checks.ts +166 -90
  83. package/src/core/discover-legacy-checks.ts +26 -0
  84. package/src/core/doc-block.ts +13 -0
  85. package/src/core/floor-resolve.ts +264 -0
  86. package/src/core/floor.ts +20 -137
  87. package/src/core/git-bin.ts +24 -0
  88. package/src/core/git-root.ts +108 -0
  89. package/src/core/instance.ts +195 -0
  90. package/src/core/jobs.ts +118 -0
  91. package/src/core/layout.ts +52 -0
  92. package/src/core/legacy-shapes.ts +207 -0
  93. package/src/core/lib-root.ts +5 -0
  94. package/src/core/locked-write.ts +110 -39
  95. package/src/core/machine-commit.ts +5 -4
  96. package/src/core/migrate-layout.ts +557 -0
  97. package/src/core/motherhome.ts +2 -8
  98. package/src/core/nesting.ts +4 -12
  99. package/src/core/one-way-in-policy.ts +73 -17
  100. package/src/core/outcome-words.ts +45 -0
  101. package/src/core/package-manager.ts +86 -0
  102. package/src/core/parallel.ts +21 -0
  103. package/src/core/process-tree.ts +44 -0
  104. package/src/core/raw-access.ts +93 -0
  105. package/src/core/read-set.ts +152 -0
  106. package/src/core/run-check.ts +155 -213
  107. package/src/core/run-ci.ts +153 -189
  108. package/src/core/run-fix.ts +96 -0
  109. package/src/core/run-fixers.ts +53 -30
  110. package/src/core/self-update.ts +9 -3
  111. package/src/core/slots.ts +83 -0
  112. package/src/core/spawn-timeout.ts +34 -3
  113. package/src/core/when-block.ts +401 -0
  114. package/src/core/when.ts +135 -0
  115. package/src/core/write-status.ts +5 -2
  116. package/src/hooks/leak-guard.ts +10 -8
  117. package/src/hooks/one-way-in-guard.ts +13 -5
  118. package/src/hooks/root-identity.ts +2 -1
  119. package/src/hooks/run-hooks.ts +35 -23
  120. package/src/init-repo.ts +39 -23
  121. package/src/ledger/check-stats.ts +155 -0
  122. package/src/ledger/check-table.ts +179 -0
  123. package/src/ledger/db.ts +255 -0
  124. package/src/ledger/gate-checks.ts +433 -0
  125. package/src/ledger/git-key.ts +116 -77
  126. package/src/ledger/git-scan.ts +96 -35
  127. package/src/ledger/ledger.ts +116 -42
  128. package/src/ledger/live-runs.ts +111 -0
  129. package/src/ledger/live-timeline.ts +97 -0
  130. package/src/ledger/repo-view.ts +73 -0
  131. package/src/ledger/run-log.ts +448 -0
  132. package/src/ledger/run-timeline.ts +173 -0
  133. package/src/ledger/store.ts +23 -12
  134. package/src/ledger/suite-log.ts +161 -0
  135. package/src/ledger/toolchain-identity.ts +7 -1
  136. package/src/ledger/world-cache.ts +111 -0
  137. package/src/mv/mv-md.ts +16 -6
  138. package/src/problems/extract.ts +182 -0
  139. package/src/problems/problem-board.ts +37 -0
  140. package/src/problems/problem-line.ts +77 -0
  141. package/src/prose/prose-view.ts +90 -0
  142. package/src/prose/run-prose.ts +60 -19
  143. package/src/prose/ste.ts +140 -24
  144. package/src/reflections/reflections-queue.ts +81 -0
  145. package/src/release/git-facts.ts +15 -9
  146. package/src/release/run-bump.ts +6 -5
  147. package/src/release/run-release.ts +25 -11
  148. package/src/release/run-releases.ts +2 -2
  149. package/src/release/unit-gate.ts +7 -54
  150. package/src/release/unit-resolve.ts +282 -0
  151. package/src/release/units.ts +33 -217
  152. package/src/repo-config.ts +155 -85
  153. package/src/rules/rule.ts +36 -96
  154. package/src/rules/untrack-generated.ts +5 -4
  155. package/src/scaffold/files/vscode-tasks.json +10 -31
  156. package/src/scaffold/jsonc-spans.ts +113 -0
  157. package/src/scaffold/project-scaffold.ts +16 -43
  158. package/src/scan.ts +98 -0
  159. package/src/serve/gate-trigger.ts +148 -0
  160. package/src/serve/health-schedule.ts +102 -0
  161. package/src/serve/hub-app/chart-view.ts +179 -0
  162. package/src/serve/hub-app/check-view.tsx +345 -0
  163. package/src/serve/hub-app/check-words.ts +164 -0
  164. package/src/serve/hub-app/checks-svc.ts +280 -0
  165. package/src/serve/hub-app/clock-svc.ts +33 -0
  166. package/src/serve/hub-app/col-card.tsx +23 -39
  167. package/src/serve/hub-app/cols-svc.ts +44 -20
  168. package/src/serve/hub-app/comfy-svc.ts +171 -0
  169. package/src/serve/hub-app/comfy-view.tsx +81 -0
  170. package/src/serve/hub-app/dur.ts +41 -0
  171. package/src/serve/hub-app/hash-route.ts +121 -0
  172. package/src/serve/hub-app/header-bar.tsx +117 -0
  173. package/src/serve/hub-app/health-svc.ts +23 -28
  174. package/src/serve/hub-app/help.tsx +29 -0
  175. package/src/serve/hub-app/hub-app.tsx +122 -119
  176. package/src/serve/hub-app/hub-st.ts +115 -42
  177. package/src/serve/hub-app/hub-types.ts +117 -2
  178. package/src/serve/hub-app/hub.css +534 -192
  179. package/src/serve/hub-app/index.html +2 -0
  180. package/src/serve/hub-app/live-svc.ts +174 -0
  181. package/src/serve/hub-app/md.ts +15 -0
  182. package/src/serve/hub-app/media-svc.ts +4 -1
  183. package/src/serve/hub-app/media-view.tsx +15 -0
  184. package/src/serve/hub-app/next-run.ts +68 -0
  185. package/src/serve/hub-app/project-gutter.css +65 -0
  186. package/src/serve/hub-app/project-gutter.tsx +128 -0
  187. package/src/serve/hub-app/projects-svc.ts +226 -0
  188. package/src/serve/hub-app/prose-hits-st.ts +124 -0
  189. package/src/serve/hub-app/prose-hits-view.tsx +176 -0
  190. package/src/serve/hub-app/prose-hits.css +21 -0
  191. package/src/serve/hub-app/run-strip.ts +47 -0
  192. package/src/serve/hub-app/runs-tabs.ts +24 -0
  193. package/src/serve/hub-app/test-view.tsx +107 -0
  194. package/src/serve/hub-app/tests-svc.ts +62 -0
  195. package/src/serve/hub-app/tests-view.tsx +238 -0
  196. package/src/serve/hub-app/time-ago.tsx +34 -0
  197. package/src/serve/hub-app/timeline-chart.tsx +274 -0
  198. package/src/serve/hub-app/timeline-geometry.ts +314 -0
  199. package/src/serve/hub-app/timeline-svc.ts +62 -0
  200. package/src/serve/hub-app/timeline-view.tsx +245 -0
  201. package/src/serve/hub-app/tree-build.ts +410 -0
  202. package/src/serve/hub-app/tree-row.tsx +42 -0
  203. package/src/serve/hub-app/tree-svc.ts +174 -0
  204. package/src/serve/hub-app/wikis-view.tsx +53 -0
  205. package/src/serve/other-repos.ts +79 -0
  206. package/src/serve/project-gutter-route.ts +350 -0
  207. package/src/serve/project-gutter.ts +449 -0
  208. package/src/serve/run-serve.ts +245 -16
  209. package/src/serve/same-origin.ts +14 -0
  210. package/src/serve/serve-hub.ts +213 -18
  211. package/src/shipkit.ts +19 -12
  212. package/src/smoke/run-smoke.ts +3 -2
  213. package/src/start-repo.ts +75 -36
  214. package/src/suite/judge-suites.ts +209 -15
  215. package/src/suite/kept-timings.ts +58 -0
  216. package/src/suite/runner-shapes.ts +88 -0
  217. package/src/suite/suite-cli.ts +147 -24
  218. package/src/suite/suite-cpu-preload.ts +92 -0
  219. package/src/suite/test-units.ts +243 -0
  220. package/src/ts/config.ts +4 -69
  221. package/src/ts/jsonc.ts +69 -0
  222. package/src/ts/lex.ts +143 -0
  223. package/src/ts/rename.ts +73 -10
  224. package/src/ts/source-graph.ts +28 -8
  225. package/src/ts/tsconfig-projects.ts +6 -97
  226. package/src/ts/tsconfig-resolve.ts +108 -0
  227. package/src/tui/build-steps-panel.ts +61 -0
  228. package/src/tui/checks-panel.ts +24 -16
  229. package/src/tui/run-tui.ts +2 -2
  230. package/src/tui/tui-panel.ts +5 -4
  231. package/src/wiki/graph-colors.ts +68 -0
  232. package/src/wiki/graph-mount.ts +7 -9
  233. package/src/wiki/graph-page.ts +42 -25
  234. package/src/wiki/graph-positions.ts +105 -0
  235. package/src/wiki/run-wiki.ts +1 -1
  236. package/src/wiki/serve-graph.ts +33 -3
  237. package/src/cli/cmd_Rules.ts +0 -51
  238. package/src/cli/cmd_Units.ts +0 -82
  239. package/src/collections/graphs.ts +0 -32
  240. package/src/contracts/contracts-once.ts +0 -43
  241. package/src/core/check-suite.ts +0 -302
  242. package/src/core/repair-contract-dirs.ts +0 -136
  243. package/src/core/task-runner.ts +0 -339
  244. package/src/ledger/gate-units.ts +0 -138
  245. package/src/ledger/unit-table.ts +0 -105
  246. package/src/rules/SK002-contracts-fulfillness.ts +0 -24
  247. package/src/rules/SK003-claude-md-shape.ts +0 -70
  248. package/src/rules/SK004-gitignore-policy.ts +0 -238
  249. package/src/rules/SK010-undeclared-release-unit.ts +0 -127
  250. package/src/rules/rule-registry.ts +0 -64
  251. package/src/rules/run-fix.ts +0 -72
  252. package/src/tui/tasks-panel.ts +0 -60
@@ -0,0 +1,173 @@
1
+ // THE RUN TIMELINE, shaped from the run record: every invocation on record
2
+ // (`shipkit ci`, `shipkit check`, the hub's buttons and its daily pass), each
3
+ // check a lane with its bar at its real start and end, and three lists beside
4
+ // it: the slowest checks, the longest invocations, the test files to fix.
5
+ //
6
+ // Pure: the record is read by the caller, so every rule here is pinned on a
7
+ // seeded record without a disk. Nothing is estimated. A check's start is its
8
+ // finish minus what it measured, which is exactly what the runner timed
9
+ // (core/check-runner.ts finish), and a replay measured nothing, so its bar is a
10
+ // tick at the moment it was answered.
11
+ import type { InvocationRow, InvokedBy, RunRow, RunStatus } from './run-log.ts'
12
+ import type { SuiteRow } from './suite-log.ts'
13
+
14
+ /** how many invocations the timeline draws, newest first */
15
+ export const TIMELINE_INVOCATIONS = 12
16
+ /** how many rows each list keeps */
17
+ export const TIMELINE_LIST = 12
18
+ /** past this share of its ceiling a check is flagged near it: the same share
19
+ * the hub's duration tone turns red at (hub-app/dur.ts) */
20
+ export const NEAR_CEILING_SHARE = 0.5
21
+
22
+ /** one check run. `running` = still going: its end is the moment the view
23
+ * was shaped, and a drawing carries it to now */
24
+ export type TimelineBar = { id: string; start: number; end: number; ms: number; status: RunStatus; replayed: boolean; running?: boolean }
25
+
26
+ export type TimelineInvocation = {
27
+ runId: string
28
+ /** `unknown` for rows written before invocations were recorded */
29
+ kind: 'ci' | 'check' | 'unknown'
30
+ /** who started it. A starter this build does not name stays the word the
31
+ * record holds */
32
+ by: InvokedBy | 'unknown' | (string & {})
33
+ label: string
34
+ started: number
35
+ ended: number
36
+ wallMs: number
37
+ /** one lane per check, in the order they started */
38
+ bars: TimelineBar[]
39
+ reds: number
40
+ replays: number
41
+ /** still going: `ended` is the moment the view was shaped */
42
+ running?: boolean
43
+ }
44
+
45
+ /** an invocation in a list: its numbers without its lanes */
46
+ export type InvocationSummary = Omit<TimelineInvocation, 'bars'> & { checks: number }
47
+
48
+ export type SlowCheck = { id: string; meanMs: number; worstMs: number; samples: number; ceilingMs: number | null; flag: 'over' | 'near' | null }
49
+
50
+ export type SuiteToFix = {
51
+ check: string; unit: string; file: string; at: number; ms: number; budgetMs: number; tier: 'quick' | 'full'; red: boolean; slow: boolean
52
+ /** the first failure bun printed for it, when the record kept one */
53
+ failLine?: string
54
+ }
55
+
56
+ export type Timeline = {
57
+ invocations: TimelineInvocation[]
58
+ slowest: SlowCheck[]
59
+ longest: InvocationSummary[]
60
+ testFiles: SuiteToFix[]
61
+ /** whether a suite record exists at all: an empty list means "nothing to
62
+ * fix" only when something was recorded */
63
+ suitesRecorded: boolean
64
+ /** the runner's cap, when the caller knows it: what the concurrency band
65
+ * draws its line at */
66
+ cap?: number
67
+ }
68
+
69
+ export type TimelineInput = {
70
+ runs: RunRow[]
71
+ invocations: InvocationRow[]
72
+ suites: SuiteRow[]
73
+ /** each check's kill time, ms */
74
+ ceilings: Record<string, number>
75
+ /** the checks the repo declares today. Given, `slowest` lists only these:
76
+ * a deleted check's rows stay in the run history, and no edit can make it
77
+ * faster */
78
+ inventory?: readonly string[]
79
+ /** suites declared slow: the quick tier leaves them out, so the budget is
80
+ * not theirs to break */
81
+ declaredSlow?: string[]
82
+ cap?: number
83
+ keep?: number
84
+ listSize?: number
85
+ }
86
+
87
+ const barOf = (r: RunRow): TimelineBar => ({ id: r.id, start: r.at - r.ms, end: r.at, ms: r.ms, status: r.status, replayed: r.replayed })
88
+
89
+ /** every invocation on record, newest first, each with its lanes */
90
+ export function invocationsOf(runs: RunRow[], invocations: InvocationRow[]): TimelineInvocation[] {
91
+ const byRun = new Map<string, RunRow[]>()
92
+ for (const r of runs) {
93
+ const list = byRun.get(r.runId) ?? []
94
+ list.push(r)
95
+ byRun.set(r.runId, list)
96
+ }
97
+ const meta = new Map(invocations.map((i) => [i.runId, i]))
98
+ const out: TimelineInvocation[] = []
99
+ for (const [runId, rows] of byRun) {
100
+ const bars = rows.map(barOf).sort((a, b) => a.start - b.start || a.id.localeCompare(b.id))
101
+ const inv = meta.get(runId)
102
+ const first = Math.min(...bars.map((b) => b.start))
103
+ const last = Math.max(...bars.map((b) => b.end))
104
+ const started = inv != null ? Math.min(inv.started, first) : first
105
+ const ended = inv != null ? Math.max(inv.ended, last) : last
106
+ out.push({
107
+ runId,
108
+ kind: inv?.kind ?? 'unknown',
109
+ by: inv?.by ?? 'unknown',
110
+ label: inv?.label ?? 'a run recorded before invocations were',
111
+ started,
112
+ ended,
113
+ wallMs: ended - started,
114
+ bars,
115
+ reds: bars.filter((b) => b.status === 'fail').length,
116
+ replays: bars.filter((b) => b.replayed).length,
117
+ })
118
+ }
119
+ return out.sort((a, b) => b.started - a.started)
120
+ }
121
+
122
+ /** the checks that cost the most when they really run: mean and worst of the
123
+ * MEASURED runs, flagged when the worst reached its ceiling or half of it */
124
+ export function slowestChecks(runs: RunRow[], ceilings: Record<string, number>, size = TIMELINE_LIST, inventory?: readonly string[]): SlowCheck[] {
125
+ const live = inventory == null ? null : new Set(inventory)
126
+ const ms = new Map<string, number[]>()
127
+ for (const r of runs) {
128
+ if (r.replayed || r.status === 'skip') continue
129
+ if (live != null && !live.has(r.id)) continue
130
+ const list = ms.get(r.id) ?? []
131
+ list.push(r.ms)
132
+ ms.set(r.id, list)
133
+ }
134
+ const out: SlowCheck[] = []
135
+ for (const [id, list] of ms) {
136
+ const worstMs = Math.max(...list)
137
+ const ceilingMs = ceilings[id] ?? null
138
+ const flag = ceilingMs == null ? null : worstMs >= ceilingMs ? 'over' : worstMs > ceilingMs * NEAR_CEILING_SHARE ? 'near' : null
139
+ out.push({ id, meanMs: Math.round(list.reduce((a, b) => a + b, 0) / list.length), worstMs, samples: list.length, ceilingMs, flag })
140
+ }
141
+ return out.sort((a, b) => b.meanMs - a.meanMs || a.id.localeCompare(b.id)).slice(0, size)
142
+ }
143
+
144
+ /** each suite's LAST recorded run, kept when it was red or broke the quick
145
+ * tier budget. Red first, then the slowest */
146
+ export function testFilesToFix(suites: SuiteRow[], declaredSlow: string[] = []): SuiteToFix[] {
147
+ const slowDecl = new Set(declaredSlow.map((f) => f.replace(/^\.\//, '')))
148
+ const last = new Map<string, SuiteRow>()
149
+ for (const s of suites) {
150
+ const prev = last.get(s.file)
151
+ if (prev == null || s.at >= prev.at) last.set(s.file, s)
152
+ }
153
+ const out: SuiteToFix[] = []
154
+ for (const s of last.values()) {
155
+ const red = s.status === 'fail'
156
+ const slow = s.tier === 'quick' && s.ms > s.budgetMs && !slowDecl.has(s.file)
157
+ if (red || slow) out.push({ check: s.check, unit: s.unit, file: s.file, at: s.at, ms: s.ms, budgetMs: s.budgetMs, tier: s.tier, red, slow, ...(red && s.failLine != null && s.failLine !== '' ? { failLine: s.failLine } : {}) })
158
+ }
159
+ return out.sort((a, b) => Number(b.red) - Number(a.red) || b.ms - a.ms || a.file.localeCompare(b.file))
160
+ }
161
+
162
+ export function shapeTimeline(o: TimelineInput): Timeline {
163
+ const all = invocationsOf(o.runs, o.invocations)
164
+ const size = o.listSize ?? TIMELINE_LIST
165
+ return {
166
+ invocations: all.slice(0, o.keep ?? TIMELINE_INVOCATIONS),
167
+ slowest: slowestChecks(o.runs, o.ceilings, size, o.inventory),
168
+ longest: [...all].sort((a, b) => b.wallMs - a.wallMs || b.started - a.started).slice(0, size).map(({ bars, ...rest }) => ({ ...rest, checks: bars.length })),
169
+ testFiles: testFilesToFix(o.suites, o.declaredSlow),
170
+ suitesRecorded: o.suites.length > 0,
171
+ ...(o.cap == null ? {} : { cap: o.cap }),
172
+ }
173
+ }
@@ -1,5 +1,6 @@
1
- // ONE STORE. Everything this toolkit remembers between runs lives here, in
2
- // one shape, under one root, busted by one flag.
1
+ // ONE STORE for every ANSWER this toolkit remembers between runs, in one
2
+ // shape, under one root, busted by one flag. What a run COST lives beside it
3
+ // in `ledger/db.ts`, which nothing about a verdict ever reads.
3
4
  //
4
5
  // There used to be two: `tmp/.shipkit-cache` for the check orchestrator and
5
6
  // `tmp/.check-cache` for the page checks, each with its own bust env var, each
@@ -24,9 +25,16 @@
24
25
  // keys and simply miss each other (ledger/toolchain-identity.ts owns the
25
26
  // measurement). It is also outside the work tree, so nothing here can dirty a
26
27
  // diff or reach a commit.
28
+ //
29
+ // The store dir holds keyed entries and NOTHING else: every file under it is
30
+ // one whole json document, so a reader can walk it and parse all of it. The
31
+ // reporting record beside it (the run db) lives one level up, in
32
+ // `shipkitRoot`, because it is not keyed and is not a json document.
27
33
  import { mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'fs'
28
34
  import { dirname, join } from 'path'
35
+ import { gitRoot } from '../core/git-root.ts'
29
36
  import { atomicWrite } from '../core/locked-write.ts'
37
+ import { LAYOUT } from '../core/layout.ts'
30
38
 
31
39
  /** the storage FORMAT. Bumped when the envelope changes, so an old file can
32
40
  * never be handed to a new reader. The KEY carries everything about the
@@ -44,7 +52,7 @@ export type Stored<T> = {
44
52
  key: string
45
53
  /** when it was written, ms since epoch */
46
54
  at: number
47
- /** how long THIS entry stays valid, when that is shorter than the unit's
55
+ /** how long THIS entry stays valid, when that is shorter than the check's
48
56
  * declared TTL. A measured negative writes one, so a red about the world
49
57
  * clears soon after a human fixes the world, while a clean answer keeps
50
58
  * for the full declared window. Absent means the declared TTL. */
@@ -56,19 +64,22 @@ export type Stored<T> = {
56
64
  * declares, so a live entry is never swept out from under a replay. */
57
65
  export const KEEP_MS = 30 * 24 * 60 * 60 * 1000
58
66
 
59
- /** where this repo's remembered answers live.
67
+ /** the one dir everything this repo remembers hangs off.
60
68
  *
61
69
  * `--git-common-dir` rather than `--git-dir`: a linked worktree has its own
62
70
  * git dir but shares the common one, and slice 3 measured that sharing is
63
71
  * SAFE here because every key component is content or a version string.
64
- * A tree that is not a git repo at all still gets a store, under `tmp/`,
72
+ * A tree that is not a git repo at all still gets one, under `.shipkit/cache/`,
65
73
  * because a scaffold in progress must not crash on it. */
66
- export function storeRoot(root: string): string {
67
- const p = Bun.spawnSync(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
68
- const dir = p.exitCode === 0 ? p.stdout.toString().trim() : ''
69
- return dir === '' ? join(root, 'tmp/.shipkit-store') : join(dir, 'shipkit/store')
74
+ export function shipkitRoot(root: string): string {
75
+ const dir = gitRoot(root)?.commonDir
76
+ return dir == null ? join(root, LAYOUT.cache) : join(dir, 'shipkit')
70
77
  }
71
78
 
79
+ /** where this repo's remembered ANSWERS live: keyed entries only, one whole
80
+ * json document per file, nothing else at any depth. */
81
+ export const storeRoot = (root: string): string => join(shipkitRoot(root), 'store')
82
+
72
83
  const slug = (s: string): string => s.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 60)
73
84
 
74
85
  /** where ONE answer lives. The key is in the file NAME, so a run whose key
@@ -81,7 +92,7 @@ export function entryPath(store: string, ns: string, name: string, key: string):
81
92
  }
82
93
 
83
94
  /** a missing or half-written file reads as "no answer", which is the safe
84
- * side: the unit runs. tmp + rename means half-written cannot happen from a
95
+ * side: the check runs. tmp + rename means half-written cannot happen from a
85
96
  * shipkit write, so this only ever catches a truncated file from something
86
97
  * else, and treats it the same way. */
87
98
  export function readStored<T>(path: string, key: string): Stored<T> | null {
@@ -106,8 +117,8 @@ export function dropStored(path: string): void {
106
117
  try { rmSync(path, { force: true }) } catch { /* raced with another runner: it is gone either way */ }
107
118
  }
108
119
 
109
- /** age-based housekeeping. Sweeping by "the units I know about" is what made
110
- * the old prune wrong: a run narrowed with --only knows about one unit and
120
+ /** age-based housekeeping. Sweeping by "the checks I know about" is what made
121
+ * the old prune wrong: a run narrowed with --only knows about one check and
111
122
  * would delete the other sixty-five. */
112
123
  export function sweepStore(store: string, ns: string, now: number, keepMs = KEEP_MS): void {
113
124
  const dir = join(store, slug(ns))
@@ -0,0 +1,161 @@
1
+ // THE SUITE RECORD — what every test suite cost and whether it passed, per run
2
+ // of the suite runner (src/suite/suite-cli.ts). The runner already timed every
3
+ // suite for its budget and threw the numbers away with its temp dir, so "which
4
+ // tests are slow, which are red" had no answer after the run.
5
+ //
6
+ // Same file and same rules as the run record (ledger/run-log.ts): never read by
7
+ // the gate, one transaction per write, swept by age with the other tables, and
8
+ // a db that cannot be written costs the history one warn line, never a verdict.
9
+ import { treePrefix } from '../core/nesting.ts'
10
+ import { INSERT_SUITE, INSERT_SUITE_RUN, openDb } from './db.ts'
11
+ import { runsPath, type RunStatus } from './run-log.ts'
12
+
13
+ export type SuiteRow = {
14
+ /** epoch ms, when the run that timed it ended */
15
+ at: number
16
+ /** the suite path, repo relative */
17
+ file: string
18
+ ms: number
19
+ status: Extract<RunStatus, 'pass' | 'fail'>
20
+ tier: 'quick' | 'full'
21
+ /** the per suite budget the quick tier held it to */
22
+ budgetMs: number
23
+ /** the check whose suite runner timed it (`test`, `test:rvlib-shipkit`), '' when
24
+ * the runner ran outside a check or before the record named it */
25
+ check: string
26
+ /** the unit whose tests these are (`root`, `rvlib-shipkit`), '' when
27
+ * the runner was not told, or before the record named it */
28
+ unit: string
29
+ /** CPU the file burned on its own, ms: known only where a process ran it
30
+ * alone (an over budget file run again by itself, a phase of one file).
31
+ * null or absent everywhere else, because a shared worker's CPU is not
32
+ * any one file's */
33
+ cpuMs?: number | null
34
+ /** the runner phase that timed it: `parallel` (the quick tier's workers),
35
+ * `sequential` (the first phase, one file at a time), `serial` (the
36
+ * declared serial files, after it). '' or absent before the record named it */
37
+ phase?: SuitePhase | ''
38
+ /** its wall time in that phase, as bun timed it among the others. `ms` is
39
+ * the time the budget judged, which an alone rerun replaces with the
40
+ * file's own CPU or wall; this is what the file cost the shared run */
41
+ phaseMs?: number | null
42
+ /** the first failure bun printed for it, '' or absent when it passed or
43
+ * before the record kept it */
44
+ failLine?: string
45
+ }
46
+
47
+ export type SuitePhase = 'parallel' | 'sequential' | 'serial'
48
+
49
+ /** one run of the suite runner as a whole: where its wall time went. The
50
+ * check's own run (the run record) is this plus the process that hosts it */
51
+ export type SuiteRunRow = {
52
+ /** the same `at` as the suite rows it timed */
53
+ at: number
54
+ check: string
55
+ unit: string
56
+ tier: 'quick' | 'full'
57
+ /** the runner, first line to the record write */
58
+ wallMs: number
59
+ /** before the first phase: probing bun, reading the bunfig and the pool */
60
+ setupMs: number
61
+ /** the parallel workers, 0 = one file at a time */
62
+ workers: number
63
+ files: number
64
+ parallelMs: number
65
+ parallelCpuMs: number | null
66
+ /** null = no serial phase */
67
+ serialMs: number | null
68
+ serialCpuMs: number | null
69
+ serialFiles: number
70
+ /** the over budget files, run again one by one before they can fail */
71
+ aloneMs: number
72
+ aloneFiles: number
73
+ /** the machine's one minute load and its cores when the runner judged */
74
+ load1: number | null
75
+ cores: number | null
76
+ }
77
+
78
+ /** how a check runner tells the process it spawns which check it is: the
79
+ * suite runner files every suite it times under it */
80
+ export const CHECK_ID_ENV = 'SHIPKIT_CHECK_ID'
81
+
82
+ /** one write for the whole suite run, each file named from the top of the
83
+ * work tree. Returns the warn line a caller prints when the record could not
84
+ * be written, null when it was */
85
+ export function recordSuites(root: string, rows: SuiteRow[], run?: SuiteRunRow): string | null {
86
+ if (rows.length === 0) return null
87
+ // a unit's runner names its suites from the unit's own dir, while
88
+ // the record and the hub read paths from the top of the work tree
89
+ const prefix = treePrefix(root)
90
+ let path = ''
91
+ try { path = runsPath(root) } catch { return 'WARN suite timings not recorded: the run record has no home here' }
92
+ const opened = openDb(path)
93
+ if (opened == null) return `WARN suite timings not recorded, the run record did not open (${path})`
94
+ try {
95
+ const insert = opened.db.query(INSERT_SUITE)
96
+ opened.db.transaction(() => {
97
+ for (const r of rows)
98
+ insert.run(r.at, `${prefix}${r.file}`, Math.round(r.ms), r.status, r.tier, Math.round(r.budgetMs), r.check, r.unit, r.cpuMs == null ? null : Math.round(r.cpuMs), r.phase ?? '', r.phaseMs == null ? null : Math.round(r.phaseMs), r.failLine ?? '')
99
+ if (run != null)
100
+ opened.db.query(INSERT_SUITE_RUN).run(run.at, run.check, run.unit, run.tier, Math.round(run.wallMs), Math.round(run.setupMs), run.workers, run.files, Math.round(run.parallelMs),
101
+ roundOrNull(run.parallelCpuMs), roundOrNull(run.serialMs), roundOrNull(run.serialCpuMs), run.serialFiles, Math.round(run.aloneMs), run.aloneFiles, run.load1, run.cores)
102
+ }).immediate()
103
+ return null
104
+ } catch {
105
+ return `WARN suite timings not recorded, the run record refused the write (${path})`
106
+ } finally {
107
+ try { opened.db.close() } catch { /* already gone */ }
108
+ }
109
+ }
110
+
111
+ const roundOrNull = (n: number | null): number | null => (n == null ? null : Math.round(n))
112
+ const numOrNull = (n: unknown): number | null => (n == null ? null : Number(n))
113
+ const PHASES: readonly string[] = ['parallel', 'sequential', 'serial'] satisfies SuitePhase[]
114
+
115
+ /** every suite row on record, oldest first. An unreadable db is none */
116
+ export function readSuites(path: string): SuiteRow[] {
117
+ const opened = openDb(path, { create: false })
118
+ if (opened == null) return []
119
+ try {
120
+ const rows = opened.db.query('select at, file, ms, status, tier, budget_ms as budgetMs, check_id as checkId, unit, cpu_ms as cpuMs, phase, phase_ms as phaseMs, fail_line as failLine from suites order by at asc, rowid asc').all() as {
121
+ at: number; file: string; ms: number; status: string; tier: string; budgetMs: number; checkId: string; unit: string; cpuMs: number | null; phase: string; phaseMs: number | null; failLine: string
122
+ }[]
123
+ return rows.map((r): SuiteRow => ({
124
+ at: Number(r.at), file: String(r.file), ms: Number(r.ms), status: r.status === 'fail' ? 'fail' : 'pass',
125
+ tier: r.tier === 'full' ? 'full' : 'quick', budgetMs: Number(r.budgetMs), check: String(r.checkId ?? ''), unit: String(r.unit ?? ''),
126
+ // each one only where the record holds it, so a row written before
127
+ // the record kept them reads exactly as it was written
128
+ ...(r.cpuMs == null ? {} : { cpuMs: Number(r.cpuMs) }),
129
+ ...(PHASES.includes(r.phase) ? { phase: r.phase as SuitePhase } : {}),
130
+ ...(r.phaseMs == null ? {} : { phaseMs: Number(r.phaseMs) }),
131
+ ...(r.failLine == null || r.failLine === '' ? {} : { failLine: String(r.failLine) }),
132
+ }))
133
+ } catch {
134
+ return []
135
+ } finally {
136
+ try { opened.db.close() } catch { /* already gone */ }
137
+ }
138
+ }
139
+
140
+ /** every run of the suite runner on record, oldest first. An unreadable db,
141
+ * or one written before the table existed, is none */
142
+ export function readSuiteRuns(path: string): SuiteRunRow[] {
143
+ const opened = openDb(path, { create: false })
144
+ if (opened == null) return []
145
+ try {
146
+ const rows = opened.db.query(
147
+ 'select at, check_id as checkId, unit, tier, wall_ms as wallMs, setup_ms as setupMs, workers, files, parallel_ms as parallelMs, parallel_cpu_ms as parallelCpuMs,' +
148
+ ' serial_ms as serialMs, serial_cpu_ms as serialCpuMs, serial_files as serialFiles, alone_ms as aloneMs, alone_files as aloneFiles, load1, cores from suite_runs order by at asc, rowid asc',
149
+ ).all() as Record<string, unknown>[]
150
+ return rows.map((r): SuiteRunRow => ({
151
+ at: Number(r.at), check: String(r.checkId ?? ''), unit: String(r.unit ?? ''), tier: r.tier === 'full' ? 'full' : 'quick',
152
+ wallMs: Number(r.wallMs), setupMs: Number(r.setupMs), workers: Number(r.workers), files: Number(r.files),
153
+ parallelMs: Number(r.parallelMs), parallelCpuMs: numOrNull(r.parallelCpuMs), serialMs: numOrNull(r.serialMs), serialCpuMs: numOrNull(r.serialCpuMs),
154
+ serialFiles: Number(r.serialFiles ?? 0), aloneMs: Number(r.aloneMs ?? 0), aloneFiles: Number(r.aloneFiles ?? 0), load1: numOrNull(r.load1), cores: numOrNull(r.cores),
155
+ }))
156
+ } catch {
157
+ return []
158
+ } finally {
159
+ try { opened.db.close() } catch { /* already gone */ }
160
+ }
161
+ }
@@ -38,6 +38,12 @@ export type Toolchain = {
38
38
  uncacheable: string[]
39
39
  }
40
40
 
41
+ /** the gate binary driving THIS process, which is what the key hashes when a
42
+ * caller names none. One owner rather than the same URL written at each
43
+ * entry point, and one place for a caller that must NOT be keyed on the
44
+ * source tree it is running from to hand in something stable instead. */
45
+ export const gateBinary = (): string => new URL('../cli.ts', import.meta.url).pathname
46
+
41
47
  /** does this path live inside the repo being gated? */
42
48
  const inRepo = (repoRoot: string, p: string): boolean => {
43
49
  const r = relative(repoRoot, p)
@@ -82,7 +88,7 @@ function hashCliTree(h: ReturnType<typeof createHash>, cli: string): void {
82
88
  * Exported because a repo's own install is not the only one a gate can run
83
89
  * against. Sub-packages carry their own `node_modules`, gitignored like every
84
90
  * other, so no git key can see them and the root toolchain does not describe
85
- * them either. A unit whose declared inputs COVER such a directory folds this
91
+ * them either. A check whose declared inputs COVER such a directory folds this
86
92
  * in beside the path, or the toolchain component stops at the repo root while
87
93
  * the task it is meant to describe does not.
88
94
  *
@@ -0,0 +1,111 @@
1
+ // THE WORLD POPULATION's replay policy, in one place: how long an answer
2
+ // about the world keeps, which answers may be kept at all, and what a kept
3
+ // answer is stamped with.
4
+ //
5
+ // The gate runner remembers a check's `ttl` answer through here. Storage is
6
+ // not this module's concern:
7
+ // ledger/store.ts owns the file, and the CONTENT half of the three
8
+ // populations is keyed instead of timed (ledger/ledger.ts).
9
+ //
10
+ // AN ANSWER CACHES. THE ABSENCE OF ONE DOES NOT. That distinction, not
11
+ // pass-versus-fail, is what decides cacheability here, and a future reader is
12
+ // warned off "fixing" the asymmetry:
13
+ //
14
+ // measured negative a probe that RAN and came back bad ("the box is off",
15
+ // "the token answered 403") is a real answer about the
16
+ // world. Replaying it is CORRECT for a WHILE: the world
17
+ // does not fix itself between two gate runs, and
18
+ // re-asking every 30 seconds spends a third-party API
19
+ // call to be told the same thing. It is NOT correct for
20
+ // the full TTL, because a negative names something a
21
+ // HUMAN is expected to go fix and the fix is invisible
22
+ // from here. So a negative keeps for the cap below at
23
+ // most, while a clean pass keeps for the whole declared
24
+ // window, which is what a long TTL is for.
25
+ // unmeasured a probe that could not run at all (registry
26
+ // unreachable, no cred, rate limited, tool missing)
27
+ // produced no answer. Caching it asserts a dead network
28
+ // as fact long after it recovered, so it stores nothing
29
+ // and EVICTS whatever was stored.
30
+ //
31
+ // A measured negative may be kept only for a check about the WORLD. One about
32
+ // THIS REPO is fixed by a human and the next run goes green, so replaying its
33
+ // red would be the cache lying about work already done.
34
+ import { fmtStamp, type Line } from '../core/wire.ts'
35
+ import { dropStored, entryPath, FRESH_ENV, readStored, writeStored } from './store.ts'
36
+ import { CACHED, RAN } from '../core/outcome-words.ts'
37
+
38
+ /** the longest a MEASURED NEGATIVE is remembered, whatever TTL was declared.
39
+ * Long enough to stop a re-ask storm inside one working session, short
40
+ * enough that the world being fixed is noticed the same day. */
41
+ export const NEGATIVE_TTL_CAP_MS = 60 * 60 * 1000
42
+
43
+ export type WorldCache = {
44
+ /** the ONE store root (ledger/store.ts storeRoot) */
45
+ storeDir: string
46
+ /** which namespace inside it (the repo name for the gate), so a sweep or
47
+ * a manual `rm` can be scoped */
48
+ ns: string
49
+ now: () => number
50
+ /** TEST SEAM: the env var that busts replays. Production is always the one
51
+ * house-wide flag, and a test overrides it so busting one suite cannot
52
+ * poison a neighbour's run. */
53
+ freshEnv?: string
54
+ }
55
+
56
+ const pathFor = (c: WorldCache, name: string, key: string): string => entryPath(c.storeDir, c.ns, name, key)
57
+
58
+ /** the stored answer for this exact question, or null when it must be
59
+ * measured. An entry may keep for LESS than the declared TTL: a measured
60
+ * negative wrote its own shorter window, so the run after a fix sees the fix
61
+ * rather than the memory of the breakage. */
62
+ export function replayWorld(c: WorldCache, name: string, key: string, ttlMs: number): Line[] | null {
63
+ if (ttlMs <= 0) return null
64
+ if (process.env[c.freshEnv ?? FRESH_ENV] != null) return null
65
+ const entry = readStored<Line[]>(pathFor(c, name, key), key)
66
+ if (entry == null) return null
67
+ const keeps = entry.keeps ?? ttlMs
68
+ const now = c.now()
69
+ if (now - entry.at >= keeps || now < entry.at) return null
70
+ // the stamp was baked in when the answer was MEASURED (below): a replay
71
+ // prints the stored lines VERBATIM, so the live run and every later replay
72
+ // render the same string. Annotating replays but not live runs diffed on
73
+ // every fresh-vs-cached alternation, so neither side annotates.
74
+ return entry.payload
75
+ }
76
+
77
+ export type Measured = {
78
+ lines: Line[]
79
+ /** it passed with nothing to say: no FAIL, no WARN */
80
+ clean: boolean
81
+ /** the probe never reached its subject, so there is no answer to keep */
82
+ unmeasured: boolean
83
+ /** a negative may be kept: this check reports the WORLD rather than this
84
+ * repo (`blocking: false`) */
85
+ keepNegative: boolean
86
+ }
87
+
88
+ /** what a measured answer leaves behind, and the lines to report for it. This
89
+ * check writes its OWN file and only its own, so a racing runner can lose
90
+ * nothing. */
91
+ export function rememberWorld(c: WorldCache, name: string, key: string, ttlMs: number, m: Measured): Line[] {
92
+ if (ttlMs <= 0) return m.lines
93
+ const cacheable = !m.unmeasured && (m.clean || m.keepNegative)
94
+ const keepsFor = m.clean ? ttlMs : Math.min(ttlMs, NEGATIVE_TTL_CAP_MS)
95
+ const path = pathFor(c, name, key)
96
+ if (!cacheable) {
97
+ // an unmeasured run evicts whatever was stored: a later run must
98
+ // re-probe rather than replay an answer recorded before the outage. A
99
+ // racing runner may have written a good answer into that same file a
100
+ // moment ago and lose it here, which costs one re-probe and can never
101
+ // produce a stale green, so it is the safe direction.
102
+ dropStored(path)
103
+ return m.lines
104
+ }
105
+ const now = c.now()
106
+ const lines = m.lines.map((l, i): Line =>
107
+ i === 0 ? { ...l, text: `${l.text} (${m.clean ? `${CACHED} at ${fmtStamp(now)} until` : `${RAN} at ${fmtStamp(now)}, kept until`} ${fmtStamp(now + keepsFor)})` } : l,
108
+ )
109
+ writeStored(path, key, lines, now, keepsFor)
110
+ return lines
111
+ }
package/src/mv/mv-md.ts CHANGED
@@ -14,6 +14,10 @@ export type MvOpts = { write?: boolean; commit?: boolean }
14
14
 
15
15
  const sh = (cmd: string, cwd: string): string => execSync(cmd, { cwd }).toString()
16
16
 
17
+ /** a page's own probe, named after the page: a check file, or a legacy
18
+ * check script, which shares the suffix. */
19
+ const SIBLING_SUFFIXES = ['.check.ts'] as const
20
+
17
21
  const posix = (p: string): string => p.replace(/\\/g, '/')
18
22
  /** a link points at a file. A directory that happens to share a prose word's
19
23
  * spelling is not a reference, and existsSync alone accepts it */
@@ -114,8 +118,10 @@ export function movePage(root: string, oldRel: string, newRel: string, io: MvIo,
114
118
  ...(oldId != null ? [[`${oldId}.md`, `${newId}.md`] as [string, string]] : []),
115
119
  // probe file is named after its page; DIR-qualified so another
116
120
  // page's same-named check.ts in a different dir never gets rewritten
117
- [`${oldBase}/${oldName}.check.ts`, `${newBase}/${newName}.check.ts`],
118
- ...(oldId != null ? [[`${oldId}/${oldName}.check.ts`, `${newId}/${newName}.check.ts`] as [string, string]] : []),
121
+ ...SIBLING_SUFFIXES.flatMap((sfx): [string, string][] => [
122
+ [`${oldBase}/${oldName}${sfx}`, `${newBase}/${newName}${sfx}`],
123
+ ...(oldId != null ? [[`${oldId}/${oldName}${sfx}`, `${newId}/${newName}${sfx}`] as [string, string]] : []),
124
+ ]),
119
125
  [`${oldBase}/`, `${newBase}/`],
120
126
  ...(oldId != null ? [[`${oldId}/`, `${newId}/`] as [string, string]] : []),
121
127
  [oldBase, newBase],
@@ -134,8 +140,10 @@ export function movePage(root: string, oldRel: string, newRel: string, io: MvIo,
134
140
  if (existsSync(join(root, oldBase))) {
135
141
  moves.set(oldBase, newBase)
136
142
  for (const f of tracked) if (f.startsWith(`${oldBase}/`)) moves.set(f, `${newBase}${f.slice(oldBase.length)}`)
137
- const probe = `${oldBase}/${oldName}.check.ts`
138
- if (existsSync(join(root, probe))) moves.set(probe, `${newBase}/${newName}.check.ts`)
143
+ for (const sfx of SIBLING_SUFFIXES) {
144
+ const probe = `${oldBase}/${oldName}${sfx}`
145
+ if (existsSync(join(root, probe))) moves.set(probe, `${newBase}/${newName}${sfx}`)
146
+ }
139
147
  }
140
148
 
141
149
  let touched = 0
@@ -164,8 +172,10 @@ export function movePage(root: string, oldRel: string, newRel: string, io: MvIo,
164
172
  // the probe is named after its page, so it only moves when the NAME
165
173
  // changed. A move that keeps the name and changes the directory takes
166
174
  // the probe along inside the dir, and `git mv X X` is a fatal
167
- const oldCheck = join(newBase, `${oldName}.check.ts`)
168
- if (oldName !== newName && existsSync(join(root, oldCheck))) sh(`git mv ${JSON.stringify(oldCheck)} ${JSON.stringify(join(newBase, `${newName}.check.ts`))}`, root)
175
+ for (const sfx of SIBLING_SUFFIXES) {
176
+ const oldProbe = join(newBase, `${oldName}${sfx}`)
177
+ if (oldName !== newName && existsSync(join(root, oldProbe))) sh(`git mv ${JSON.stringify(oldProbe)} ${JSON.stringify(join(newBase, `${newName}${sfx}`))}`, root)
178
+ }
169
179
  }
170
180
 
171
181
  // repo-specific re-stamps (e.g. wiki depth frontmatter) — loud on failure