rvlib-shipkit 1.3.0 → 1.5.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 (203) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +17 -17
  3. package/package.json +3 -2
  4. package/src/asks/asks-queue.ts +4 -2
  5. package/src/assets/kits/identity-kit.ts +15 -13
  6. package/src/assets/run-assets.ts +12 -2
  7. package/src/check.ts +73 -98
  8. package/src/checks/SK001-package-json-shape.check.ts +26 -4
  9. package/src/checks/SK002-contracts-fulfillness.check.ts +4 -0
  10. package/src/checks/SK003-claude-md-shape.check.ts +5 -1
  11. package/src/checks/SK004-gitignore-policy.check.ts +7 -4
  12. package/src/checks/SK005-rvlib-versions.check.ts +53 -21
  13. package/src/checks/SK006-npm-publish-safety.check.ts +4 -0
  14. package/src/checks/SK007-github-visibility.check.ts +4 -2
  15. package/src/checks/SK008-reviewer-agent.check.ts +6 -4
  16. package/src/checks/SK009-commit-ai-tells.check.ts +94 -74
  17. package/src/checks/SK010-undeclared-unit.check.ts +4 -0
  18. package/src/checks/SK011-sibling-unit-ranges.check.ts +4 -0
  19. package/src/checks/SK012-config-barrel-import.check.ts +5 -1
  20. package/src/checks/SK013-one-way-in-policy.check.ts +7 -3
  21. package/src/checks/SK014-prose-ste.check.ts +20 -6
  22. package/src/checks/SK015-nested-repo.check.ts +4 -2
  23. package/src/checks/SK016-legacy-check-shapes.check.ts +23 -16
  24. package/src/checks/SK017-vscode-hub-task.check.ts +22 -7
  25. package/src/checks/SK018-test-files-run.check.ts +6 -2
  26. package/src/checks/SK019-tests-run-in-units.check.ts +8 -4
  27. package/src/checks/SK020-self-spawning-check.check.ts +4 -0
  28. package/src/checks/SK022-machine-runs-the-pin.check.ts +6 -4
  29. package/src/checks/SK024-identity-colors.check.ts +5 -3
  30. package/src/checks/SK025-release-drift.check.ts +4 -5
  31. package/src/checks/SK026-asks-autoclose.check.ts +4 -4
  32. package/src/checks/SK027-agent-wiki-links.check.ts +34 -9
  33. package/src/checks/SK028-readme.check.ts +10 -2
  34. package/src/checks/SK029-explicit-check-files.check.ts +95 -0
  35. package/src/cli/cmd_Asks.ts +5 -5
  36. package/src/cli/cmd_Assets.ts +7 -7
  37. package/src/cli/cmd_Bump.ts +12 -11
  38. package/src/cli/cmd_Check.ts +8 -7
  39. package/src/cli/cmd_Checks.ts +7 -6
  40. package/src/cli/cmd_Ci.ts +12 -11
  41. package/src/cli/cmd_Commands.ts +16 -13
  42. package/src/cli/cmd_ConfigKeys.ts +7 -6
  43. package/src/cli/cmd_Contracts.ts +3 -3
  44. package/src/cli/cmd_CrossRepoActivity.ts +10 -6
  45. package/src/cli/cmd_Fleet.ts +9 -7
  46. package/src/cli/cmd_GlobalProcesses.ts +116 -4
  47. package/src/cli/cmd_Home.ts +23 -17
  48. package/src/cli/cmd_Hooks.ts +6 -5
  49. package/src/cli/cmd_Init.ts +5 -4
  50. package/src/cli/cmd_Keys.ts +8 -7
  51. package/src/cli/cmd_Mv.ts +7 -6
  52. package/src/cli/cmd_Needs.ts +4 -3
  53. package/src/cli/cmd_Overview.ts +4 -3
  54. package/src/cli/cmd_Pb.ts +2 -2
  55. package/src/cli/cmd_Planned.ts +6 -5
  56. package/src/cli/cmd_Prose.ts +5 -5
  57. package/src/cli/cmd_Reflect.ts +7 -7
  58. package/src/cli/cmd_Release.ts +6 -5
  59. package/src/cli/cmd_Releases.ts +5 -4
  60. package/src/cli/cmd_Serve.ts +4 -3
  61. package/src/cli/cmd_Smoke.ts +5 -4
  62. package/src/cli/cmd_Start.ts +7 -6
  63. package/src/cli/cmd_Status.ts +4 -3
  64. package/src/cli/cmd_Tui.ts +3 -2
  65. package/src/cli/cmd_When.ts +71 -0
  66. package/src/cli/cmd_Wiki.ts +4 -4
  67. package/src/cli.ts +7 -2
  68. package/src/collections/checks.ts +2 -0
  69. package/src/collections/collection-types.ts +24 -4
  70. package/src/collections/providers.ts +6 -2
  71. package/src/contracts/apply-contracts.ts +9 -8
  72. package/src/contracts/contract-types.ts +8 -2
  73. package/src/contracts/contracts-engine.ts +34 -13
  74. package/src/contracts/manifests/identity.contract.ts +6 -5
  75. package/src/contracts/manifests/social.contract.ts +4 -2
  76. package/src/contracts/manifests/steam.contract.ts +7 -3
  77. package/src/contracts/web-surface.ts +59 -0
  78. package/src/core/budget.ts +19 -9
  79. package/src/core/canonical-scripts.ts +1 -1
  80. package/src/core/check-runner.ts +63 -25
  81. package/src/core/config-rewrite.ts +37 -24
  82. package/src/core/discover-checks.ts +31 -7
  83. package/src/core/layout.ts +2 -0
  84. package/src/core/legacy-shapes.ts +39 -10
  85. package/src/core/motherhome.ts +88 -66
  86. package/src/core/one-way-in-policy.ts +2 -2
  87. package/src/core/outcome-words.ts +18 -2
  88. package/src/core/port-check-cli.ts +52 -0
  89. package/src/core/port-check-file.ts +295 -0
  90. package/src/core/port-check-files.ts +31 -0
  91. package/src/core/retention.ts +0 -14
  92. package/src/core/run-check.ts +14 -14
  93. package/src/core/run-ci.ts +11 -7
  94. package/src/core/run-fix.ts +30 -23
  95. package/src/core/run-fixers.ts +42 -5
  96. package/src/core/runs-matrix.ts +179 -0
  97. package/src/core/runs.ts +242 -0
  98. package/src/core/update-policy.ts +20 -0
  99. package/src/core/when-block.ts +29 -30
  100. package/src/core/when.ts +41 -103
  101. package/src/core/wire.ts +34 -9
  102. package/src/fleet/scan-fleet.ts +20 -4
  103. package/src/fleet/update-fleet.ts +6 -6
  104. package/src/home/activity-shape.ts +4 -1
  105. package/src/home/activity.ts +22 -15
  106. package/src/home/gui-app/ShipkitGui.swift +144 -25
  107. package/src/home/gui-app.ts +93 -30
  108. package/src/home/home-launchd.ts +30 -6
  109. package/src/home/home-routes.ts +14 -2
  110. package/src/home/home-start.ts +33 -5
  111. package/src/home/hubs.ts +5 -5
  112. package/src/home/machine-stores.ts +4 -2
  113. package/src/home/open-app.ts +23 -10
  114. package/src/home/pm-config.ts +15 -11
  115. package/src/home/pm-inputs.ts +76 -0
  116. package/src/home/pm.ts +11 -3
  117. package/src/home/process-rows.ts +23 -6
  118. package/src/home/resources.ts +67 -15
  119. package/src/home/run-daemon.ts +6 -1
  120. package/src/home/run-pm.ts +30 -5
  121. package/src/home/serve-web.ts +2 -2
  122. package/src/home/supervise.ts +24 -1
  123. package/src/home/widgets.ts +32 -3
  124. package/src/hooks/commit-message.ts +2 -3
  125. package/src/hooks/run-hooks.ts +60 -6
  126. package/src/init-repo.ts +6 -6
  127. package/src/ledger/gate-checks.ts +115 -61
  128. package/src/ledger/git-key.ts +5 -1
  129. package/src/ledger/ledger.ts +5 -5
  130. package/src/ledger/store.ts +11 -0
  131. package/src/ledger/toolchain-identity.ts +45 -27
  132. package/src/ledger/world-cache.ts +17 -15
  133. package/src/plan/repo-source.ts +18 -39
  134. package/src/release/run-release.ts +5 -0
  135. package/src/repo-config.ts +36 -56
  136. package/src/rules/rule.ts +2 -2
  137. package/src/scaffold/files/agent-coding.md +1 -1
  138. package/src/scaffold/files/agent-index.md +1 -1
  139. package/src/scaffold/files/verify-skill.md +2 -2
  140. package/src/serve/hub-app/activity-st.ts +5 -4
  141. package/src/serve/hub-app/activity-view.tsx +4 -4
  142. package/src/serve/hub-app/admin-view.tsx +13 -11
  143. package/src/serve/hub-app/check-view.tsx +66 -46
  144. package/src/serve/hub-app/check-words.ts +1 -11
  145. package/src/serve/hub-app/checks-svc.ts +16 -36
  146. package/src/serve/hub-app/col-tables-view.tsx +131 -0
  147. package/src/serve/hub-app/col-tables.ts +115 -0
  148. package/src/serve/hub-app/cols-svc.ts +33 -4
  149. package/src/serve/hub-app/dock-st.ts +3 -2
  150. package/src/serve/hub-app/dock-view.tsx +36 -0
  151. package/src/serve/hub-app/dock.css +7 -1
  152. package/src/serve/hub-app/float-cards.tsx +30 -0
  153. package/src/serve/hub-app/floating.tsx +41 -4
  154. package/src/serve/hub-app/git-st.ts +42 -0
  155. package/src/serve/hub-app/git-view.tsx +61 -0
  156. package/src/serve/hub-app/hash-route.ts +45 -1
  157. package/src/serve/hub-app/header-bar.tsx +62 -72
  158. package/src/serve/hub-app/health-svc.ts +3 -2
  159. package/src/serve/hub-app/home-st.ts +15 -3
  160. package/src/serve/hub-app/home-strip.css +69 -19
  161. package/src/serve/hub-app/home-strip.tsx +192 -118
  162. package/src/serve/hub-app/home.html +1 -0
  163. package/src/serve/hub-app/hub-app.tsx +21 -5
  164. package/src/serve/hub-app/hub-types.ts +11 -5
  165. package/src/serve/hub-app/hub.css +88 -44
  166. package/src/serve/hub-app/icons.tsx +15 -0
  167. package/src/serve/hub-app/index.html +1 -0
  168. package/src/serve/hub-app/keys.ts +11 -8
  169. package/src/serve/hub-app/live-svc.ts +3 -2
  170. package/src/serve/hub-app/machine-st.ts +34 -49
  171. package/src/serve/hub-app/palette-st.ts +5 -5
  172. package/src/serve/hub-app/palette-view.tsx +1 -1
  173. package/src/serve/hub-app/plan-view.tsx +51 -0
  174. package/src/serve/hub-app/planned-st.ts +9 -4
  175. package/src/serve/hub-app/processes-view.tsx +38 -0
  176. package/src/serve/hub-app/project-list.tsx +3 -3
  177. package/src/serve/hub-app/projects-svc.ts +15 -10
  178. package/src/serve/hub-app/prose-hits-st.ts +3 -2
  179. package/src/serve/hub-app/reach.ts +36 -0
  180. package/src/serve/hub-app/readme-svc.ts +3 -2
  181. package/src/serve/hub-app/repo-sections.ts +117 -0
  182. package/src/serve/hub-app/repo-st.ts +47 -10
  183. package/src/serve/hub-app/runs-tabs.ts +1 -1
  184. package/src/serve/hub-app/sidebar.tsx +12 -37
  185. package/src/serve/hub-app/timeline-svc.ts +3 -2
  186. package/src/serve/hub-app/timeline-view.tsx +14 -11
  187. package/src/serve/hub-app/tree-build.ts +149 -155
  188. package/src/serve/hub-app/tree-row.tsx +39 -9
  189. package/src/serve/hub-app/tree-svc.ts +70 -49
  190. package/src/serve/hub-app/when-view.tsx +89 -0
  191. package/src/serve/hub-app/when.css +17 -0
  192. package/src/serve/hub-runtime.ts +3 -16
  193. package/src/serve/project-gutter.ts +1 -2
  194. package/src/serve/run-serve.ts +1 -1
  195. package/src/serve/serve-hub.ts +4 -14
  196. package/src/serve/warm-cell.ts +49 -16
  197. package/src/suite/judge-suites.ts +42 -17
  198. package/src/suite/suite-cli.ts +5 -8
  199. package/src/wiki/lint-md.ts +2 -0
  200. package/src/wiki/scan-md.ts +37 -10
  201. package/src/serve/hub-app/processes-svc.ts +0 -63
  202. package/src/serve/hub-app/repo-sections.tsx +0 -61
  203. package/src/serve/repo-processes.ts +0 -89
@@ -16,8 +16,6 @@ import { JOBS, JOBS_KEEP_MS, JOBS_MAX, sweepJobs } from './jobs.ts'
16
16
  import { CI_LOG_DIR } from './layout.ts'
17
17
  import { compactDb, dbPath, shipkitRoot } from '../ledger/db.ts'
18
18
  import { SCAN_INDEX_KEEP_MS, sweepScanIndexes } from '../ledger/git-scan.ts'
19
- import { capLog, LOG_MAX_BYTES } from '../home/supervise.ts'
20
- import { processDir } from '../serve/repo-processes.ts'
21
19
  import { KEEP_MS as RECORD_KEEP_MS, MAX_ROWS, recordUse, sweepRuns } from '../ledger/run-log.ts'
22
20
  import { answersDb, answersUse, importLegacyStore, KEEP_MS as ANSWERS_KEEP_MS, legacyStoreDir, MAX_ANSWERS, sweepAnswers } from '../ledger/store.ts'
23
21
 
@@ -169,18 +167,6 @@ export function repoStores(root: string): Store[] {
169
167
  },
170
168
  sweep: (now) => sweepJobs(root, now),
171
169
  },
172
- {
173
- // the dev servers the hub runs for the repo (serve.processes)
174
- id: 'process logs',
175
- where: `${processDir(root)}/*.log`,
176
- cap: { maxFileBytes: LOG_MAX_BYTES },
177
- measure: () => walkUse(processDir(root), { match: (n) => n.endsWith('.log') }),
178
- sweep: () => {
179
- let names: string[] = []
180
- try { names = readdirSync(processDir(root)) } catch { return }
181
- for (const n of names) if (n.endsWith('.log')) capLog(join(processDir(root), n))
182
- },
183
- },
184
170
  ]
185
171
  if (gitDir != null) {
186
172
  const dir = join(gitDir, 'shipkit')
@@ -17,7 +17,7 @@ import { gateBinary } from '../ledger/toolchain-identity.ts'
17
17
  import { runChecks, selectChecks, type Audience, type CheckEvent, type CheckResult, type RunnableCheck } from './check-runner.ts'
18
18
  import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
19
19
  import { reportToMotherhome } from './motherhome.ts'
20
- import { unknownWhenIds } from './when.ts'
20
+ import { unknownOffIds } from './when.ts'
21
21
  import { unknownBudgetIds } from './budget.ts'
22
22
  import { formatLine, type Line } from './wire.ts'
23
23
  import { writeStatus } from './write-status.ts'
@@ -75,6 +75,8 @@ export type CheckOpts = {
75
75
  buildOnly?: boolean
76
76
  /** the gate asks the proactive checks too (`release.requireHealthy`) */
77
77
  includeProactive?: boolean
78
+ /** the release preflight: the rows declaring `onRelease` run */
79
+ release?: boolean
78
80
  /** ids this caller does not need (a sub-unit release) */
79
81
  exclude?: string[]
80
82
  /** the already-composed check list, when the caller built one (runCi does,
@@ -87,11 +89,8 @@ export type CheckOpts = {
87
89
  /** the gate binary that own ledger is keyed on (default: the one running
88
90
  * it). Ignored when `ledger` is passed, which carries its own. */
89
91
  cliEntry?: string
90
- /** refresh the motherhome registry row. runCi passes false and does it
91
- * ITSELF, after the ledger has committed: that row is a TRACKED file under
92
- * `wiki/`, so writing it before the post-run re-hash makes the gate look
93
- * like an agent editing the tree and throws away every result the run just
94
- * measured. */
92
+ /** file the motherhome row in the machine db (core/motherhome.ts). runCi
93
+ * passes false and does it ITSELF, once, after its verdict. */
95
94
  reportHome?: boolean
96
95
  }
97
96
 
@@ -110,9 +109,9 @@ export type CheckRun = {
110
109
  advisoryFailures: string[]
111
110
  /** checks that never declared `blocking` — always fatal */
112
111
  undeclared: string[]
113
- /** `when` entries naming no check of this repo — a toggle that does
112
+ /** `off` entries naming no check of this repo — a toggle that does
114
113
  * nothing is worse than none, so it is fatal too */
115
- unknownWhen: string[]
114
+ unknownOff: string[]
116
115
  /** `budgets` entries naming no check of this repo, fatal for the same reason */
117
116
  unknownBudgets: string[]
118
117
  /** what every asked-for check did, in declaration order */
@@ -128,6 +127,7 @@ function selectionOf(checks: RunnableCheck[], o: CheckOpts, tier: TestTier) {
128
127
  checks,
129
128
  audience: o.audience ?? 'health',
130
129
  fullTier: tier === 'full',
130
+ release: o.release,
131
131
  skipGroups: o.skipGroups,
132
132
  onlyIds: o.only,
133
133
  withIds: o.withIds,
@@ -214,7 +214,7 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
214
214
  // page just replaced
215
215
  if (summary.parked.length > 0) {
216
216
  const head = summary.parked.slice(0, 8).join(', ')
217
- say({ kind: 'skip', text: `${summary.parked.length} check(s) not in this run (health only, release only at the quick tier, or a skipped group): ${head}${summary.parked.length > 8 ? `, and ${summary.parked.length - 8} more` : ''}` }, o.detail ?? print)
217
+ say({ kind: 'skip', text: `${summary.parked.length} check(s) not in this run (their runs do not name this context, or a skipped group, shipkit when shows each): ${head}${summary.parked.length > 8 ? `, and ${summary.parked.length - 8} more` : ''}` }, o.detail ?? print)
218
218
  }
219
219
  // the survey dies with the run that made it, and only the run that made it
220
220
  if (inherited == null || inherited === '') for (const f of [sharePath, `${sharePath}.lock`]) { try { rmSync(f, { force: true }) } catch {} }
@@ -228,17 +228,17 @@ export async function runCheckPass(root: string, o: CheckOpts = {}): Promise<Che
228
228
  if (o.ledger === undefined && ledger != null) for (const l of ledger.header(ledger.commit())) say(l)
229
229
  // the caller that OPENED the record owns writing it, the same rule
230
230
  if (o.runLog === undefined && log != null) for (const l of log.flush()) say(l)
231
- // a `when` id naming nothing is a switch wired to no check: the id is read
231
+ // an `off` id naming nothing is a switch wired to no check: the id is read
232
232
  // against the WHOLE inventory this run composed, never its narrowed list
233
233
  const rows = composed.map((g) => g.row)
234
- const unknownWhen = unknownWhenIds(rows, c)
234
+ const unknownOff = unknownOffIds(rows, c)
235
235
  const unknownBudgets = unknownBudgetIds(rows.map((r) => r.decl.id), c.budgets)
236
236
  return {
237
237
  lines: [...collected, ...lines],
238
238
  blocked: !summary.ok,
239
239
  advisoryFailures: summary.advisoryFailures,
240
240
  undeclared: summary.undeclared,
241
- unknownWhen,
241
+ unknownOff,
242
242
  unknownBudgets,
243
243
  results: summary.results,
244
244
  interrupted: summary.interrupted,
@@ -250,13 +250,13 @@ export async function runCheck(root: string, o: CheckOpts = {}): Promise<number>
250
250
  const print = o.print ?? ((l: string) => console.log(l))
251
251
  const c = o.config ?? (await loadRepoConfig(root))
252
252
  const run = await runCheckPass(root, { ...o, config: c })
253
- const code = run.blocked || run.undeclared.length > 0 || run.unknownWhen.length > 0 || run.unknownBudgets.length > 0 ? 1 : 0
253
+ const code = run.blocked || run.undeclared.length > 0 || run.unknownOff.length > 0 || run.unknownBudgets.length > 0 ? 1 : 0
254
254
  if (o.json === true) { print(JSON.stringify(run)); return code }
255
255
  for (const u of run.undeclared) {
256
256
  print(`FAIL: ${u} does not declare whether it blocks`)
257
257
  print(` fix: add \`blocking: true\` (about this repo) or \`false\` (about the world) to that check`)
258
258
  }
259
- for (const key of [...run.unknownWhen.map((id) => `when.${id}`), ...run.unknownBudgets.map((id) => `budgets.${id}`)]) {
259
+ for (const key of [...run.unknownOff.map((id) => `off.${id}`), ...run.unknownBudgets.map((id) => `budgets.${id}`)]) {
260
260
  print(`FAIL: ${key} names no check of this repo`)
261
261
  print(` fix: \`shipkit checks\` lists every id; drop the entry or correct it`)
262
262
  }
@@ -32,8 +32,11 @@ export type CiOpts = {
32
32
  /** repair the mechanical slots before gating. default on locally, verify
33
33
  * mode under CI. core/run-fixers.ts owns the scope. */
34
34
  fix?: boolean
35
- /** also apply the structural SK rule fixers during the fix pass */
35
+ /** also apply the repairs of the checks declaring `fix: 'ci'` during the
36
+ * fix pass */
36
37
  fixRules?: boolean
38
+ /** the release preflight: the rows declaring `onRelease` run */
39
+ release?: boolean
37
40
  /** self-arm the git hooks in the fix pass (interactive ci entry only) */
38
41
  armHooks?: boolean
39
42
  /** run only the checks that can block (core/run-checks onlyBlocking) */
@@ -203,6 +206,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
203
206
  composed,
204
207
  audience: 'gate',
205
208
  includeProactive: o.failOnAdvisory === true,
209
+ release: o.release,
206
210
  frozen: o.frozen,
207
211
  onlyBlocking: o.onlyBlockingChecks,
208
212
  only: o.onlyIds,
@@ -226,7 +230,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
226
230
  })
227
231
  const reg = ledger.layer.registration
228
232
  for (const e of reg.errors) { registrationFailed = true; sink(`FAIL: ${e}`) }
229
- if (registrationFailed) sink(' fix: a check declares `inputs` (content), or `ttl` (world), or `never: <reason>`, and never two of them')
233
+ if (registrationFailed) sink(' fix: each check declares one cache that its body can honour: files, reads, ttl, fact or never (build/shipkit/runs.md)')
230
234
  // both counts, always. Printing only the burn-down made `never:` the
231
235
  // cheap way to shrink the visible number, and that field carries no
232
236
  // verification at all: a growing decided population has to be as
@@ -262,7 +266,7 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
262
266
  }
263
267
  // a switch wired to no check does nothing, silently, which is the one thing
264
268
  // a toggle must never do
265
- for (const key of [...run.unknownWhen.map((id) => `when.${id}`), ...run.unknownBudgets.map((id) => `budgets.${id}`)]) {
269
+ for (const key of [...run.unknownOff.map((id) => `off.${id}`), ...run.unknownBudgets.map((id) => `budgets.${id}`)]) {
266
270
  sink(`FAIL: ${key} names no check of this repo`)
267
271
  sink(` fix: \`shipkit checks\` lists every id; drop the entry or correct it`)
268
272
  }
@@ -283,15 +287,15 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
283
287
  if (ledger != null) for (const [i, l] of ledger.header(ledger.commit()).entries()) (i === 0 ? detail : sink)(formatLine(l, false))
284
288
  // one write for the whole gate, after everything has finished
285
289
  for (const l of runLog.flush()) sink(formatLine(l, false))
286
- const code = run.blocked || fixFailed || registrationFailed || advisoryFatal || run.undeclared.length > 0 || run.unknownWhen.length > 0 || run.unknownBudgets.length > 0 ? 1 : 0
290
+ const code = run.blocked || fixFailed || registrationFailed || advisoryFatal || run.undeclared.length > 0 || run.unknownOff.length > 0 || run.unknownBudgets.length > 0 ? 1 : 0
287
291
  // THE VERDICT, one line and only on a pass: a red run already said so in
288
292
  // its FAIL lines. The whole gate adds the tier and the cache counts, which
289
293
  // a narrowed run asked no question about
290
294
  const verdict = code === 0 && !run.interrupted ? verdictLine(run.results, scope.fullGate ? { tier, cache: ledger?.counts ?? null } : null) : null
291
295
  if (verdict != null) sink(verdict)
292
- // motherhome row (docsBase wiki on this disk) — never affects the gate,
293
- // and never fires under frozen: a bump/release gate must not re-dirty the
294
- // tree it just proved clean
296
+ // the motherhome row, filed in the machine db and landed in the docsBase
297
+ // page by the daemon (core/motherhome.ts): never affects the gate, never
298
+ // writes in another repo, and a frozen run files nothing
295
299
  if (o.frozen !== true) {
296
300
  const mh = reportToMotherhome(root, c)
297
301
  if (mh.note != null) print(mh.note)
@@ -1,11 +1,13 @@
1
- // `shipkit check --fix [<id> …]` — apply check autofixes, then re-run and
2
- // show the real end state. Never runs under ci (the gate stays pure), never
3
- // caches (a fixer must see reality). No ids = every fixable check; a
4
- // requested id that is unknown or unfixable fails loud instead of silently
5
- // narrowing.
1
+ // `shipkit check --fix [<id> …]` — apply check repairs, then re-run and
2
+ // show the real end state. Never caches (a repair must see reality). No ids =
3
+ // every check declaring `fix: 'ci'`, which is also what the gate's fix pass
4
+ // applies; a `fix: 'manual'` repair runs only when its id is named. A
5
+ // requested id that is unknown or has no repair fails loud instead of
6
+ // silently narrowing.
6
7
  import { CheckCtx } from './check-ctx.ts'
7
8
  import { formatLine } from './wire.ts'
8
- import { whenOfRule, whenOfCheck, type WhenDecl } from './when.ts'
9
+ import type { FixWhen } from './runs.ts'
10
+ import { gateInventory } from '../ledger/gate-checks.ts'
9
11
  import { loadRepoConfig } from '../repo-config.ts'
10
12
  import { discoverChecks } from './discover-checks.ts'
11
13
  import { runCheckBody, type CheckDef, type CheckEnv } from '../check.ts'
@@ -34,19 +36,25 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
34
36
  const ctx = (c: RepoConfig): CheckCtx => CheckCtx.open({ root, config: c })
35
37
  const color = process.stdout.isTTY && process.env.NO_COLOR == null
36
38
  /** one fixable check, whatever declared it */
37
- type Target = { id: string; title: string; owner: string; run: CheckDef['run']; fix?: CheckDef['fix']; when: WhenDecl }
39
+ type Target = { id: string; title: string; owner: string; run: CheckDef['run']; fix?: CheckDef['repair']; when: FixWhen; off: string | null }
40
+ // what each row resolved to: its declared `fix` and whether it is off
41
+ const resolved = new Map((await gateInventory(root, config)).map((r) => [r.decl.id, r.runs]))
38
42
  // a rule the repo still registers fixes exactly like a check file, one
39
- // code path. Only a check that declared a `fix` is a target at all
43
+ // code path, with the fix word its derived row carries
40
44
  const all: Target[] = [
41
- ...(await discoverChecks(root)).map((u): Target => ({ id: u.id, title: u.title, owner: u.owner, run: u.def.run, fix: u.def.fix, when: whenOfCheck(config, u) })),
42
- ...(config.rules ?? []).map((r): Target => ({
43
- id: r.id, title: r.title, owner: r.owner, when: whenOfRule(config, r.id),
44
- run: (c) => r.lint(c, { root: c.root, config: c.config }),
45
- fix: r.fix == null ? undefined : (c) => r.fix!({ root: c.root, config: c.config }),
46
- })),
45
+ ...(await discoverChecks(root)).map((u): Target => ({ id: u.id, title: u.title, owner: u.owner, run: u.def.run, fix: u.def.repair, when: resolved.get(u.id)?.fix ?? u.def.fix, off: resolved.get(u.id)?.off ?? null })),
46
+ ...(config.rules ?? []).map((r): Target => {
47
+ const runs = resolved.get(r.id)
48
+ return {
49
+ id: r.id, title: r.title, owner: r.owner, when: runs?.fix ?? 'none', off: runs?.off ?? null,
50
+ run: (c) => r.lint(c, { root: c.root, config: c.config }),
51
+ fix: r.fix == null ? undefined : (c) => r.fix!({ root: c.root, config: c.config }),
52
+ }
53
+ }),
47
54
  ]
48
55
 
49
- let targets = all.filter((r) => r.fix != null)
56
+ // no ids: the repairs declared for the gate. A manual one runs by name
57
+ let targets = all.filter((r) => r.fix != null && r.when === 'ci')
50
58
  if (o.ids != null && o.ids.length > 0) {
51
59
  for (const id of o.ids) {
52
60
  const r = all.find((x) => x.id === id)
@@ -54,17 +62,16 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
54
62
  if (r.fix == null) { print(`FAIL: [${id}] has no autofix — fix by hand: ${config.docsBase ?? ''}${r.owner}`); return 1 }
55
63
  }
56
64
  const wanted = new Set(o.ids)
57
- targets = targets.filter((r) => wanted.has(r.id))
65
+ targets = all.filter((r) => r.fix != null && wanted.has(r.id))
58
66
  }
59
67
 
60
68
  let exit = 0
61
69
  for (const rule of targets) {
62
70
  const before = ctx(env.config)
63
- const off = rule.when
64
- await runCheckBody(rule, before, off.at === 'off' ? { reason: off.reason } : undefined)
71
+ await runCheckBody(rule, before, rule.off != null ? { reason: rule.off } : undefined)
65
72
  // a check that is OFF never autofixes: the repo broke that invariant on purpose
66
- if (off.at === 'off') {
67
- if (o.quiet !== true) print(`skip: [${rule.id}] ${rule.id} exempt: ${off.reason ?? ''}`)
73
+ if (rule.off != null) {
74
+ if (o.quiet !== true) print(`skip: [${rule.id}] ${rule.id} exempt: ${rule.off}`)
68
75
  continue
69
76
  }
70
77
  if (!before.failed && !before.warned) {
@@ -97,8 +104,8 @@ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
97
104
  for (const l of after.lines) (o.quiet === true ? o.detail ?? print : say)(formatLine({ ...l, text: l.text.replace(/ · autofix: shipkit check --fix \S+$/, '') }, color))
98
105
  if (after.failed) exit = 1
99
106
  }
100
- const unfixable = all.filter((r) => r.fix == null).length
101
- if (unfixable > 0 && o.quiet !== true && (o.ids == null || o.ids.length === 0))
102
- print(`${unfixable} check(s) have no autofix — they report in the run below`)
107
+ const manual = all.filter((r) => r.fix != null && r.when === 'manual').map((r) => r.id)
108
+ if (manual.length > 0 && o.quiet !== true && (o.ids == null || o.ids.length === 0))
109
+ print(`${manual.length} repair(s) run only by name: shipkit check --fix ${manual.join(' ')}`)
103
110
  return exit
104
111
  }
@@ -8,8 +8,8 @@
8
8
  // rewrites the tree it checks can turn red green with no commit. one
9
9
  // mechanism, two modes, rather than a formatter plus a format:check that
10
10
  // drift apart.
11
- import { existsSync, readFileSync } from 'fs'
12
- import { join } from 'path'
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
12
+ import { dirname, join } from 'path'
13
13
  import { fileURLToPath } from 'url'
14
14
  import { armHooks } from '../hooks/run-hooks.ts'
15
15
  import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
@@ -22,6 +22,8 @@ import { layoutSummary, migrateLayout } from './migrate-layout.ts'
22
22
  import { renderPolicy } from '../checks/SK004-gitignore-policy.check.ts'
23
23
  import { spawnWithTimeout } from './spawn-timeout.ts'
24
24
  import { gitBin } from './git-bin.ts'
25
+ import { LAYOUT } from './layout.ts'
26
+ import { gateBinary, toolchainIdentity } from '../ledger/toolchain-identity.ts'
25
27
 
26
28
  const FIXER_TIMEOUT_MS = 120_000
27
29
 
@@ -123,6 +125,31 @@ function treeState(root: string): Map<string, string> | null {
123
125
  return m
124
126
  }
125
127
 
128
+ /** where the key of the last clean fix pass is kept, per checkout */
129
+ const memoPath = (root: string): string => join(root, LAYOUT.tmp, 'fix-pass.key')
130
+
131
+ /** what the fixers would see: HEAD, the dirty paths and their bytes, the
132
+ * toolchain that runs them, and their commands. null when HEAD cannot be read */
133
+ function fixMemo(root: string, tree: Map<string, string>, cmds: string[][]): string | null {
134
+ const head = Bun.spawnSync([gitBin(), 'rev-parse', 'HEAD'], { cwd: root })
135
+ if (head.exitCode !== 0) return null
136
+ const h = new Bun.CryptoHasher('sha256')
137
+ h.update(`head=${head.stdout.toString().trim()}\n`)
138
+ for (const [path, hash] of [...tree].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) h.update(`${path}\0${hash}\n`)
139
+ try { h.update(`toolchain=${toolchainIdentity(root, gateBinary()).id}\n`) } catch { return null }
140
+ h.update(`cmds=${JSON.stringify(cmds)}\n`)
141
+ return h.digest('hex')
142
+ }
143
+
144
+ const readMemo = (root: string): string | null => { try { return readFileSync(memoPath(root), 'utf8').trim() } catch { return null } }
145
+
146
+ const writeMemo = (root: string, key: string): void => {
147
+ try {
148
+ mkdirSync(dirname(memoPath(root)), { recursive: true })
149
+ writeFileSync(memoPath(root), `${key}\n`)
150
+ } catch { /* a memo that cannot be kept only means the next pass runs the fixers */ }
151
+ }
152
+
126
153
  /** the bytes of repo.config.ts and the set of check and check files, as one
127
154
  * hash. Read without git, so a repo with no history is covered too */
128
155
  function shapeStamp(root: string): string {
@@ -231,13 +258,22 @@ async function fixPassBody(root: string, loaded: RepoConfig, o: FixPassOpts, ver
231
258
  for (const u of unfilled) detail(`skip: ${u.id} (${u.reason})`)
232
259
  for (const d of disabled) detail(`skip: ${d.id} (${d.reason})`)
233
260
  const before = treeState(root)
234
-
235
- for (const s of slots) {
261
+ // the fixers already ran clean over exactly this tree (HEAD, every dirty
262
+ // path's bytes, the toolchain, the commands): running them again rewrites
263
+ // nothing and costs a whole formatter pass. CI never skips: there the
264
+ // pass IS the check
265
+ const memo = verify || before == null ? null : fixMemo(root, before, slots.map((s) => s.cmd))
266
+ const skip = memo != null && readMemo(root) === memo
267
+ let fixersOk = true
268
+ if (skip) detail(`ok: fixers skipped, the tree is unchanged since they last ran clean (${slots.length} fixer(s))`)
269
+ else for (const s of slots) {
236
270
  const r = await spawnWithTimeout(s.cmd, { cwd: root, timeoutMs: FIXER_TIMEOUT_MS })
237
271
  // a broken fixer must never be mistaken for a clean one: the gate
238
272
  // still runs, but the failure is loud and names the command
239
- if (r.code !== 0)
273
+ if (r.code !== 0) {
274
+ fixersOk = false
240
275
  o.print(`WARN fix ${s.id} failed (exit ${r.code}) — ${r.out.trim().split('\n').at(-1)?.slice(0, 160) ?? ''}; the gate below still ran`)
276
+ }
241
277
  }
242
278
  // structural repo-config repair is never implicit, and never in verify mode
243
279
  if (o.rules === true && !verify) await runFix(root, { quiet: true, print: o.print, detail: o.detail, config })
@@ -265,6 +301,7 @@ async function fixPassBody(root: string, loaded: RepoConfig, o: FixPassOpts, ver
265
301
  return { ran: true, touched, failed: false, reshaped }
266
302
  }
267
303
  if (touched.length === 0) {
304
+ if (memo != null && !skip && fixersOk) writeMemo(root, memo)
268
305
  detail(`ok: ${verify ? 'fixers verified' : 'fix pass'} clean (${slots.length} fixer(s), nothing to rewrite)`)
269
306
  return { ran: true, touched, failed: false, reshaped }
270
307
  }
@@ -0,0 +1,179 @@
1
+ // WHAT RUNS WHEN, as data every surface prints the same way: the legend (each
2
+ // axis, each value, what it means) and one row of cells per check. Pure: it
3
+ // reads composed rows and returns strings, so `shipkit when`, the hub's when
4
+ // page and the docs page can never disagree.
5
+ import type { CheckRow } from '../ledger/gate-checks.ts'
6
+ import { cellText, RUNS_KEYS, type RunsAxis, type RunsSource } from './runs.ts'
7
+
8
+ export type MatrixAxis = RunsAxis | 'fix'
9
+
10
+ export const MATRIX_AXES: readonly MatrixAxis[] = [...RUNS_KEYS, 'fix']
11
+
12
+ /** one axis of the legend: the command or event it is, and every value */
13
+ export type LegendAxis = { axis: MatrixAxis | 'cache'; short: string; context: string; values: { value: string; means: string }[] }
14
+
15
+ /** THE legend. Every surface prints it verbatim, and build/shipkit/runs.md is
16
+ * generated from it */
17
+ export const RUNS_LEGEND: readonly LegendAxis[] = [
18
+ {
19
+ axis: 'onPreCommit', short: 'commit', context: '`git commit`, in the pre-commit hook, on the working tree',
20
+ values: [
21
+ { value: 'false', means: 'never at commit' },
22
+ { value: '{ budgetMs: n }', means: 'runs in process with no cache, before the message. A failure refuses the commit, a run past n ms (at most 100) warns' },
23
+ ],
24
+ },
25
+ {
26
+ axis: 'onCi', short: 'ci', context: '`shipkit ci`, the quick gate: an agent runs it once before it pushes its work, and the hub button runs a whole one. Named after the command, never a CI server',
27
+ values: [
28
+ { value: 'true', means: 'in every quick gate: the cached answer while its key has not moved, else the body runs' },
29
+ { value: 'false', means: 'not in the quick gate. `--with <id>` or `--only <id>` still runs it by name' },
30
+ ],
31
+ },
32
+ {
33
+ axis: 'onFull', short: 'full', context: '`shipkit ci --full`: the quick gate (every onCi row) plus every slow suite',
34
+ values: [
35
+ { value: 'true', means: 'in the full gate' },
36
+ { value: 'false', means: 'not in the full gate' },
37
+ ],
38
+ },
39
+ {
40
+ axis: 'onRelease', short: 'release', context: 'the preflight of `shipkit bump` and `shipkit release`: full tier, frozen, no cache read, no repair',
41
+ values: [
42
+ { value: 'true', means: 'its body runs before a version ships, never from the cache, and a failure stops the release. A non blocking check runs there only when the repo sets release.requireHealthy' },
43
+ { value: 'false', means: 'a release never asks it' },
44
+ ],
45
+ },
46
+ {
47
+ axis: 'onHealth', short: 'health', context: '`shipkit check`, the TUI checks panel and the hub health run: is this repo healthy',
48
+ values: [
49
+ { value: 'true', means: 'part of the health pass' },
50
+ { value: 'false', means: 'not part of it (every build step)' },
51
+ ],
52
+ },
53
+ {
54
+ axis: 'onSave', short: 'save', context: 'the daemon, 60s after the last save in the repo',
55
+ values: [
56
+ { value: 'false', means: 'a save never starts it' },
57
+ { value: "'moved'", means: 'runs only when a file it is keyed on moved' },
58
+ { value: "'burst'", means: 'runs after every save burst, whatever moved (nothing keys it)' },
59
+ ],
60
+ },
61
+ {
62
+ axis: 'onClock', short: 'clock', context: 'the daemon, on its own time',
63
+ values: [
64
+ { value: 'false', means: 'never on a clock' },
65
+ { value: "'once'", means: 'once while no answer exists for its fact (first run, a changed declaration)' },
66
+ { value: '{ every, inactive }', means: "on a period ('24h', a range ['20h','28h'], a cron, or a function of its last result). inactive 'wait' holds it while nobody works in the repo, 'run' runs it anyway" },
67
+ ],
68
+ },
69
+ {
70
+ axis: 'fix', short: 'fix', context: 'which command applies the check\'s repair',
71
+ values: [
72
+ { value: "'none'", means: 'no repair: a failure is fixed by hand' },
73
+ { value: "'ci'", means: 'the fix pass of `shipkit ci` applies it after the body fails or warns, and `shipkit check --fix`' },
74
+ { value: "'manual'", means: 'only `shipkit check --fix <id>` or the hub fix button' },
75
+ ],
76
+ },
77
+ {
78
+ axis: 'cache', short: 'cache', context: 'how an answer is kept, in every context but the release and the hook',
79
+ values: [
80
+ { value: 'files', means: 'keyed on these paths and the check\'s own code: the cached answer holds while they do not move' },
81
+ { value: 'reads', means: 'keyed on what the body read last time, which the cache checks again before it answers' },
82
+ { value: 'ttl', means: 'an answer about the world, kept this long' },
83
+ { value: 'fact', means: 'a decided value, kept until the declaration changes' },
84
+ { value: 'never', means: 'runs every time, with the reason' },
85
+ ],
86
+ },
87
+ ]
88
+
89
+ /** one cell: what it prints, whether the context runs it, and who said so */
90
+ export type RunsCell = { text: string; on: boolean; source: RunsSource }
91
+
92
+ /** one check as the matrix prints it */
93
+ export type RunsCells = {
94
+ cells: Record<MatrixAxis, RunsCell>
95
+ cache: string
96
+ /** the reason it runs nowhere, null for a live row */
97
+ off: string | null
98
+ /** why a derived row has its values, null for a declared one */
99
+ derivedWhy: string | null
100
+ }
101
+
102
+ export function runsCellsOf(row: CheckRow): RunsCells {
103
+ const r = row.runs
104
+ const cells = Object.fromEntries(MATRIX_AXES.map((k) => {
105
+ const v = k === 'fix' ? r.fix : r[k]
106
+ const on = r.off == null && v !== false && v !== 'none'
107
+ return [k, { text: cellText(r, k), on, source: r.source[k] }]
108
+ })) as Record<MatrixAxis, RunsCell>
109
+ return { cells, cache: row.cache, off: r.off, derivedWhy: r.derivedWhy }
110
+ }
111
+
112
+ /** where a row runs, as one line of words: every context that runs it, with
113
+ * its value when that says more than yes, then the command that repairs it */
114
+ export function runsWords(c: RunsCells): string {
115
+ if (c.off != null) return `runs nowhere: ${c.off}`
116
+ const word = (k: MatrixAxis): string => {
117
+ const cell = c.cells[k]
118
+ const short = RUNS_LEGEND.find((a) => a.axis === k)!.short
119
+ return `${short}${cell.text === 'yes' ? '' : ` ${cell.text}`}`
120
+ }
121
+ const on = RUNS_KEYS.filter((k) => c.cells[k].on).map(word)
122
+ const where = on.length === 0 ? 'runs only when named' : `runs on ${on.join(' · ')}`
123
+ return c.cells.fix.on ? `${where}, repaired by ${word('fix')}` : where
124
+ }
125
+
126
+ /** what a cell's value means, the legend's own sentence for it. A cell
127
+ * prints short (yes, ·, moved, 24h); the legend names the declared value */
128
+ export function cellMeaning(k: MatrixAxis | 'cache', text: string): string {
129
+ const values = RUNS_LEGEND.find((a) => a.axis === k)!.values
130
+ const declared = k === 'cache'
131
+ ? text.split(/[\s:]/)[0]!
132
+ : text === 'yes' ? 'true'
133
+ : text === '·' ? 'false'
134
+ : k === 'onPreCommit' ? '{ budgetMs: n }'
135
+ : k === 'onClock' && text !== 'once' ? '{ every, inactive }'
136
+ : `'${text}'`
137
+ return values.find((v) => v.value === declared)?.means ?? ''
138
+ }
139
+
140
+ /** which rows a context runs, for the matrix's column filter */
141
+ export const inColumn = (c: RunsCells, k: MatrixAxis): boolean => c.cells[k].on
142
+
143
+ /** the matrix as terminal lines: the legend, then one row per check, and a
144
+ * row the repo turned off with its reason */
145
+ /** what a failure does, as one word: a build step and a blocking check stop
146
+ * you, a check about the world only warns */
147
+ export const stopsWord = (r: { blocking: boolean | null; build: boolean }): string => (r.build ? 'build' : r.blocking === false ? 'warns' : 'blocks')
148
+
149
+ export function matrixLines(rows: { id: string; blocking: boolean | null; build: boolean; runs: RunsCells }[], o: { legend: boolean; only?: MatrixAxis } = { legend: true }): string[] {
150
+ const out: string[] = []
151
+ if (o.legend) {
152
+ for (const a of RUNS_LEGEND) {
153
+ out.push(`${a.axis.padEnd(12)}${a.context}`)
154
+ for (const v of a.values) out.push(`${''.padEnd(14)}${v.value.padEnd(20)}${v.means}`)
155
+ }
156
+ out.push('')
157
+ }
158
+ const shown = o.only == null ? rows : rows.filter((r) => inColumn(r.runs, o.only!))
159
+ const width = Math.max(5, ...shown.map((r) => r.id.length)) + 2
160
+ const heads = MATRIX_AXES.map((k) => RUNS_LEGEND.find((a) => a.axis === k)!.short)
161
+ const colW = (i: number): number => Math.max(heads[i]!.length, ...shown.map((r) => cellOut(r.runs, MATRIX_AXES[i]!).length)) + 2
162
+ const widths = MATRIX_AXES.map((_, i) => colW(i))
163
+ out.push(`${'check'.padEnd(width)}${'fails'.padEnd(8)}${heads.map((h, i) => h.padEnd(widths[i]!)).join('')}cache`)
164
+ for (const r of shown) {
165
+ if (r.runs.off != null) { out.push(`${r.id.padEnd(width)}${stopsWord(r).padEnd(8)}off: ${r.runs.off}`); continue }
166
+ out.push(`${r.id.padEnd(width)}${stopsWord(r).padEnd(8)}${MATRIX_AXES.map((k, i) => cellOut(r.runs, k).padEnd(widths[i]!)).join('')}${r.runs.cache}`)
167
+ }
168
+ return out
169
+ }
170
+
171
+ const cellOut = (c: RunsCells, k: MatrixAxis): string => c.cells[k].text
172
+
173
+ /** the legend as markdown, the block `shipkit when --out` writes into a docs
174
+ * page between its markers */
175
+ export function legendMarkdown(): string {
176
+ const out = ['| key | the context | value | what it means |', '|---|---|---|---|']
177
+ for (const a of RUNS_LEGEND) a.values.forEach((v, i) => out.push(`| ${i === 0 ? `\`${a.axis}\`` : ''} | ${i === 0 ? a.context.replace(/\|/g, '\\|') : ''} | \`${v.value}\` | ${v.means.replace(/\|/g, '\\|')} |`))
178
+ return out.join('\n')
179
+ }