rvlib-shipkit 0.38.1 → 1.0.1

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 (255) hide show
  1. package/CHANGELOG.md +379 -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/derived.ts +1 -1
  6. package/src/assets/gen/generate-roll.ts +1 -1
  7. package/src/assets/kit-config.ts +6 -6
  8. package/src/assets/run-assets.ts +14 -10
  9. package/src/assets/server/studio-server.ts +6 -5
  10. package/src/check.ts +343 -0
  11. package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
  12. package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
  13. package/src/checks/SK003-claude-md-shape.check.ts +66 -0
  14. package/src/checks/SK004-gitignore-policy.check.ts +309 -0
  15. package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +49 -32
  16. package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
  17. package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
  18. package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
  19. package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
  20. package/src/checks/SK010-undeclared-unit.check.ts +111 -0
  21. package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
  22. package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
  23. package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
  24. package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
  25. package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
  26. package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
  27. package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
  28. package/src/checks/SK018-test-files-run.check.ts +28 -0
  29. package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
  30. package/src/checks/SK020-self-spawning-check.check.ts +147 -0
  31. package/src/checks/contracts.check.ts +20 -0
  32. package/src/cli/cmd_Asks.ts +3 -2
  33. package/src/cli/cmd_Assets.ts +2 -2
  34. package/src/cli/cmd_Bump.ts +1 -1
  35. package/src/cli/cmd_Check.ts +6 -6
  36. package/src/cli/cmd_Checks.ts +95 -0
  37. package/src/cli/cmd_Ci.ts +48 -13
  38. package/src/cli/cmd_Commands.ts +2 -6
  39. package/src/cli/cmd_ConfigKeys.ts +69 -0
  40. package/src/cli/cmd_Contracts.ts +1 -1
  41. package/src/cli/cmd_Fleet.ts +16 -3
  42. package/src/cli/cmd_Hooks.ts +1 -1
  43. package/src/cli/cmd_Keys.ts +43 -18
  44. package/src/cli/cmd_Mv.ts +2 -2
  45. package/src/cli/cmd_Prose.ts +10 -1
  46. package/src/cli/cmd_Releases.ts +2 -2
  47. package/src/cli/cmd_Serve.ts +1 -1
  48. package/src/cli/cmd_Start.ts +6 -6
  49. package/src/cli/cmd_Status.ts +25 -0
  50. package/src/cli/cmd_Tui.ts +2 -2
  51. package/src/cli.ts +7 -4
  52. package/src/collections/actions.ts +4 -4
  53. package/src/collections/checks.ts +257 -0
  54. package/src/collections/collection-types.ts +254 -1
  55. package/src/collections/comfy.ts +23 -0
  56. package/src/collections/md-body.ts +14 -0
  57. package/src/collections/pings.ts +38 -12
  58. package/src/collections/posts.ts +19 -3
  59. package/src/collections/providers.ts +198 -91
  60. package/src/collections/reflections.ts +41 -0
  61. package/src/collections/state-rules.ts +73 -0
  62. package/src/collections/test-time.ts +288 -0
  63. package/src/collections/tests.ts +181 -0
  64. package/src/collections/wikis.ts +66 -0
  65. package/src/comfy/comfy-panel.ts +292 -0
  66. package/src/contracts/apply-contracts.ts +17 -12
  67. package/src/contracts/contract-types.ts +16 -4
  68. package/src/contracts/contracts-engine.ts +78 -42
  69. package/src/contracts/manifests/discord.contract.ts +6 -6
  70. package/src/contracts/manifests/identity.contract.ts +3 -3
  71. package/src/contracts/manifests/itch.contract.ts +12 -15
  72. package/src/contracts/manifests/social.contract.ts +5 -5
  73. package/src/contracts/manifests/steam.contract.ts +16 -16
  74. package/src/contracts/run-contracts.ts +4 -3
  75. package/src/core/attach.ts +96 -0
  76. package/src/core/canonical-scripts.ts +25 -0
  77. package/src/core/check-ctx.ts +204 -14
  78. package/src/core/check-file-names.ts +112 -0
  79. package/src/core/check-pass.ts +84 -0
  80. package/src/core/check-runner.ts +796 -0
  81. package/src/core/config-keys.ts +256 -0
  82. package/src/core/config-rewrite.ts +77 -0
  83. package/src/core/discover-checks.ts +166 -90
  84. package/src/core/discover-legacy-checks.ts +26 -0
  85. package/src/core/doc-block.ts +13 -0
  86. package/src/core/floor-resolve.ts +264 -0
  87. package/src/core/floor.ts +20 -137
  88. package/src/core/git-bin.ts +24 -0
  89. package/src/core/git-root.ts +108 -0
  90. package/src/core/instance.ts +195 -0
  91. package/src/core/jobs.ts +118 -0
  92. package/src/core/layout.ts +52 -0
  93. package/src/core/legacy-shapes.ts +207 -0
  94. package/src/core/lib-root.ts +5 -0
  95. package/src/core/locked-write.ts +110 -39
  96. package/src/core/machine-commit.ts +5 -4
  97. package/src/core/migrate-layout.ts +616 -0
  98. package/src/core/motherhome.ts +2 -8
  99. package/src/core/nesting.ts +4 -12
  100. package/src/core/one-way-in-policy.ts +73 -17
  101. package/src/core/outcome-words.ts +45 -0
  102. package/src/core/package-manager.ts +88 -0
  103. package/src/core/parallel.ts +21 -0
  104. package/src/core/process-tree.ts +44 -0
  105. package/src/core/raw-access.ts +93 -0
  106. package/src/core/read-set.ts +152 -0
  107. package/src/core/run-check.ts +155 -213
  108. package/src/core/run-ci.ts +152 -189
  109. package/src/core/run-fix.ts +99 -0
  110. package/src/core/run-fixers.ts +64 -33
  111. package/src/core/self-update.ts +11 -3
  112. package/src/core/slots.ts +83 -0
  113. package/src/core/spawn-timeout.ts +34 -3
  114. package/src/core/when-block.ts +401 -0
  115. package/src/core/when.ts +135 -0
  116. package/src/core/write-status.ts +5 -2
  117. package/src/fleet/scan-fleet.ts +16 -0
  118. package/src/hooks/leak-guard.ts +20 -9
  119. package/src/hooks/one-way-in-guard.ts +13 -5
  120. package/src/hooks/root-identity.ts +2 -1
  121. package/src/hooks/run-hooks.ts +38 -26
  122. package/src/init-repo.ts +39 -23
  123. package/src/ledger/check-stats.ts +155 -0
  124. package/src/ledger/check-table.ts +179 -0
  125. package/src/ledger/db.ts +255 -0
  126. package/src/ledger/gate-checks.ts +433 -0
  127. package/src/ledger/git-key.ts +116 -77
  128. package/src/ledger/git-scan.ts +96 -35
  129. package/src/ledger/ledger.ts +116 -42
  130. package/src/ledger/live-runs.ts +111 -0
  131. package/src/ledger/live-timeline.ts +97 -0
  132. package/src/ledger/repo-view.ts +73 -0
  133. package/src/ledger/run-log.ts +448 -0
  134. package/src/ledger/run-timeline.ts +173 -0
  135. package/src/ledger/store.ts +23 -12
  136. package/src/ledger/suite-log.ts +161 -0
  137. package/src/ledger/toolchain-identity.ts +7 -1
  138. package/src/ledger/world-cache.ts +111 -0
  139. package/src/mv/mv-md.ts +16 -6
  140. package/src/problems/extract.ts +182 -0
  141. package/src/problems/problem-board.ts +37 -0
  142. package/src/problems/problem-line.ts +77 -0
  143. package/src/prose/prose-view.ts +90 -0
  144. package/src/prose/run-prose.ts +60 -19
  145. package/src/prose/ste.ts +140 -24
  146. package/src/reflections/reflections-queue.ts +81 -0
  147. package/src/release/git-facts.ts +15 -9
  148. package/src/release/run-bump.ts +6 -5
  149. package/src/release/run-release.ts +25 -11
  150. package/src/release/run-releases.ts +2 -2
  151. package/src/release/unit-gate.ts +7 -54
  152. package/src/release/unit-resolve.ts +282 -0
  153. package/src/release/units.ts +33 -217
  154. package/src/repo-config.ts +157 -88
  155. package/src/rules/rule.ts +36 -96
  156. package/src/rules/untrack-generated.ts +5 -4
  157. package/src/scaffold/files/vscode-tasks.json +10 -31
  158. package/src/scaffold/jsonc-spans.ts +113 -0
  159. package/src/scaffold/project-scaffold.ts +16 -43
  160. package/src/scan.ts +98 -0
  161. package/src/serve/gate-trigger.ts +148 -0
  162. package/src/serve/health-schedule.ts +102 -0
  163. package/src/serve/hub-app/chart-view.ts +179 -0
  164. package/src/serve/hub-app/check-view.tsx +345 -0
  165. package/src/serve/hub-app/check-words.ts +164 -0
  166. package/src/serve/hub-app/checks-svc.ts +280 -0
  167. package/src/serve/hub-app/clock-svc.ts +33 -0
  168. package/src/serve/hub-app/col-card.tsx +23 -39
  169. package/src/serve/hub-app/cols-svc.ts +44 -20
  170. package/src/serve/hub-app/comfy-svc.ts +171 -0
  171. package/src/serve/hub-app/comfy-view.tsx +81 -0
  172. package/src/serve/hub-app/dur.ts +41 -0
  173. package/src/serve/hub-app/hash-route.ts +121 -0
  174. package/src/serve/hub-app/header-bar.tsx +117 -0
  175. package/src/serve/hub-app/health-svc.ts +23 -28
  176. package/src/serve/hub-app/help.tsx +29 -0
  177. package/src/serve/hub-app/hub-app.tsx +122 -119
  178. package/src/serve/hub-app/hub-st.ts +115 -42
  179. package/src/serve/hub-app/hub-types.ts +117 -2
  180. package/src/serve/hub-app/hub.css +534 -192
  181. package/src/serve/hub-app/index.html +2 -0
  182. package/src/serve/hub-app/live-svc.ts +174 -0
  183. package/src/serve/hub-app/md.ts +15 -0
  184. package/src/serve/hub-app/media-svc.ts +4 -1
  185. package/src/serve/hub-app/media-view.tsx +15 -0
  186. package/src/serve/hub-app/next-run.ts +68 -0
  187. package/src/serve/hub-app/project-gutter.css +65 -0
  188. package/src/serve/hub-app/project-gutter.tsx +128 -0
  189. package/src/serve/hub-app/projects-svc.ts +226 -0
  190. package/src/serve/hub-app/prose-hits-st.ts +124 -0
  191. package/src/serve/hub-app/prose-hits-view.tsx +176 -0
  192. package/src/serve/hub-app/prose-hits.css +21 -0
  193. package/src/serve/hub-app/run-strip.ts +47 -0
  194. package/src/serve/hub-app/runs-tabs.ts +24 -0
  195. package/src/serve/hub-app/test-view.tsx +107 -0
  196. package/src/serve/hub-app/tests-svc.ts +62 -0
  197. package/src/serve/hub-app/tests-view.tsx +238 -0
  198. package/src/serve/hub-app/time-ago.tsx +34 -0
  199. package/src/serve/hub-app/timeline-chart.tsx +274 -0
  200. package/src/serve/hub-app/timeline-geometry.ts +314 -0
  201. package/src/serve/hub-app/timeline-svc.ts +62 -0
  202. package/src/serve/hub-app/timeline-view.tsx +245 -0
  203. package/src/serve/hub-app/tree-build.ts +410 -0
  204. package/src/serve/hub-app/tree-row.tsx +42 -0
  205. package/src/serve/hub-app/tree-svc.ts +174 -0
  206. package/src/serve/hub-app/wikis-view.tsx +53 -0
  207. package/src/serve/other-repos.ts +79 -0
  208. package/src/serve/project-gutter-route.ts +350 -0
  209. package/src/serve/project-gutter.ts +449 -0
  210. package/src/serve/run-serve.ts +245 -16
  211. package/src/serve/same-origin.ts +14 -0
  212. package/src/serve/serve-hub.ts +213 -18
  213. package/src/shipkit.ts +19 -12
  214. package/src/smoke/run-smoke.ts +9 -4
  215. package/src/start-repo.ts +75 -36
  216. package/src/suite/judge-suites.ts +209 -15
  217. package/src/suite/kept-timings.ts +58 -0
  218. package/src/suite/runner-shapes.ts +88 -0
  219. package/src/suite/suite-cli.ts +147 -24
  220. package/src/suite/suite-cpu-preload.ts +92 -0
  221. package/src/suite/test-units.ts +243 -0
  222. package/src/ts/config.ts +4 -69
  223. package/src/ts/jsonc.ts +69 -0
  224. package/src/ts/lex.ts +143 -0
  225. package/src/ts/rename.ts +73 -10
  226. package/src/ts/source-graph.ts +28 -8
  227. package/src/ts/tsconfig-projects.ts +6 -97
  228. package/src/ts/tsconfig-resolve.ts +108 -0
  229. package/src/tui/build-steps-panel.ts +61 -0
  230. package/src/tui/checks-panel.ts +24 -16
  231. package/src/tui/run-tui.ts +2 -2
  232. package/src/tui/tui-panel.ts +5 -4
  233. package/src/wiki/graph-colors.ts +68 -0
  234. package/src/wiki/graph-mount.ts +7 -9
  235. package/src/wiki/graph-page.ts +42 -25
  236. package/src/wiki/graph-positions.ts +105 -0
  237. package/src/wiki/run-wiki.ts +1 -1
  238. package/src/wiki/scan-md.ts +1 -1
  239. package/src/wiki/serve-graph.ts +33 -3
  240. package/src/cli/cmd_Rules.ts +0 -51
  241. package/src/cli/cmd_Units.ts +0 -82
  242. package/src/collections/graphs.ts +0 -32
  243. package/src/contracts/contracts-once.ts +0 -43
  244. package/src/core/check-suite.ts +0 -302
  245. package/src/core/repair-contract-dirs.ts +0 -136
  246. package/src/core/task-runner.ts +0 -339
  247. package/src/ledger/gate-units.ts +0 -138
  248. package/src/ledger/unit-table.ts +0 -105
  249. package/src/rules/SK002-contracts-fulfillness.ts +0 -24
  250. package/src/rules/SK003-claude-md-shape.ts +0 -70
  251. package/src/rules/SK004-gitignore-policy.ts +0 -238
  252. package/src/rules/SK010-undeclared-release-unit.ts +0 -127
  253. package/src/rules/rule-registry.ts +0 -64
  254. package/src/rules/run-fix.ts +0 -72
  255. package/src/tui/tasks-panel.ts +0 -60
@@ -18,6 +18,8 @@
18
18
  import { createHash } from 'crypto'
19
19
  import { existsSync, readFileSync } from 'fs'
20
20
  import { basename, join } from 'path'
21
+ import { gitBin } from '../core/git-bin.ts'
22
+ import { KEYWORD_FILE, MACHINE_PRIVATE } from '../core/layout.ts'
21
23
 
22
24
  export type LeakHit = { where: string; pattern: string; file?: string }
23
25
  export type LeakWaiver = LeakHit & { reason: string; source: string }
@@ -78,6 +80,15 @@ export type ParsedPatterns = {
78
80
 
79
81
  const escapeRegex = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
80
82
 
83
+ /** a key shape is a token, so it starts where a token starts: `ask-the-...`
84
+ * or `desk-lamp-...` is a word that ends in `sk`, never a key */
85
+ export const SK_KEY_ROW = 're:(?<![A-Za-z0-9_-])sk-[A-Za-z0-9_-]{20,}'
86
+
87
+ /** rows the hooks seeded before their shape was fixed, read as the fixed
88
+ * shape. Their id stays the seeded row, so a waiver written against its
89
+ * fingerprint keeps working */
90
+ const SEEDED_ROW_FIXES: Record<string, string> = { 're:sk-[A-Za-z0-9_-]{20,}': SK_KEY_ROW }
91
+
81
92
  /** Parse the keyword file: blank lines and `#` comments dropped, `re:<pattern>`
82
93
  * rows become case-insensitive regexes, `w:<word>` rows match on word
83
94
  * boundaries, everything else a case-insensitive substring.
@@ -93,7 +104,7 @@ export function parsePatterns(text: string): ParsedPatterns {
93
104
  const row = raw.trim()
94
105
  if (row === '' || row.startsWith('#')) continue
95
106
  if (row.startsWith('re:')) {
96
- try { regexes.push({ row, re: new RegExp(row.slice(3), 'i') }) }
107
+ try { regexes.push({ row, re: new RegExp((SEEDED_ROW_FIXES[row] ?? row).slice(3), 'i') }) }
97
108
  catch { /* a malformed row must not disable the whole guard */ }
98
109
  } else if (row.startsWith('w:')) {
99
110
  const word = row.slice(2).trim().toLowerCase()
@@ -227,11 +238,11 @@ export function leakAllowProblems(entries: unknown, where: string, tracked: bool
227
238
 
228
239
  /** the machine-global list every repo's guard also reads: one private list
229
240
  * per machine, never copied into a repo (a copy rots, and a not-yet-ignored
230
- * .rv-private/ would make the list itself committable). */
241
+ * private folder would make the list itself committable). */
231
242
  export function globalKeywordFile(): string | null {
232
243
  const home = process.env.HOME ?? ''
233
244
  if (home === '') return null
234
- const p = join(home, '.rv-private', 'banned-keywords.txt')
245
+ const p = join(home, MACHINE_PRIVATE, 'banned-keywords.txt')
235
246
  return existsSync(p) ? p : null
236
247
  }
237
248
 
@@ -240,7 +251,7 @@ export function globalKeywordFile(): string | null {
240
251
  export function globalAllowFile(): string | null {
241
252
  const home = process.env.HOME ?? ''
242
253
  if (home === '') return null
243
- const p = join(home, '.rv-private', 'leak-allow.json')
254
+ const p = join(home, MACHINE_PRIVATE, 'leak-allow.json')
244
255
  return existsSync(p) ? p : null
245
256
  }
246
257
 
@@ -263,7 +274,7 @@ export function loadAllowFile(path: string, tracked: boolean): { entries: Loaded
263
274
  }
264
275
 
265
276
  export type LeakGuardOpts = {
266
- /** path to the keyword file, relative to root (default '.rv-private/banned-keywords.txt') */
277
+ /** path to the keyword file, relative to root (default .shipkit/private/banned-keywords.txt) */
267
278
  keywordFile?: string
268
279
  /** extra text to scan (a commit message, a release note) */
269
280
  text?: { label: string; content: string }[]
@@ -334,10 +345,10 @@ export function addedLinesByFile(diff: string): { file: string; content: string
334
345
  }
335
346
 
336
347
  export function runLeakGuard(root: string, o: LeakGuardOpts = {}): LeakGuardResult {
337
- const rel = o.keywordFile ?? '.rv-private/banned-keywords.txt'
348
+ const rel = o.keywordFile ?? KEYWORD_FILE
338
349
  const file = join(root, rel)
339
350
  const sources = [existsSync(file) ? file : null, globalKeywordFile()].filter((f): f is string => f != null)
340
- if (sources.length === 0) return { status: 'skipped', reason: `${rel} not found and no ~/.rv-private/banned-keywords.txt — no keyword list, no leak guard` }
351
+ if (sources.length === 0) return { status: 'skipped', reason: `${rel} not found and no machine-global banned-keywords.txt — no keyword list, no leak guard` }
341
352
  const patterns = parsePatterns(sources.map((f) => readFileSync(f, 'utf8')).join('\n'))
342
353
  const count = patterns.substrings.length + patterns.words.length + patterns.regexes.length
343
354
  if (count === 0) return { status: 'skipped', reason: `keyword file(s) have no patterns (${sources.join(', ')})` }
@@ -358,11 +369,11 @@ export function runLeakGuard(root: string, o: LeakGuardOpts = {}): LeakGuardResu
358
369
 
359
370
  const targets: ScanTarget[] = []
360
371
  if (o.staged !== false) {
361
- const diff = Bun.spawnSync(['git', 'diff', '--cached'], { cwd: root, maxBuffer: 256 * 1024 * 1024 })
372
+ const diff = Bun.spawnSync([gitBin(), 'diff', '--cached'], { cwd: root, maxBuffer: 256 * 1024 * 1024 })
362
373
  if (diff.exitCode === 0)
363
374
  for (const f of addedLinesByFile(diff.stdout.toString())) targets.push({ where: 'staged content', file: f.file, content: f.content })
364
375
  // a filename can leak as loudly as a line of code
365
- const names = Bun.spawnSync(['git', 'diff', '--cached', '--name-only'], { cwd: root })
376
+ const names = Bun.spawnSync([gitBin(), 'diff', '--cached', '--name-only'], { cwd: root })
366
377
  if (names.exitCode === 0) for (const n of names.stdout.toString().split('\n')) if (n.trim() !== '') targets.push({ where: 'staged path', file: n, content: n })
367
378
  }
368
379
  for (const t of o.text ?? []) targets.push({ where: t.label, content: t.content })
@@ -1,16 +1,17 @@
1
1
  #!/usr/bin/env bun
2
2
  // the PreToolUse guard for ONE WAY IN (core/one-way-in-policy.ts owns the
3
- // table and the wording). Its ONE job is to refuse a raw runner with the
4
- // shipkit command to type instead: `permissions.deny` already blocks these,
5
- // but a bare deny tells the model only that it was denied, and a refusal with
6
- // no replacement just sends it looking for another way in.
3
+ // table and the wording). It refuses a raw runner with the shipkit command to
4
+ // type instead: `permissions.deny` already blocks these, but a bare deny tells
5
+ // the model only that it was denied, and a refusal with no replacement just
6
+ // sends it looking for another way in. It also refuses a gate piped into
7
+ // another command, whose exit code the pipe hides.
7
8
  //
8
9
  // It runs before EVERY Bash call, so it stays a leaf: one tiny import, one
9
10
  // small json read, no clipanion, no repo config. Anything it cannot decide it
10
11
  // waves through, because a guard that fails closed on its own bugs would wedge
11
12
  // a repo with no way to tell why, and `permissions.deny` is still the floor
12
13
  // underneath it. Measured at ~10ms per call.
13
- import { deniedHeads, rawRunnerHit, refusal } from '../core/one-way-in-policy.ts'
14
+ import { deniedHeads, pipedGateIn, pipedGateRefusal, rawRunnerHit, refusal } from '../core/one-way-in-policy.ts'
14
15
 
15
16
  const input = await Bun.stdin.text()
16
17
  let command = ''
@@ -21,6 +22,13 @@ try {
21
22
  } catch {
22
23
  process.exit(0)
23
24
  }
25
+ // a piped gate is refused whatever the deny list says: it is not a raw
26
+ // runner, it is a verdict whose exit code the tail of the pipe replaces
27
+ const piped = pipedGateIn(command)
28
+ if (piped != null) {
29
+ console.error(pipedGateRefusal(piped))
30
+ process.exit(2)
31
+ }
24
32
  // the settings file is the authority on WHICH runners this repo refuses, so
25
33
  // deleting one `Bash(...)` line lifts the deny and this refusal together
26
34
  const settingsPath = `${process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd()}/.claude/settings.json`
@@ -7,6 +7,7 @@
7
7
  // valid package.json. Only the NAME betrayed it.
8
8
  import { existsSync } from 'fs'
9
9
  import { join } from 'path'
10
+ import { gitBin } from '../core/git-bin.ts'
10
11
 
11
12
  export type IdentityOutcome =
12
13
  | { status: 'skipped'; reason: string }
@@ -18,7 +19,7 @@ export type IdentityOutcome =
18
19
  const SCRATCH = /\.(bak|orig|rej)$/
19
20
 
20
21
  const git = (root: string, args: string[]): { code: number; out: string } => {
21
- const p = Bun.spawnSync(['git', ...args], { cwd: root, env: process.env })
22
+ const p = Bun.spawnSync([gitBin(), ...args], { cwd: root, env: process.env })
22
23
  return { code: p.exitCode, out: p.stdout.toString() }
23
24
  }
24
25
 
@@ -6,31 +6,44 @@
6
6
  import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'fs'
7
7
  import { dirname, join } from 'path'
8
8
  import { fileURLToPath } from 'url'
9
+ import { KEYWORD_FILE, LAYOUT } from '../core/layout.ts'
9
10
  import { commitMessageOffenses } from './commit-message.ts'
10
- import { globalAllowFile, globalKeywordFile, maskPattern, patternFingerprint, runLeakGuard, type LeakAllowEntry, type LeakHit, type LeakWaiver } from './leak-guard.ts'
11
+ import { globalAllowFile, globalKeywordFile, maskPattern, patternFingerprint, runLeakGuard, SK_KEY_ROW, type LeakAllowEntry, type LeakHit, type LeakWaiver } from './leak-guard.ts'
11
12
  import { runRootIdentity } from './root-identity.ts'
13
+ import { gitRoot } from '../core/git-root.ts'
12
14
  import { spawnWithTimeout } from '../core/spawn-timeout.ts'
13
15
  import type { RepoConfig } from '../repo-config.ts'
16
+ import { gitBin } from '../core/git-bin.ts'
14
17
 
15
18
  export type HooksConfig = {
16
19
  /** repo-specific commands run in the pre-commit gate, after the built-ins.
17
- * Non-zero exit blocks the commit; stdout/stderr are shown. */
18
- preCommit?: { id: string; cmd: string[] }[]
19
- /** repo-specific commands run on the commit message; the message file path
20
- * is appended as the last argument. */
21
- commitMsg?: { id: string; cmd: string[] }[]
22
- /** keyword file for the leak guard (default '.rv-private/banned-keywords.txt') */
20
+ * A non-zero exit blocks the commit, and the output is shown. */
21
+ preCommit?: {
22
+ /** gate name, printed by `shipkit hooks list` */
23
+ id: string
24
+ /** argv, run without a shell */
25
+ cmd: string[]
26
+ }[]
27
+ /** repo-specific commands run on the commit message. The message file path
28
+ * arrives as the last argument. */
29
+ commitMsg?: {
30
+ /** gate name, printed by `shipkit hooks list` */
31
+ id: string
32
+ /** argv, with the message file path appended */
33
+ cmd: string[]
34
+ }[]
35
+ /** keyword file for the leak guard (default .shipkit/private/banned-keywords.txt) */
23
36
  keywordFile?: string
24
37
  /** known-good leak-guard matches this repo waives: a lockfile whose
25
38
  * dependency names or integrity hashes collide with a keyword is the
26
39
  * motivating case. Each entry carries a reason, and a waiver that fires
27
40
  * PRINTS, so nothing passes silently. This file is tracked, so an entry
28
41
  * names a pattern by fingerprint (`fp:…`, printed beside every hit) and
29
- * never by value. The machine-global twin is ~/.rv-private/leak-allow.json.
42
+ * never by value. The machine-global twin is ~/.shipkit/private/leak-allow.json.
30
43
  * hooks/leak-guard.ts */
31
44
  leakAllow?: LeakAllowEntry[]
32
- /** staged-path prefixes that may never enter git (local state like
33
- * '.comfy-ts/'). gitignore is advisory and editable; this gate is not. */
45
+ /** staged-path prefixes that can never enter git (local state like
46
+ * '.comfy-ts/'). A gitignore line is advisory and editable. This gate is not. */
34
47
  blockedPaths?: string[]
35
48
  /** words the commit-message language gate never counts as French
36
49
  * (proper nouns that carry accents without being a language choice) */
@@ -41,7 +54,7 @@ export type HooksConfig = {
41
54
  disable?: ('leak-guard' | 'commit-message' | 'root-identity' | 'commit-language')[]
42
55
  }
43
56
 
44
- const HOOK_DIR = 'hooks'
57
+ const HOOK_DIR = LAYOUT.hooks
45
58
  const HOOKS = ['pre-commit', 'commit-msg'] as const
46
59
 
47
60
  /** how a self-written hook body should invoke shipkit in this repo: the
@@ -66,15 +79,14 @@ export type ArmHooksResult = { state: 'armed' | 'current' | 'foreign' | 'no-git'
66
79
  * arm (a subdir would set the parent's hooksPath onto a dir that does not
67
80
  * exist there, disarming the parent's gates). */
68
81
  export function armHooks(root: string, config: RepoConfig): ArmHooksResult {
69
- const top = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], { cwd: root })
70
- if (top.exitCode !== 0) return { state: 'no-git', detail: 'not a git repo' }
71
- const topDir = top.stdout.toString().trim()
82
+ const topDir = gitRoot(root)?.top
83
+ if (topDir == null) return { state: 'no-git', detail: 'not a git repo' }
72
84
  try {
73
85
  if (realpathSync(topDir) !== realpathSync(root)) return { state: 'not-root', detail: `nested in ${topDir}, which owns its own commit gates` }
74
86
  } catch {
75
87
  return { state: 'no-git', detail: 'work tree root unresolvable' }
76
88
  }
77
- const p = Bun.spawnSync(['git', 'config', 'core.hooksPath'], { cwd: root })
89
+ const p = Bun.spawnSync([gitBin(), 'config', 'core.hooksPath'], { cwd: root })
78
90
  const current = p.exitCode === 0 ? p.stdout.toString().trim() : ''
79
91
  if (current !== '' && current !== HOOK_DIR) return { state: 'foreign', detail: `core.hooksPath=${current}, foreign, left alone` }
80
92
  if (current === HOOK_DIR && HOOKS.every((h) => existsSync(join(root, HOOK_DIR, h)))) return { state: 'current', detail: 'hooks already armed' }
@@ -94,7 +106,7 @@ function setHooksPath(root: string): { code: number; detail: string } {
94
106
  let last = { code: -1, detail: '' }
95
107
  for (let attempt = 0; attempt < 5; attempt++) {
96
108
  if (attempt > 0) Bun.sleepSync(20 * 2 ** (attempt - 1))
97
- const p = Bun.spawnSync(['git', 'config', 'core.hooksPath', HOOK_DIR], { cwd: root })
109
+ const p = Bun.spawnSync([gitBin(), 'config', 'core.hooksPath', HOOK_DIR], { cwd: root })
98
110
  if (p.exitCode === 0) return { code: 0, detail: '' }
99
111
  last = { code: p.exitCode, detail: p.stderr.toString().trim().split('\n')[0]?.slice(0, 120) ?? '' }
100
112
  // only lock contention is worth retrying; anything else is a real
@@ -129,16 +141,16 @@ export function installHooks(root: string, binInvocation: string, keywordFile?:
129
141
 
130
142
  // a starter so the leak guard bites from day one instead of skipping forever:
131
143
  // generic secret shapes only. private names live in the machine-global
132
- // ~/.rv-private/banned-keywords.txt, which the guard reads on every run
144
+ // ~/.shipkit/private/banned-keywords.txt, which the guard reads on every run
133
145
  // (never copied into a repo: a copy rots and a copy can leak)
134
- const KEYWORD_STARTER = `# banned-keywords — the leak guard greps every commit (added lines, paths,
146
+ export const KEYWORD_STARTER = `# banned-keywords — the leak guard greps every commit (added lines, paths,
135
147
  # message) for these rows. one row = one case-insensitive substring, or
136
148
  # re:<regex>. add repo-specific names here; machine-wide private names go in
137
- # ~/.rv-private/banned-keywords.txt, read automatically alongside this file.
149
+ # ~/.shipkit/private/banned-keywords.txt, read automatically alongside this file.
138
150
  # this file is gitignored on purpose: the list itself is private.
139
151
  re:-----BEGIN [A-Z ]*PRIVATE KEY-----
140
152
  re:AKIA[0-9A-Z]{16}
141
- re:sk-[A-Za-z0-9_-]{20,}
153
+ ${SK_KEY_ROW}
142
154
  re:ghp_[A-Za-z0-9]{36}
143
155
  re:xox[baprs]-[A-Za-z0-9-]{10,}
144
156
  `
@@ -146,7 +158,7 @@ re:xox[baprs]-[A-Za-z0-9-]{10,}
146
158
  /** seed the configured keyword file with the generic starter so the guard is
147
159
  * ARMED, not a skip. an existing file is never touched. */
148
160
  function seedKeywordFile(root: string, keywordFile?: string): string | null {
149
- const rel = keywordFile ?? '.rv-private/banned-keywords.txt'
161
+ const rel = keywordFile ?? KEYWORD_FILE
150
162
  const dest = join(root, rel)
151
163
  if (existsSync(dest)) return null
152
164
  mkdirSync(dirname(dest), { recursive: true })
@@ -212,7 +224,7 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
212
224
  })
213
225
  }
214
226
  if ((h.blockedPaths ?? []).length > 0) {
215
- const names = Bun.spawnSync(['git', 'diff', '--cached', '--name-only'], { cwd: root })
227
+ const names = Bun.spawnSync([gitBin(), 'diff', '--cached', '--name-only'], { cwd: root })
216
228
  const staged = names.stdout.toString().split('\n').filter(Boolean)
217
229
  const blocked = staged.filter((f) => h.blockedPaths!.some((p) => f.startsWith(p)))
218
230
  out.push(blocked.length === 0
@@ -305,16 +317,16 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
305
317
  export function listGates(config: RepoConfig, root = process.cwd()): string[] {
306
318
  const h = config.hooks ?? {}
307
319
  const off = new Set(h.disable ?? [])
308
- const kw = h.keywordFile ?? '.rv-private/banned-keywords.txt'
320
+ const kw = h.keywordFile ?? KEYWORD_FILE
309
321
  // a gate listed as active but unable to fire is worse than no listing:
310
322
  // this surface exists so nobody has to guess
311
- const leakState = off.has('leak-guard') ? ' (DISABLED by config)' : existsSync(join(root, kw)) || globalKeywordFile() != null ? '' : ` (INACTIVE, ${kw} absent and no ~/.rv-private list)`
323
+ const leakState = off.has('leak-guard') ? ' (DISABLED by config)' : existsSync(join(root, kw)) || globalKeywordFile() != null ? '' : ` (INACTIVE, ${kw} absent and no ~/.shipkit/private list)`
312
324
  // a waiver nobody can enumerate is a hole nobody can audit: the count of
313
325
  // declared exemptions belongs on the same surface as the gate itself
314
326
  const waivers = (h.leakAllow ?? []).length + (globalAllowFile() != null ? 1 : 0)
315
- const leakAllowState = waivers === 0 ? '' : `, ${(h.leakAllow ?? []).length} repo waiver(s)${globalAllowFile() == null ? '' : ' + ~/.rv-private/leak-allow.json'}`
327
+ const leakAllowState = waivers === 0 ? '' : `, ${(h.leakAllow ?? []).length} repo waiver(s)${globalAllowFile() == null ? '' : ' + ~/.shipkit/private/leak-allow.json'}`
316
328
  return [
317
- `pre-commit leak-guard${leakState} — staged added lines + paths vs ${kw} + ~/.rv-private/banned-keywords.txt${leakAllowState}`,
329
+ `pre-commit leak-guard${leakState} — staged added lines + paths vs ${kw} + ~/.shipkit/private/banned-keywords.txt${leakAllowState}`,
318
330
  ...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git`] : []),
319
331
  `pre-commit root-identity${off.has('root-identity') ? ' (DISABLED by config)' : ''} — the repo's own name in package.json + repo.config.ts, and scratch files`,
320
332
  ...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
package/src/init-repo.ts CHANGED
@@ -9,12 +9,15 @@
9
9
  import { existsSync, readFileSync, writeFileSync } from 'fs'
10
10
  import { basename, join } from 'path'
11
11
  import { canonicalBodies } from './core/canonical-scripts.ts'
12
- import { hasTestFiles, packageJsonShape } from './rules/SK001-package-json-shape.ts'
13
- import { oneWayInPolicy } from './rules/SK013-one-way-in-policy.ts'
12
+ import { CheckCtx } from './core/check-ctx.ts'
13
+ import { hasTestFiles, packageJsonShape } from './checks/SK001-package-json-shape.check.ts'
14
+ import { oneWayInPolicy } from './checks/SK013-one-way-in-policy.check.ts'
15
+ import { listRepoFiles } from './core/floor.ts'
14
16
  import { reportToMotherhome } from './core/motherhome.ts'
15
17
  import { ensureGitRepo, publishRepo, type Run } from './git-bootstrap.ts'
16
18
  import { defineRepo, loadRepoConfig, resolveFloor } from './repo-config.ts'
17
- import { gitignorePolicy } from './rules/SK004-gitignore-policy.ts'
19
+ import { gitignorePolicy } from './checks/SK004-gitignore-policy.check.ts'
20
+ import { vscodeHubTask } from './checks/SK017-vscode-hub-task.check.ts'
18
21
  import { editorErgonomics } from './scaffold/project-scaffold.ts'
19
22
  import { defaultHubPort } from './serve/hub-port.ts'
20
23
 
@@ -26,21 +29,23 @@ const CONFIG_TEMPLATE = (name: string): string => `import { defineRepo } from 'r
26
29
 
27
30
  export default defineRepo({
28
31
  name: '${name}',
29
- venues: [], // e.g. ['itch', 'steam'], selects which .rv-<venue>/ contracts are REQUIRED
30
- social: [], // e.g. ['x', 'discord'], the .rv-social/<channel>/ post folders (+ .rv-discord/)
32
+ venues: [], // e.g. ['itch', 'steam'], selects which .shipkit/<venue>/ contracts are REQUIRED
33
+ social: [], // e.g. ['x', 'discord'], the .shipkit/social/<channel>/ post folders (+ .shipkit/discord/)
31
34
  tasks: [], // repo commit-gate tasks; deps double as race guards
32
- // lint, typecheck, imports:check and cycles replay off the tracked tree.
33
- // \`test\` measures every run until you say what it reads, because an
35
+ // lint, typecheck, imports:check and cycles are cached on the tracked tree.
36
+ // \`test\` runs every time until you say what it reads, because an
34
37
  // ignored path (a fixture, a local db, a .env) is not in a tree key:
35
38
  // defaults: { test: { inputs: ['tests', 'src', 'package.json'] } },
36
- // .comfy-ts/ holds schema dumps naming a private model collection.
37
- // gitignore (SK004) is advisory, this pre-commit gate is not
38
- hooks: { blockedPaths: ['.comfy-ts/'] },
39
+ // staged paths that may never enter git: local state, big derived output, a
40
+ // dump naming something private. gitignore (SK004) is advisory and editable,
41
+ // this pre-commit gate is not
42
+ hooks: { blockedPaths: ['.comfy-ts/hosts/', '.comfy-ts/cache/', '.comfy-ts/outputs/'] },
39
43
  // the declared reviewer agent type (rule SK008). Uncomment with YOUR
40
44
  // standing brief, then \`shipkit check --fix SK008\` writes the agent file:
41
45
  // reviewer: { name: 'my-reviewer', brief: '~/path/to/reviewer-brief.md' },
42
- // health checks need no config. Drop a <name>.check.ts file anywhere,
43
- // \`shipkit check\` auto-discovers and runs it (wire output, exit code)
46
+ // checks need no config. Drop a <name>.check.ts file anywhere, and
47
+ // \`shipkit ci\`, \`shipkit check\` and the hub discover it
48
+ // every key this file accepts, one line each: \`shipkit config-keys\`
44
49
  })
45
50
  `
46
51
 
@@ -95,12 +100,12 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
95
100
  // its .gitignore policy block (same fixer as `check --fix SK004`),
96
101
  // with the repo's own contributed lines honored
97
102
  const cfg = await loadRepoConfig(root)
98
- const g = await gitignorePolicy.fix!({ root, config: cfg })
103
+ const g = await gitignorePolicy.fix!(CheckCtx.open({ root, config: cfg }))
99
104
  p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
100
105
  // the repos that need ONE WAY IN most are the ones that already exist,
101
106
  // so a re-run REPAIRS rather than only seeding a fresh checkout
102
107
  await repairOneWayIn(root, cfg, p)
103
- furnishEditor(root, cfg, p)
108
+ await furnishEditor(root, cfg, p)
104
109
  const mh = reportToMotherhome(root, cfg)
105
110
  if (mh.note != null) p(mh.note)
106
111
  if (wired && opts.noPublish !== true) await publishRepo(root, { github: opts.github, gh: opts.gh, print: p })
@@ -121,7 +126,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
121
126
  // `test` is scaffolded only once the repo has tests: `bun test` exits
122
127
  // non-zero with none, and a scaffold that reds the gate is a bad scaffold
123
128
  for (const [k, v] of Object.entries(canonicalBodies({ bin: opts.bin }))) {
124
- if (k === 'test' && !hasTestFiles(root)) continue
129
+ if (k === 'test' && !hasTestFiles(listRepoFiles(root))) continue
125
130
  if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
126
131
  else if (pkg.scripts[k] !== v) p(`skip: package.json script '${k}' exists — left untouched`)
127
132
  }
@@ -131,10 +136,10 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
131
136
  }
132
137
  // the .gitignore policy block (contract git policy) — same fixer as
133
138
  // `shipkit check --fix SK004`, one code path
134
- const g = await gitignorePolicy.fix!({ root, config: defineRepo({ name }) })
139
+ const g = await gitignorePolicy.fix!(CheckCtx.open({ root, config: defineRepo({ name }) }))
135
140
  p(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
136
141
  await repairOneWayIn(root, defineRepo({ name }), p)
137
- furnishEditor(root, { name }, p)
142
+ await furnishEditor(root, { name }, p)
138
143
  // what the gate actually runs, in ONE line: a thin floor must be seen at
139
144
  // init time rather than discovered after a bad commit slipped through.
140
145
  // The per-task resolution is diagnostics, and a `skip:` naming a script
@@ -143,7 +148,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
143
148
  // matter, and reports them about a repo that has code in it
144
149
  const floor = resolveFloor(defineRepo({ name }), root)
145
150
  if (floor.tasks.length > 0) p(`ok: gate floor: ${floor.tasks.map((t) => t.id).join(', ')}`)
146
- else p(`WARN the gate would check NOTHING here (${floor.unfilled.map((u) => u.id).join(', ')} all unresolved) — see \`shipkit rules\``)
151
+ else p(`WARN the gate would check NOTHING here (${floor.unfilled.map((u) => u.id).join(', ')} all unresolved) — see \`shipkit checks\``)
147
152
  // every verb comes from the `shipkit` bin — dead on arrival unless the
148
153
  // package is actually installed (bunx runs without adding the dep)
149
154
  if (opts.bin == null) {
@@ -174,7 +179,7 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
174
179
  * files are the only half of that scaffold an adopted repo can be handed
175
180
  * unasked, because they change no output of any command (project-scaffold.ts
176
181
  * owns the line). Create-only, so a .vscode/ the repo brought with it wins. */
177
- function furnishEditor(root: string, cfg: { name?: string; serve?: { port?: number } }, p: (line: string) => void): void {
182
+ async function furnishEditor(root: string, cfg: { name?: string; serve?: { port?: number } }, p: (line: string) => void): Promise<void> {
178
183
  // the least important thing init does, and it runs AFTER repo.config.ts is
179
184
  // on disk: an unwritable .vscode (a read-only tree, a path that is a file),
180
185
  // or a config with no name at all, must not abort the run before the
@@ -183,9 +188,20 @@ function furnishEditor(root: string, cfg: { name?: string; serve?: { port?: numb
183
188
  try {
184
189
  const name = repoName(root, undefined, cfg.name)
185
190
  const port = cfg.serve?.port ?? defaultHubPort(name)
186
- if (editorErgonomics(root, { name, port }).length === 0) return void p('ok: .vscode/ is this repo\'s own, left untouched')
191
+ if (editorErgonomics(root, { name, port }).length === 0) {
192
+ // the repo's own file stays its own, except the hub task, which
193
+ // rule SK017 keeps feeding the problems pane
194
+ // a file SK017 cannot read is still the repo's own: it is never
195
+ // reported as missing, and it is never rewritten
196
+ let hub: { changed: boolean; summary: string }
197
+ try { hub = await vscodeHubTask.fix!(CheckCtx.open({ root, config: defineRepo({ name }) })) } catch (e) {
198
+ p('ok: .vscode/ is this repo\'s own, left untouched')
199
+ return void p(`WARN .vscode/tasks.json does not parse (${e instanceof Error ? e.message : String(e)}): the hub task gets no problem matcher until it does`)
200
+ }
201
+ return void p(hub.changed ? `ok: ${hub.summary}` : 'ok: .vscode/ is this repo\'s own, left untouched')
202
+ }
187
203
  p(`ok: .vscode/tasks.json written — opening the folder starts the hub on ${port}`)
188
- p(' next: add this stack\'s own watchers (dev server, css, tests) — build/vscode-tasks.md carries the shapes')
204
+ p(' next: add this stack\'s own watchers (dev server, css) — build/vscode-tasks.md carries the shapes')
189
205
  } catch (e) {
190
206
  p(`WARN no .vscode/tasks.json here: ${e instanceof Error ? e.message : String(e)} — opening the folder starts nothing, everything else landed`)
191
207
  }
@@ -198,8 +214,8 @@ function furnishEditor(root: string, cfg: { name?: string; serve?: { port?: numb
198
214
  * one that was not. The deny is a RESTRICTION, which is why it lands
199
215
  * unconditionally where the scaffold's allow list stays opt in. */
200
216
  async function repairOneWayIn(root: string, config: Parameters<typeof defineRepo>[0], p: (line: string) => void): Promise<void> {
201
- const pkg = await packageJsonShape.fix!({ root, config })
217
+ const pkg = await packageJsonShape.fix!(CheckCtx.open({ root, config }))
202
218
  if (pkg.changed) p(`ok: package.json — ${pkg.summary}`)
203
- const policy = await oneWayInPolicy.fix!({ root, config })
219
+ const policy = await oneWayInPolicy.fix!(CheckCtx.open({ root, config }))
204
220
  p(policy.changed ? `ok: ${policy.summary}` : `ok: .claude/settings.json already closes every raw runner`)
205
221
  }
@@ -0,0 +1,155 @@
1
+ // What a check costs, per check, from the two things this repo remembers: the
2
+ // store (one file per key, greens only, `at` plus `measuredMs`) and the runs
3
+ // run record beside it (every outcome, red included).
4
+ //
5
+ // The store is HISTORY here and the db is the record. They overlap for as
6
+ // long as both describe the same run, so a stored entry is counted only when
7
+ // it predates that CHECK's first row in the record: otherwise one measurement
8
+ // lands in the average twice, and the sample count, the one number that says
9
+ // whether an average means anything, is the first thing to lie.
10
+ //
11
+ // PER CHECK, never one boundary for the whole repo, because the two sources do
12
+ // not cover the same checks. A discovered check NEVER enters the store: its
13
+ // result lives in the check ttl cache or nowhere at all, so the store holds
14
+ // rules and tasks only, and a surface reading it alone shows every check as
15
+ // never measured however often it runs. The record is the only source that
16
+ // measures a check, and the store is only ever the history of the other two.
17
+ import { readdirSync, readFileSync, statSync } from 'fs'
18
+ import { join } from 'path'
19
+ import type { RunRow, RunStatus } from './run-log.ts'
20
+ import { readRuns, runsPath, runsStamp } from './run-log.ts'
21
+ import { storeRoot } from './store.ts'
22
+
23
+ /** one measurement read back off the store */
24
+ export type StoreSample = { key: string; at: number; ms: number; status: RunStatus }
25
+
26
+ export type CheckLast = { status: RunStatus; at: number; ms: number; replayed: boolean }
27
+
28
+ export type CheckStat = {
29
+ /** the newest outcome, replayed or measured */
30
+ last: CheckLast | null
31
+ /** mean of the MEASURED runs, ms. null when nothing measured it */
32
+ avgMs: number | null
33
+ /** how many measurements the mean is over: an average over two runs is
34
+ * not an average, and the page says which it is looking at */
35
+ samples: number
36
+ slowestMs: number | null
37
+ /** measurements that went red, out of `samples` */
38
+ failures: number
39
+ /** when it was last MEASURED rather than replayed, and what that said:
40
+ * a remembered answer expires from its measurement, never its replay */
41
+ measuredAt?: number | null
42
+ measuredStatus?: RunStatus | null
43
+ }
44
+
45
+ /** the ONE merge. Pure, so the rules above are testable without a disk. */
46
+ export function mergeCheckStats(o: { runs: RunRow[]; store: StoreSample[] }): Map<string, CheckStat> {
47
+ // each check's own first row is its boundary: what the store holds from
48
+ // before it is history the record never saw, and what comes after it is a
49
+ // measurement the record already carries. A check with no rows at all keeps
50
+ // its whole stored history
51
+ const firstRunAt = new Map<string, number>()
52
+ for (const r of o.runs) {
53
+ const prev = firstRunAt.get(r.id)
54
+ if (prev == null || r.at < prev) firstRunAt.set(r.id, r.at)
55
+ }
56
+ type Sample = { at: number; ms: number; status: RunStatus; full?: boolean }
57
+ const samples = new Map<string, Sample[]>()
58
+ const last = new Map<string, CheckLast>()
59
+ const push = (key: string, s: Sample): void => {
60
+ const list = samples.get(key) ?? []
61
+ list.push(s)
62
+ samples.set(key, list)
63
+ }
64
+ for (const s of o.store) {
65
+ if (s.at >= (firstRunAt.get(s.key) ?? Infinity)) continue
66
+ push(s.key, { at: s.at, ms: s.ms, status: s.status })
67
+ const prev = last.get(s.key)
68
+ if (prev == null || s.at > prev.at) last.set(s.key, { status: s.status, at: s.at, ms: s.ms, replayed: false })
69
+ }
70
+ for (const r of o.runs) {
71
+ // a replay measured nothing and a skip ran nothing: both are the last
72
+ // VERDICT and neither is a timing
73
+ if (!r.replayed && r.status !== 'skip') push(r.id, { at: r.at, ms: r.ms, status: r.status, ...(r.full === true ? { full: true } : {}) })
74
+ const prev = last.get(r.id)
75
+ if (prev == null || r.at >= prev.at) last.set(r.id, { status: r.status, at: r.at, ms: r.ms, replayed: r.replayed })
76
+ }
77
+ const out = new Map<string, CheckStat>()
78
+ for (const [key, l] of last) {
79
+ // the quick tier is what every edit waits on: a full tier run (every
80
+ // slow suite, one at a time) joins the numbers only of a check that
81
+ // never ran at the quick one
82
+ const all = samples.get(key) ?? []
83
+ const quick = all.filter((s) => s.full !== true)
84
+ const list = quick.length > 0 ? quick : all
85
+ const newest = list.reduce<Sample | null>((a, s) => (a == null || s.at >= a.at ? s : a), null)
86
+ out.set(key, {
87
+ last: l,
88
+ avgMs: list.length === 0 ? null : Math.round(list.reduce((a, s) => a + s.ms, 0) / list.length),
89
+ samples: list.length,
90
+ slowestMs: list.length === 0 ? null : Math.max(...list.map((s) => s.ms)),
91
+ failures: list.filter((s) => s.status === 'fail').length,
92
+ measuredAt: newest?.at ?? null,
93
+ measuredStatus: newest?.status ?? null,
94
+ })
95
+ }
96
+ return out
97
+ }
98
+
99
+ /** the store, read back. A check's file name is the slug of its id
100
+ * (ledger/store.ts entryPath), so the caller's check list decides which
101
+ * prefixes are read: deriving the id back OUT of a slug cannot work, since
102
+ * both `:` and `/` slug to `_`. */
103
+ export function scanCheckStore(store: string, checks: { id: string }[]): StoreSample[] {
104
+ const byPrefix = new Map<string, string>()
105
+ for (const u of checks) byPrefix.set(storeSlug(u.id), u.id)
106
+ const dir = join(store, 'checks')
107
+ const out: StoreSample[] = []
108
+ let files: string[] = []
109
+ try { files = readdirSync(dir) } catch { return out }
110
+ for (const f of files) {
111
+ if (!f.endsWith('.json')) continue
112
+ // `<slug>.<12 hex of the key>.json`
113
+ const key = byPrefix.get(f.replace(/\.[0-9a-f]{12}\.json$/, ''))
114
+ if (key == null) continue
115
+ try {
116
+ const e = JSON.parse(readFileSync(join(dir, f), 'utf8')) as { at?: number; payload?: { measuredMs?: number; status?: RunStatus } }
117
+ const ms = e.payload?.measuredMs
118
+ if (typeof e.at !== 'number' || typeof ms !== 'number') continue
119
+ out.push({ key, at: e.at, ms, status: e.payload?.status === 'warn' ? 'warn' : 'pass' })
120
+ } catch { /* a half written or foreign entry reads as no sample */ }
121
+ }
122
+ return out
123
+ }
124
+
125
+ /** ledger/store.ts entryPath's file name rule, which this module must agree
126
+ * with byte for byte or it reads nothing */
127
+ const storeSlug = (s: string): string => s.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 60)
128
+
129
+ type Cached = { stamp: string; stats: Map<string, CheckStat> }
130
+ const cache = new Map<string, Cached>()
131
+
132
+ /** the per check numbers for a repo, cached on what the two sources look like
133
+ * from the outside: the store dir's mtime, plus what the run record holds.
134
+ * The store is thousands of small files and the hub asks on a ten second
135
+ * poll. The record's own FILE cannot answer for it: in WAL mode a commit
136
+ * lands in the sidecar and the db's mtime does not move. */
137
+ export function checkStatsFor(root: string, checks: { id: string }[], o?: { fresh?: boolean }): Map<string, CheckStat> {
138
+ const store = storeRoot(root)
139
+ const record = runsPath(root)
140
+ const stamp = `${checks.length}:${stampOf(join(store, 'checks'))}:${runsStamp(record)}`
141
+ const hit = cache.get(root)
142
+ if (o?.fresh !== true && hit != null && hit.stamp === stamp) return hit.stats
143
+ const stats = mergeCheckStats({ runs: readRuns(record), store: scanCheckStore(store, checks) })
144
+ cache.set(root, { stamp, stats })
145
+ return stats
146
+ }
147
+
148
+ const stampOf = (p: string): string => {
149
+ try {
150
+ const s = statSync(p)
151
+ return `${s.mtimeMs}/${s.size}`
152
+ } catch {
153
+ return 'none'
154
+ }
155
+ }