rvlib-shipkit 0.38.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/CHANGELOG.md +371 -16
  2. package/README.md +26 -14
  3. package/package.json +2 -2
  4. package/src/assets/gen/comfyts-gen.ts +27 -10
  5. package/src/assets/gen/generate-roll.ts +1 -1
  6. package/src/assets/kit-config.ts +6 -6
  7. package/src/assets/run-assets.ts +14 -10
  8. package/src/assets/server/studio-server.ts +6 -5
  9. package/src/check.ts +343 -0
  10. package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
  11. package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
  12. package/src/checks/SK003-claude-md-shape.check.ts +66 -0
  13. package/src/checks/SK004-gitignore-policy.check.ts +309 -0
  14. package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
  15. package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
  16. package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
  17. package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
  18. package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
  19. package/src/checks/SK010-undeclared-unit.check.ts +111 -0
  20. package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
  21. package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
  22. package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
  23. package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
  24. package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
  25. package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
  26. package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
  27. package/src/checks/SK018-test-files-run.check.ts +28 -0
  28. package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
  29. package/src/checks/SK020-self-spawning-check.check.ts +147 -0
  30. package/src/checks/contracts.check.ts +20 -0
  31. package/src/cli/cmd_Asks.ts +3 -2
  32. package/src/cli/cmd_Assets.ts +2 -2
  33. package/src/cli/cmd_Bump.ts +1 -1
  34. package/src/cli/cmd_Check.ts +6 -6
  35. package/src/cli/cmd_Checks.ts +95 -0
  36. package/src/cli/cmd_Ci.ts +48 -13
  37. package/src/cli/cmd_Commands.ts +2 -6
  38. package/src/cli/cmd_ConfigKeys.ts +69 -0
  39. package/src/cli/cmd_Contracts.ts +1 -1
  40. package/src/cli/cmd_Fleet.ts +6 -2
  41. package/src/cli/cmd_Hooks.ts +1 -1
  42. package/src/cli/cmd_Keys.ts +43 -18
  43. package/src/cli/cmd_Mv.ts +2 -2
  44. package/src/cli/cmd_Prose.ts +10 -1
  45. package/src/cli/cmd_Releases.ts +2 -2
  46. package/src/cli/cmd_Serve.ts +1 -1
  47. package/src/cli/cmd_Start.ts +6 -6
  48. package/src/cli/cmd_Status.ts +25 -0
  49. package/src/cli/cmd_Tui.ts +2 -2
  50. package/src/cli.ts +7 -4
  51. package/src/collections/actions.ts +4 -4
  52. package/src/collections/checks.ts +258 -0
  53. package/src/collections/collection-types.ts +254 -1
  54. package/src/collections/comfy.ts +23 -0
  55. package/src/collections/md-body.ts +14 -0
  56. package/src/collections/pings.ts +38 -12
  57. package/src/collections/posts.ts +19 -3
  58. package/src/collections/providers.ts +198 -91
  59. package/src/collections/reflections.ts +41 -0
  60. package/src/collections/state-rules.ts +73 -0
  61. package/src/collections/test-time.ts +288 -0
  62. package/src/collections/tests.ts +181 -0
  63. package/src/collections/wikis.ts +66 -0
  64. package/src/comfy/comfy-panel.ts +292 -0
  65. package/src/contracts/apply-contracts.ts +17 -12
  66. package/src/contracts/contract-types.ts +16 -4
  67. package/src/contracts/contracts-engine.ts +78 -42
  68. package/src/contracts/manifests/discord.contract.ts +6 -6
  69. package/src/contracts/manifests/identity.contract.ts +3 -3
  70. package/src/contracts/manifests/itch.contract.ts +12 -15
  71. package/src/contracts/manifests/social.contract.ts +5 -5
  72. package/src/contracts/manifests/steam.contract.ts +16 -16
  73. package/src/contracts/run-contracts.ts +4 -3
  74. package/src/core/attach.ts +96 -0
  75. package/src/core/canonical-scripts.ts +25 -0
  76. package/src/core/check-ctx.ts +204 -14
  77. package/src/core/check-file-names.ts +112 -0
  78. package/src/core/check-pass.ts +84 -0
  79. package/src/core/check-runner.ts +796 -0
  80. package/src/core/config-keys.ts +256 -0
  81. package/src/core/config-rewrite.ts +77 -0
  82. package/src/core/discover-checks.ts +166 -90
  83. package/src/core/discover-legacy-checks.ts +26 -0
  84. package/src/core/doc-block.ts +13 -0
  85. package/src/core/floor-resolve.ts +264 -0
  86. package/src/core/floor.ts +20 -137
  87. package/src/core/git-bin.ts +24 -0
  88. package/src/core/git-root.ts +108 -0
  89. package/src/core/instance.ts +195 -0
  90. package/src/core/jobs.ts +118 -0
  91. package/src/core/layout.ts +52 -0
  92. package/src/core/legacy-shapes.ts +207 -0
  93. package/src/core/lib-root.ts +5 -0
  94. package/src/core/locked-write.ts +110 -39
  95. package/src/core/machine-commit.ts +5 -4
  96. package/src/core/migrate-layout.ts +557 -0
  97. package/src/core/motherhome.ts +2 -8
  98. package/src/core/nesting.ts +4 -12
  99. package/src/core/one-way-in-policy.ts +73 -17
  100. package/src/core/outcome-words.ts +45 -0
  101. package/src/core/package-manager.ts +86 -0
  102. package/src/core/parallel.ts +21 -0
  103. package/src/core/process-tree.ts +44 -0
  104. package/src/core/raw-access.ts +93 -0
  105. package/src/core/read-set.ts +152 -0
  106. package/src/core/run-check.ts +155 -213
  107. package/src/core/run-ci.ts +153 -189
  108. package/src/core/run-fix.ts +96 -0
  109. package/src/core/run-fixers.ts +53 -30
  110. package/src/core/self-update.ts +9 -3
  111. package/src/core/slots.ts +83 -0
  112. package/src/core/spawn-timeout.ts +34 -3
  113. package/src/core/when-block.ts +401 -0
  114. package/src/core/when.ts +135 -0
  115. package/src/core/write-status.ts +5 -2
  116. package/src/hooks/leak-guard.ts +10 -8
  117. package/src/hooks/one-way-in-guard.ts +13 -5
  118. package/src/hooks/root-identity.ts +2 -1
  119. package/src/hooks/run-hooks.ts +35 -23
  120. package/src/init-repo.ts +39 -23
  121. package/src/ledger/check-stats.ts +155 -0
  122. package/src/ledger/check-table.ts +179 -0
  123. package/src/ledger/db.ts +255 -0
  124. package/src/ledger/gate-checks.ts +433 -0
  125. package/src/ledger/git-key.ts +116 -77
  126. package/src/ledger/git-scan.ts +96 -35
  127. package/src/ledger/ledger.ts +116 -42
  128. package/src/ledger/live-runs.ts +111 -0
  129. package/src/ledger/live-timeline.ts +97 -0
  130. package/src/ledger/repo-view.ts +73 -0
  131. package/src/ledger/run-log.ts +448 -0
  132. package/src/ledger/run-timeline.ts +173 -0
  133. package/src/ledger/store.ts +23 -12
  134. package/src/ledger/suite-log.ts +161 -0
  135. package/src/ledger/toolchain-identity.ts +7 -1
  136. package/src/ledger/world-cache.ts +111 -0
  137. package/src/mv/mv-md.ts +16 -6
  138. package/src/problems/extract.ts +182 -0
  139. package/src/problems/problem-board.ts +37 -0
  140. package/src/problems/problem-line.ts +77 -0
  141. package/src/prose/prose-view.ts +90 -0
  142. package/src/prose/run-prose.ts +60 -19
  143. package/src/prose/ste.ts +140 -24
  144. package/src/reflections/reflections-queue.ts +81 -0
  145. package/src/release/git-facts.ts +15 -9
  146. package/src/release/run-bump.ts +6 -5
  147. package/src/release/run-release.ts +25 -11
  148. package/src/release/run-releases.ts +2 -2
  149. package/src/release/unit-gate.ts +7 -54
  150. package/src/release/unit-resolve.ts +282 -0
  151. package/src/release/units.ts +33 -217
  152. package/src/repo-config.ts +155 -85
  153. package/src/rules/rule.ts +36 -96
  154. package/src/rules/untrack-generated.ts +5 -4
  155. package/src/scaffold/files/vscode-tasks.json +10 -31
  156. package/src/scaffold/jsonc-spans.ts +113 -0
  157. package/src/scaffold/project-scaffold.ts +16 -43
  158. package/src/scan.ts +98 -0
  159. package/src/serve/gate-trigger.ts +148 -0
  160. package/src/serve/health-schedule.ts +102 -0
  161. package/src/serve/hub-app/chart-view.ts +179 -0
  162. package/src/serve/hub-app/check-view.tsx +345 -0
  163. package/src/serve/hub-app/check-words.ts +164 -0
  164. package/src/serve/hub-app/checks-svc.ts +280 -0
  165. package/src/serve/hub-app/clock-svc.ts +33 -0
  166. package/src/serve/hub-app/col-card.tsx +23 -39
  167. package/src/serve/hub-app/cols-svc.ts +44 -20
  168. package/src/serve/hub-app/comfy-svc.ts +171 -0
  169. package/src/serve/hub-app/comfy-view.tsx +81 -0
  170. package/src/serve/hub-app/dur.ts +41 -0
  171. package/src/serve/hub-app/hash-route.ts +121 -0
  172. package/src/serve/hub-app/header-bar.tsx +117 -0
  173. package/src/serve/hub-app/health-svc.ts +23 -28
  174. package/src/serve/hub-app/help.tsx +29 -0
  175. package/src/serve/hub-app/hub-app.tsx +122 -119
  176. package/src/serve/hub-app/hub-st.ts +115 -42
  177. package/src/serve/hub-app/hub-types.ts +117 -2
  178. package/src/serve/hub-app/hub.css +534 -192
  179. package/src/serve/hub-app/index.html +2 -0
  180. package/src/serve/hub-app/live-svc.ts +174 -0
  181. package/src/serve/hub-app/md.ts +15 -0
  182. package/src/serve/hub-app/media-svc.ts +4 -1
  183. package/src/serve/hub-app/media-view.tsx +15 -0
  184. package/src/serve/hub-app/next-run.ts +68 -0
  185. package/src/serve/hub-app/project-gutter.css +65 -0
  186. package/src/serve/hub-app/project-gutter.tsx +128 -0
  187. package/src/serve/hub-app/projects-svc.ts +226 -0
  188. package/src/serve/hub-app/prose-hits-st.ts +124 -0
  189. package/src/serve/hub-app/prose-hits-view.tsx +176 -0
  190. package/src/serve/hub-app/prose-hits.css +21 -0
  191. package/src/serve/hub-app/run-strip.ts +47 -0
  192. package/src/serve/hub-app/runs-tabs.ts +24 -0
  193. package/src/serve/hub-app/test-view.tsx +107 -0
  194. package/src/serve/hub-app/tests-svc.ts +62 -0
  195. package/src/serve/hub-app/tests-view.tsx +238 -0
  196. package/src/serve/hub-app/time-ago.tsx +34 -0
  197. package/src/serve/hub-app/timeline-chart.tsx +274 -0
  198. package/src/serve/hub-app/timeline-geometry.ts +314 -0
  199. package/src/serve/hub-app/timeline-svc.ts +62 -0
  200. package/src/serve/hub-app/timeline-view.tsx +245 -0
  201. package/src/serve/hub-app/tree-build.ts +410 -0
  202. package/src/serve/hub-app/tree-row.tsx +42 -0
  203. package/src/serve/hub-app/tree-svc.ts +174 -0
  204. package/src/serve/hub-app/wikis-view.tsx +53 -0
  205. package/src/serve/other-repos.ts +79 -0
  206. package/src/serve/project-gutter-route.ts +350 -0
  207. package/src/serve/project-gutter.ts +449 -0
  208. package/src/serve/run-serve.ts +245 -16
  209. package/src/serve/same-origin.ts +14 -0
  210. package/src/serve/serve-hub.ts +213 -18
  211. package/src/shipkit.ts +19 -12
  212. package/src/smoke/run-smoke.ts +3 -2
  213. package/src/start-repo.ts +75 -36
  214. package/src/suite/judge-suites.ts +216 -18
  215. package/src/suite/kept-timings.ts +58 -0
  216. package/src/suite/runner-shapes.ts +88 -0
  217. package/src/suite/suite-cli.ts +147 -24
  218. package/src/suite/suite-cpu-preload.ts +92 -0
  219. package/src/suite/test-units.ts +243 -0
  220. package/src/ts/config.ts +4 -69
  221. package/src/ts/jsonc.ts +69 -0
  222. package/src/ts/lex.ts +143 -0
  223. package/src/ts/rename.ts +73 -10
  224. package/src/ts/source-graph.ts +28 -8
  225. package/src/ts/tsconfig-projects.ts +6 -97
  226. package/src/ts/tsconfig-resolve.ts +108 -0
  227. package/src/tui/build-steps-panel.ts +61 -0
  228. package/src/tui/checks-panel.ts +24 -16
  229. package/src/tui/run-tui.ts +2 -2
  230. package/src/tui/tui-panel.ts +5 -4
  231. package/src/wiki/graph-colors.ts +68 -0
  232. package/src/wiki/graph-mount.ts +7 -9
  233. package/src/wiki/graph-page.ts +42 -25
  234. package/src/wiki/graph-positions.ts +105 -0
  235. package/src/wiki/run-wiki.ts +1 -1
  236. package/src/wiki/serve-graph.ts +33 -3
  237. package/src/cli/cmd_Rules.ts +0 -51
  238. package/src/cli/cmd_Units.ts +0 -82
  239. package/src/collections/graphs.ts +0 -32
  240. package/src/contracts/contracts-once.ts +0 -43
  241. package/src/core/check-suite.ts +0 -302
  242. package/src/core/repair-contract-dirs.ts +0 -136
  243. package/src/core/task-runner.ts +0 -339
  244. package/src/ledger/gate-units.ts +0 -138
  245. package/src/ledger/unit-table.ts +0 -105
  246. package/src/rules/SK002-contracts-fulfillness.ts +0 -24
  247. package/src/rules/SK003-claude-md-shape.ts +0 -70
  248. package/src/rules/SK004-gitignore-policy.ts +0 -238
  249. package/src/rules/SK010-undeclared-release-unit.ts +0 -127
  250. package/src/rules/rule-registry.ts +0 -64
  251. package/src/rules/run-fix.ts +0 -72
  252. package/src/tui/tasks-panel.ts +0 -60
@@ -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
11
  import { globalAllowFile, globalKeywordFile, maskPattern, patternFingerprint, runLeakGuard, 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,12 +141,12 @@ 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
146
  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}
@@ -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
+ }
@@ -0,0 +1,179 @@
1
+ // THE inventory render — every gate check as one markdown row, so the question
2
+ // "what runs here, does it block me, and how often does it really run" has a
3
+ // surface that is read rather than reconstructed from three commands.
4
+ //
5
+ // Deliberately carries NO live run state: no timestamps, no key hashes, no
6
+ // last verdict. Everything here is what a check DECLARED about itself, so two
7
+ // runs a week apart over an unchanged repo write identical bytes and the file
8
+ // never churns. It is still generated, gitignored AND untracked like STATUS.md
9
+ // (rules/untrack-generated.ts), so it is read on disk, never in git history.
10
+ // Live keys are `shipkit keys`, live verdicts are STATUS.md.
11
+ import { mkdirSync } from 'fs'
12
+ import { dirname, join } from 'path'
13
+ import { atomicWrite, withFileLock } from '../core/locked-write.ts'
14
+ import { classify } from './git-key.ts'
15
+ import type { CheckRow } from './gate-checks.ts'
16
+ import { LAYOUT } from '../core/layout.ts'
17
+
18
+ /** the sidecar every gate run refreshes beside STATUS.md */
19
+ export const CHECK_TABLE_FILE = LAYOUT.statusFull
20
+
21
+ /** a cell can never break the table it sits in */
22
+ const cell = (s: string): string => s.replace(/\|/g, '\\|').replace(/\n/g, ' ')
23
+
24
+ export type CheckTableInput = {
25
+ /** repo name, for the headline */
26
+ name: string
27
+ rows: CheckRow[]
28
+ }
29
+
30
+ /** what a check's cache declaration means for how often it actually runs */
31
+ function cacheOf(r: CheckRow): { population: string; ttl: string; reads: string; why: string } {
32
+ const c = classify(r.decl)
33
+ const inputs = r.decl.inputs ?? []
34
+ if (c.error != null) return { population: 'BROKEN', ttl: '', reads: '', why: c.error }
35
+ if (c.population === 'content') return { population: 'content', ttl: '', reads: inputs.join(', '), why: '' }
36
+ // what it read is recorded per run, so the table (which never churns)
37
+ // says where to look rather than a list that moves with every edit
38
+ if (r.decl.uncacheable != null) return { population: 'never', ttl: '', reads: '', why: r.decl.uncacheable }
39
+ if (c.population === 'reads') return { population: 'reads', ttl: '', reads: 'recorded per run (shipkit keys --why)', why: '' }
40
+ // no `reads` for these two: the cache column already says "world"/"never",
41
+ // and restating it on every such row is a column of noise
42
+ if (c.population === 'world') return { population: 'world', ttl: r.decl.ttl ?? '', reads: '', why: '' }
43
+ if (c.decided === true) return { population: 'never', ttl: '', reads: '', why: r.decl.never ?? '' }
44
+ return { population: 'UNDECLARED', ttl: '', reads: '', why: 'nobody has declared what this reads, so it re-runs every single time' }
45
+ }
46
+
47
+ /** EVERY CELL of one check's row, in the one vocabulary. Both renders read
48
+ * this, so a column cannot exist on the terminal table and not in the file,
49
+ * or say two different things in the two places. */
50
+ export type CheckCells = {
51
+ check: string
52
+ runsIn: string
53
+ from: string
54
+ build: string
55
+ failure: string
56
+ cache: string
57
+ ttl: string
58
+ reads: string
59
+ owner: string
60
+ }
61
+
62
+ export function checkCells(r: CheckRow): CheckCells {
63
+ const c = cacheOf(r)
64
+ return {
65
+ check: r.decl.id,
66
+ runsIn: r.when.at,
67
+ from: r.from,
68
+ // a flag column: the word is in the header, so the cell only has to
69
+ // answer, and an empty cell is quieter than a column of `no`
70
+ build: r.build ? 'yes' : '',
71
+ failure: r.blocking == null ? 'UNDECLARED' : r.blocking ? 'blocks' : 'warns',
72
+ cache: c.population,
73
+ ttl: c.ttl,
74
+ reads: c.reads,
75
+ owner: r.owner ?? '',
76
+ }
77
+ }
78
+
79
+ export function renderCheckTable(input: CheckTableInput): string {
80
+ const { name, rows } = input
81
+ const builds = rows.filter((r) => r.build).length
82
+ const shipped = rows.filter((r) => r.from === 'shipkit').length
83
+
84
+ const body: string[] = []
85
+ body.push(`<!-- generated by \`shipkit checks\`, refreshed by every gate run, gitignored (SK004) -->`)
86
+ body.push(`# ${name} checks — ${rows.length} total`)
87
+ body.push('')
88
+ body.push(`Everything \`shipkit ci\` and \`shipkit check\` know how to run: ${builds} build step(s) the gate alone runs, ${rows.length - builds} the health pass reports too. ${shipped} ship with the toolkit, ${rows.length - shipped} belong to this repo.`)
89
+ body.push('')
90
+ body.push('- **runs in** — `always` means every `shipkit ci` runs it, so a red one says THIS change is broken. `release` means the quick gate leaves it out and `shipkit ci --full` plus the release path run it. `health` means only the health pass (`shipkit check`) runs it: it reports the world (a box that is off, a cred nobody minted, a page nobody refreshed), it can sit red for months with nothing wrong, and it never stands between you and a commit. `off` means this repo turned it off on purpose, and the reason is in `repo.config.ts when`.')
91
+ body.push('- **build** — `yes` means the gate alone runs it: it BUILDS (a compiler, a test suite, a bundler), so `shipkit check` leaves it out and reports everything else. A floor slot derives it from the package.json script behind it, and a `*.check.ts` declares it with `build: true`.')
92
+ body.push('- **on failure** — `blocks` stops the run. `warns` prints and carries on. `UNDECLARED` is a defect, not a setting: the check never said and the repo set no default, so the runner treats it as fatal by guess.')
93
+ body.push('- **cache** — `content` is cached while the files it reads are untouched. `world` is cached for its TTL, and a RED that ran is cached too, capped at one hour so the run after a fix sees the fix. `never` runs every time and somebody wrote down why. `UNDECLARED` runs every time and nobody has looked yet, which is the burn-down (`shipkit keys --burn-down`). `SHIPKIT_FRESH=1` busts all of it.')
94
+ const proactive = rows.filter((r) => r.when.at !== 'always').length
95
+ const warns = rows.filter((r) => r.blocking === false).length
96
+ const neverSaid = rows.filter((r) => r.blocking == null).length
97
+ const undeclared = rows.filter((r) => cacheOf(r).population === 'UNDECLARED')
98
+ body.push('')
99
+ // the most useful line in the file, so it sits ABOVE the 68 rows rather
100
+ // than under them
101
+ body.push(
102
+ `**${rows.length - proactive} always, ${proactive} not in the quick gate · ${warns} warn instead of blocking${neverSaid > 0 ? `, ${neverSaid} never said` : ''} · ${undeclared.length} never cached with nobody having looked.**`,
103
+ )
104
+ body.push('')
105
+ body.push('| check | runs in | from | build | on failure | cache | ttl | reads | owner |')
106
+ body.push('|---|---|---|---|---|---|---|---|---|')
107
+ for (const r of rows) {
108
+ const c = checkCells(r)
109
+ // the two answers a reader must never skim past are bolded, and only
110
+ // in the file: a terminal has no bold that a pipe survives
111
+ const loud = (v: string): string => (v === 'UNDECLARED' ? '**UNDECLARED**' : v)
112
+ body.push(
113
+ `| \`${cell(c.check)}\` | ${c.runsIn} | ${c.from} | ${c.build} | ${loud(c.failure)} | ${loud(c.cache)} | ${cell(c.ttl)} | ${cell(c.reads)} | ${c.owner === '' ? '' : `\`${cell(c.owner)}\``} |`,
114
+ )
115
+ }
116
+ body.push('')
117
+
118
+ const decided = rows.filter((r) => cacheOf(r).population === 'never')
119
+ if (undeclared.length > 0) {
120
+ body.push(`## ${undeclared.length} check(s) nothing can cache yet`)
121
+ body.push('')
122
+ body.push('Each of these runs on every single pass. Declaring `inputs` (it reads files), `ttl` (it reads the world) or `never` (nothing can describe it) is the fix, and the last one counts: a written reason is a decision, not a hole.')
123
+ body.push('')
124
+ for (const r of undeclared) body.push(`- \`${cell(r.decl.id)}\`${r.owner == null ? '' : ` — \`${cell(r.owner)}\``}`)
125
+ body.push('')
126
+ }
127
+ if (decided.length > 0) {
128
+ body.push(`## ${decided.length} check(s) never cached on purpose`)
129
+ body.push('')
130
+ for (const r of decided) body.push(`- \`${cell(r.decl.id)}\` — ${cell(r.decl.never ?? '')}`)
131
+ body.push('')
132
+ }
133
+
134
+ return body.join('\n')
135
+ }
136
+
137
+ /** the same inventory as a plain terminal table (`shipkit checks`). A catalog,
138
+ * not a health run, so no line carries a wire prefix: anything parsing wire
139
+ * output would read this as a passing check. */
140
+ export function renderCheckLines(rows: CheckRow[]): string[] {
141
+ const out: string[] = [`${'check'.padEnd(28)} ${'runs in'.padEnd(8)} ${'from'.padEnd(8)} ${'build'.padEnd(6)} ${'failure'.padEnd(10)} ${'cache'.padEnd(11)} ttl`]
142
+ for (const r of rows) {
143
+ const c = checkCells(r)
144
+ out.push(`${c.check.padEnd(28)} ${c.runsIn.padEnd(8)} ${c.from.padEnd(8)} ${c.build.padEnd(6)} ${c.failure.padEnd(10)} ${c.cache.padEnd(11)} ${c.ttl}`)
145
+ }
146
+ const undeclared = rows.filter((r) => { const cl = classify(r.decl); return cl.population === 'none' && cl.decided !== true }).length
147
+ const proactive = rows.filter((r) => r.when.at !== 'always').length
148
+ out.push('')
149
+ out.push(`${rows.length} checks — ${rows.length - proactive} always, ${proactive} not in the quick gate · ${undeclared} never cached with nobody having looked`)
150
+ return out
151
+ }
152
+
153
+ /** THE FIXABLE CATALOG (`shipkit checks --fixable`): everything in this repo
154
+ * that repairs itself, with the page that owns the invariant and where the
155
+ * check runs, so an off one cannot look like a repair waiting to happen. A
156
+ * catalog, not a health run, so no line carries a wire prefix. */
157
+ export function renderFixableLines(rows: CheckRow[]): string[] {
158
+ if (rows.length === 0) return ['no check in this repo carries an autofix']
159
+ const name = (r: CheckRow): string => (r.title === r.decl.id ? r.decl.id : `${r.decl.id} ${r.title}`)
160
+ const w = Math.max(28, ...rows.map((r) => name(r).length))
161
+ const out: string[] = [`${'check'.padEnd(w)} ${'from'.padEnd(8)} ${'runs in'.padEnd(8)} ${'failure'.padEnd(10)} owner`]
162
+ for (const r of rows) {
163
+ const c = checkCells(r)
164
+ out.push(`${name(r).padEnd(w)} ${c.from.padEnd(8)} ${c.runsIn.padEnd(8)} ${c.failure.padEnd(10)} ${c.owner}`)
165
+ }
166
+ out.push('')
167
+ out.push(`${rows.length} check(s) repair themselves — one with \`shipkit check --fix ${rows[0]!.decl.id}\`, every one with \`shipkit check --fix\``)
168
+ return out
169
+ }
170
+
171
+ /** refresh the sidecar beside STATUS.md. Lock-guarded exactly like STATUS.md:
172
+ * several runners in one checkout is the normal case, and the loser skipping
173
+ * a render it would have written identically costs nothing. */
174
+ export function writeCheckTable(root: string, input: CheckTableInput): { written: boolean; warn?: string } {
175
+ const target = join(root, CHECK_TABLE_FILE)
176
+ mkdirSync(dirname(target), { recursive: true })
177
+ const r = withFileLock(target, () => { atomicWrite(target, `${renderCheckTable(input).trimEnd()}\n`) })
178
+ return r.ok ? { written: true } : { written: false, warn: `${CHECK_TABLE_FILE} locked by another runner — skipped this write` }
179
+ }