rvlib-shipkit 0.38.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. package/CHANGELOG.md +379 -16
  2. package/README.md +26 -14
  3. package/package.json +2 -2
  4. package/src/assets/gen/comfyts-gen.ts +27 -10
  5. package/src/assets/gen/derived.ts +1 -1
  6. package/src/assets/gen/generate-roll.ts +1 -1
  7. package/src/assets/kit-config.ts +6 -6
  8. package/src/assets/run-assets.ts +14 -10
  9. package/src/assets/server/studio-server.ts +6 -5
  10. package/src/check.ts +343 -0
  11. package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
  12. package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
  13. package/src/checks/SK003-claude-md-shape.check.ts +66 -0
  14. package/src/checks/SK004-gitignore-policy.check.ts +309 -0
  15. package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +49 -32
  16. package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
  17. package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
  18. package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
  19. package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
  20. package/src/checks/SK010-undeclared-unit.check.ts +111 -0
  21. package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
  22. package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
  23. package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
  24. package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
  25. package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
  26. package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
  27. package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
  28. package/src/checks/SK018-test-files-run.check.ts +28 -0
  29. package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
  30. package/src/checks/SK020-self-spawning-check.check.ts +147 -0
  31. package/src/checks/contracts.check.ts +20 -0
  32. package/src/cli/cmd_Asks.ts +3 -2
  33. package/src/cli/cmd_Assets.ts +2 -2
  34. package/src/cli/cmd_Bump.ts +1 -1
  35. package/src/cli/cmd_Check.ts +6 -6
  36. package/src/cli/cmd_Checks.ts +95 -0
  37. package/src/cli/cmd_Ci.ts +48 -13
  38. package/src/cli/cmd_Commands.ts +2 -6
  39. package/src/cli/cmd_ConfigKeys.ts +69 -0
  40. package/src/cli/cmd_Contracts.ts +1 -1
  41. package/src/cli/cmd_Fleet.ts +16 -3
  42. package/src/cli/cmd_Hooks.ts +1 -1
  43. package/src/cli/cmd_Keys.ts +43 -18
  44. package/src/cli/cmd_Mv.ts +2 -2
  45. package/src/cli/cmd_Prose.ts +10 -1
  46. package/src/cli/cmd_Releases.ts +2 -2
  47. package/src/cli/cmd_Serve.ts +1 -1
  48. package/src/cli/cmd_Start.ts +6 -6
  49. package/src/cli/cmd_Status.ts +25 -0
  50. package/src/cli/cmd_Tui.ts +2 -2
  51. package/src/cli.ts +7 -4
  52. package/src/collections/actions.ts +4 -4
  53. package/src/collections/checks.ts +257 -0
  54. package/src/collections/collection-types.ts +254 -1
  55. package/src/collections/comfy.ts +23 -0
  56. package/src/collections/md-body.ts +14 -0
  57. package/src/collections/pings.ts +38 -12
  58. package/src/collections/posts.ts +19 -3
  59. package/src/collections/providers.ts +198 -91
  60. package/src/collections/reflections.ts +41 -0
  61. package/src/collections/state-rules.ts +73 -0
  62. package/src/collections/test-time.ts +288 -0
  63. package/src/collections/tests.ts +181 -0
  64. package/src/collections/wikis.ts +66 -0
  65. package/src/comfy/comfy-panel.ts +292 -0
  66. package/src/contracts/apply-contracts.ts +17 -12
  67. package/src/contracts/contract-types.ts +16 -4
  68. package/src/contracts/contracts-engine.ts +78 -42
  69. package/src/contracts/manifests/discord.contract.ts +6 -6
  70. package/src/contracts/manifests/identity.contract.ts +3 -3
  71. package/src/contracts/manifests/itch.contract.ts +12 -15
  72. package/src/contracts/manifests/social.contract.ts +5 -5
  73. package/src/contracts/manifests/steam.contract.ts +16 -16
  74. package/src/contracts/run-contracts.ts +4 -3
  75. package/src/core/attach.ts +96 -0
  76. package/src/core/canonical-scripts.ts +25 -0
  77. package/src/core/check-ctx.ts +204 -14
  78. package/src/core/check-file-names.ts +112 -0
  79. package/src/core/check-pass.ts +84 -0
  80. package/src/core/check-runner.ts +796 -0
  81. package/src/core/config-keys.ts +256 -0
  82. package/src/core/config-rewrite.ts +77 -0
  83. package/src/core/discover-checks.ts +166 -90
  84. package/src/core/discover-legacy-checks.ts +26 -0
  85. package/src/core/doc-block.ts +13 -0
  86. package/src/core/floor-resolve.ts +264 -0
  87. package/src/core/floor.ts +20 -137
  88. package/src/core/git-bin.ts +24 -0
  89. package/src/core/git-root.ts +108 -0
  90. package/src/core/instance.ts +195 -0
  91. package/src/core/jobs.ts +118 -0
  92. package/src/core/layout.ts +52 -0
  93. package/src/core/legacy-shapes.ts +207 -0
  94. package/src/core/lib-root.ts +5 -0
  95. package/src/core/locked-write.ts +110 -39
  96. package/src/core/machine-commit.ts +5 -4
  97. package/src/core/migrate-layout.ts +616 -0
  98. package/src/core/motherhome.ts +2 -8
  99. package/src/core/nesting.ts +4 -12
  100. package/src/core/one-way-in-policy.ts +73 -17
  101. package/src/core/outcome-words.ts +45 -0
  102. package/src/core/package-manager.ts +88 -0
  103. package/src/core/parallel.ts +21 -0
  104. package/src/core/process-tree.ts +44 -0
  105. package/src/core/raw-access.ts +93 -0
  106. package/src/core/read-set.ts +152 -0
  107. package/src/core/run-check.ts +155 -213
  108. package/src/core/run-ci.ts +152 -189
  109. package/src/core/run-fix.ts +99 -0
  110. package/src/core/run-fixers.ts +64 -33
  111. package/src/core/self-update.ts +11 -3
  112. package/src/core/slots.ts +83 -0
  113. package/src/core/spawn-timeout.ts +34 -3
  114. package/src/core/when-block.ts +401 -0
  115. package/src/core/when.ts +135 -0
  116. package/src/core/write-status.ts +5 -2
  117. package/src/fleet/scan-fleet.ts +16 -0
  118. package/src/hooks/leak-guard.ts +20 -9
  119. package/src/hooks/one-way-in-guard.ts +13 -5
  120. package/src/hooks/root-identity.ts +2 -1
  121. package/src/hooks/run-hooks.ts +38 -26
  122. package/src/init-repo.ts +39 -23
  123. package/src/ledger/check-stats.ts +155 -0
  124. package/src/ledger/check-table.ts +179 -0
  125. package/src/ledger/db.ts +255 -0
  126. package/src/ledger/gate-checks.ts +433 -0
  127. package/src/ledger/git-key.ts +116 -77
  128. package/src/ledger/git-scan.ts +96 -35
  129. package/src/ledger/ledger.ts +116 -42
  130. package/src/ledger/live-runs.ts +111 -0
  131. package/src/ledger/live-timeline.ts +97 -0
  132. package/src/ledger/repo-view.ts +73 -0
  133. package/src/ledger/run-log.ts +448 -0
  134. package/src/ledger/run-timeline.ts +173 -0
  135. package/src/ledger/store.ts +23 -12
  136. package/src/ledger/suite-log.ts +161 -0
  137. package/src/ledger/toolchain-identity.ts +7 -1
  138. package/src/ledger/world-cache.ts +111 -0
  139. package/src/mv/mv-md.ts +16 -6
  140. package/src/problems/extract.ts +182 -0
  141. package/src/problems/problem-board.ts +37 -0
  142. package/src/problems/problem-line.ts +77 -0
  143. package/src/prose/prose-view.ts +90 -0
  144. package/src/prose/run-prose.ts +60 -19
  145. package/src/prose/ste.ts +140 -24
  146. package/src/reflections/reflections-queue.ts +81 -0
  147. package/src/release/git-facts.ts +15 -9
  148. package/src/release/run-bump.ts +6 -5
  149. package/src/release/run-release.ts +25 -11
  150. package/src/release/run-releases.ts +2 -2
  151. package/src/release/unit-gate.ts +7 -54
  152. package/src/release/unit-resolve.ts +282 -0
  153. package/src/release/units.ts +33 -217
  154. package/src/repo-config.ts +157 -88
  155. package/src/rules/rule.ts +36 -96
  156. package/src/rules/untrack-generated.ts +5 -4
  157. package/src/scaffold/files/vscode-tasks.json +10 -31
  158. package/src/scaffold/jsonc-spans.ts +113 -0
  159. package/src/scaffold/project-scaffold.ts +16 -43
  160. package/src/scan.ts +98 -0
  161. package/src/serve/gate-trigger.ts +148 -0
  162. package/src/serve/health-schedule.ts +102 -0
  163. package/src/serve/hub-app/chart-view.ts +179 -0
  164. package/src/serve/hub-app/check-view.tsx +345 -0
  165. package/src/serve/hub-app/check-words.ts +164 -0
  166. package/src/serve/hub-app/checks-svc.ts +280 -0
  167. package/src/serve/hub-app/clock-svc.ts +33 -0
  168. package/src/serve/hub-app/col-card.tsx +23 -39
  169. package/src/serve/hub-app/cols-svc.ts +44 -20
  170. package/src/serve/hub-app/comfy-svc.ts +171 -0
  171. package/src/serve/hub-app/comfy-view.tsx +81 -0
  172. package/src/serve/hub-app/dur.ts +41 -0
  173. package/src/serve/hub-app/hash-route.ts +121 -0
  174. package/src/serve/hub-app/header-bar.tsx +117 -0
  175. package/src/serve/hub-app/health-svc.ts +23 -28
  176. package/src/serve/hub-app/help.tsx +29 -0
  177. package/src/serve/hub-app/hub-app.tsx +122 -119
  178. package/src/serve/hub-app/hub-st.ts +115 -42
  179. package/src/serve/hub-app/hub-types.ts +117 -2
  180. package/src/serve/hub-app/hub.css +534 -192
  181. package/src/serve/hub-app/index.html +2 -0
  182. package/src/serve/hub-app/live-svc.ts +174 -0
  183. package/src/serve/hub-app/md.ts +15 -0
  184. package/src/serve/hub-app/media-svc.ts +4 -1
  185. package/src/serve/hub-app/media-view.tsx +15 -0
  186. package/src/serve/hub-app/next-run.ts +68 -0
  187. package/src/serve/hub-app/project-gutter.css +65 -0
  188. package/src/serve/hub-app/project-gutter.tsx +128 -0
  189. package/src/serve/hub-app/projects-svc.ts +226 -0
  190. package/src/serve/hub-app/prose-hits-st.ts +124 -0
  191. package/src/serve/hub-app/prose-hits-view.tsx +176 -0
  192. package/src/serve/hub-app/prose-hits.css +21 -0
  193. package/src/serve/hub-app/run-strip.ts +47 -0
  194. package/src/serve/hub-app/runs-tabs.ts +24 -0
  195. package/src/serve/hub-app/test-view.tsx +107 -0
  196. package/src/serve/hub-app/tests-svc.ts +62 -0
  197. package/src/serve/hub-app/tests-view.tsx +238 -0
  198. package/src/serve/hub-app/time-ago.tsx +34 -0
  199. package/src/serve/hub-app/timeline-chart.tsx +274 -0
  200. package/src/serve/hub-app/timeline-geometry.ts +314 -0
  201. package/src/serve/hub-app/timeline-svc.ts +62 -0
  202. package/src/serve/hub-app/timeline-view.tsx +245 -0
  203. package/src/serve/hub-app/tree-build.ts +410 -0
  204. package/src/serve/hub-app/tree-row.tsx +42 -0
  205. package/src/serve/hub-app/tree-svc.ts +174 -0
  206. package/src/serve/hub-app/wikis-view.tsx +53 -0
  207. package/src/serve/other-repos.ts +79 -0
  208. package/src/serve/project-gutter-route.ts +350 -0
  209. package/src/serve/project-gutter.ts +449 -0
  210. package/src/serve/run-serve.ts +245 -16
  211. package/src/serve/same-origin.ts +14 -0
  212. package/src/serve/serve-hub.ts +213 -18
  213. package/src/shipkit.ts +19 -12
  214. package/src/smoke/run-smoke.ts +9 -4
  215. package/src/start-repo.ts +75 -36
  216. package/src/suite/judge-suites.ts +209 -15
  217. package/src/suite/kept-timings.ts +58 -0
  218. package/src/suite/runner-shapes.ts +88 -0
  219. package/src/suite/suite-cli.ts +147 -24
  220. package/src/suite/suite-cpu-preload.ts +92 -0
  221. package/src/suite/test-units.ts +243 -0
  222. package/src/ts/config.ts +4 -69
  223. package/src/ts/jsonc.ts +69 -0
  224. package/src/ts/lex.ts +143 -0
  225. package/src/ts/rename.ts +73 -10
  226. package/src/ts/source-graph.ts +28 -8
  227. package/src/ts/tsconfig-projects.ts +6 -97
  228. package/src/ts/tsconfig-resolve.ts +108 -0
  229. package/src/tui/build-steps-panel.ts +61 -0
  230. package/src/tui/checks-panel.ts +24 -16
  231. package/src/tui/run-tui.ts +2 -2
  232. package/src/tui/tui-panel.ts +5 -4
  233. package/src/wiki/graph-colors.ts +68 -0
  234. package/src/wiki/graph-mount.ts +7 -9
  235. package/src/wiki/graph-page.ts +42 -25
  236. package/src/wiki/graph-positions.ts +105 -0
  237. package/src/wiki/run-wiki.ts +1 -1
  238. package/src/wiki/scan-md.ts +1 -1
  239. package/src/wiki/serve-graph.ts +33 -3
  240. package/src/cli/cmd_Rules.ts +0 -51
  241. package/src/cli/cmd_Units.ts +0 -82
  242. package/src/collections/graphs.ts +0 -32
  243. package/src/contracts/contracts-once.ts +0 -43
  244. package/src/core/check-suite.ts +0 -302
  245. package/src/core/repair-contract-dirs.ts +0 -136
  246. package/src/core/task-runner.ts +0 -339
  247. package/src/ledger/gate-units.ts +0 -138
  248. package/src/ledger/unit-table.ts +0 -105
  249. package/src/rules/SK002-contracts-fulfillness.ts +0 -24
  250. package/src/rules/SK003-claude-md-shape.ts +0 -70
  251. package/src/rules/SK004-gitignore-policy.ts +0 -238
  252. package/src/rules/SK010-undeclared-release-unit.ts +0 -127
  253. package/src/rules/rule-registry.ts +0 -64
  254. package/src/rules/run-fix.ts +0 -72
  255. package/src/tui/tasks-panel.ts +0 -60
@@ -1,22 +1,24 @@
1
- // the ci gate — floor + contracts + repo tasks through the DAG runner, THEN
2
- // every discovered check (core/run-check.ts owns severity). Plain module
3
- // behind cmd_Ci (logic stays out of command classes) so tests can run the
4
- // gate in-process. Returns the exit code; json prints the structured summary.
5
- // Every run refreshes STATUS.md (write-status.ts — atomic + lock) unless
6
- // status:false, and the fix pass plus some checks WRITE, so a run is not
7
- // read-only unless frozen is set and --no-fix is passed.
8
- import { join } from 'path'
9
- import { allTasks, loadRepoConfig, type RepoConfig } from '../repo-config.ts'
1
+ // THE GATE — `shipkit ci`: the mechanical fix pass, then every check the gate
2
+ // asks for through the one runner (core/check-runner.ts, core/run-check.ts holds
3
+ // the shared body). Plain module behind cmd_Ci (logic stays out of command
4
+ // classes) so tests can run the gate in-process. Returns the exit code; json
5
+ // prints the structured summary. Every run refreshes STATUS.md
6
+ // (write-status.ts — atomic + lock) unless status:false, and the fix pass plus
7
+ // some checks WRITE, so a run is not read-only unless frozen is set and
8
+ // --no-fix is passed.
9
+ import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
10
10
  import { reportToMotherhome } from './motherhome.ts'
11
- import { checkSideIds, collectChecks } from './run-check.ts'
11
+ import { runCheckPass, type CheckOpts } from './run-check.ts'
12
+ import { poolLines } from './slots.ts'
12
13
  import { runFixPass } from './run-fixers.ts'
13
14
  import { formatLine } from './wire.ts'
14
- import { runTasks, selectTasks, type TaskResult } from './task-runner.ts'
15
15
  import { writeStatus } from './write-status.ts'
16
- import { withContractsScope } from '../contracts/contracts-once.ts'
17
- import { envTier, FULL_TESTS_ENV, gateInventory, gateUnits, tieredTasks, type TestTier } from '../ledger/gate-units.ts'
18
- import { UNIT_TABLE_FILE, writeUnitTable } from '../ledger/unit-table.ts'
19
- import { Ledger, stampReplayed, type UnitOutcome } from '../ledger/ledger.ts'
16
+ import { allTasks, composeChecks, envTier, FULL_TESTS_ENV, type TestTier } from '../ledger/gate-checks.ts'
17
+ import { CHECK_TABLE_FILE, writeCheckTable } from '../ledger/check-table.ts'
18
+ import { Ledger } from '../ledger/ledger.ts'
19
+ import { RunLog, type InvokedBy } from '../ledger/run-log.ts'
20
+ import { gateBinary } from '../ledger/toolchain-identity.ts'
21
+ import type { CheckResult } from './check-runner.ts'
20
22
 
21
23
  export type CiOpts = {
22
24
  bail?: boolean
@@ -33,76 +35,81 @@ export type CiOpts = {
33
35
  fixRules?: boolean
34
36
  /** self-arm the git hooks in the fix pass (interactive ci entry only) */
35
37
  armHooks?: boolean
36
- /** run only the checks that can block (core/run-check onlyBlocking) */
38
+ /** run only the checks that can block (core/run-checks onlyBlocking) */
37
39
  onlyBlockingChecks?: boolean
38
- /** task ids the caller does not need. A sub-unit release skips the tasks
39
- * that gate the whole repo, since the unit's own gate already ran. */
40
+ /** check ids the caller does not need. A sub-unit release skips the steps
41
+ * that gate the whole repo, since the check's own gate already ran. */
40
42
  skipTaskIds?: string[]
41
- /** report-only for self-maintaining rules and checks (SHIPKIT_FROZEN
42
- * travels to child checks). The release path passes it: a rule that
43
- * rewrites package.json between the gate and `npm publish` packs a tree
44
- * that exists in no commit. */
43
+ /** report-only for self-maintaining checks (SHIPKIT_FROZEN travels to
44
+ * children). The release path passes it: a check that rewrites
45
+ * package.json between the gate and `npm publish` packs a tree that
46
+ * exists in no commit. */
45
47
  frozen?: boolean
46
- /** treat a NON-blocking check failure as fatal too. `release.requireHealthy`
47
- * is the only caller: a repo that wants a missing cred to stop a release
48
+ /** treat a NON-blocking failure as fatal too. `release.requireHealthy` is
49
+ * the only caller: a repo that wants a missing cred to stop a release
48
50
  * asks for it, and every other run keeps the declared severity. */
49
51
  failOnAdvisory?: boolean
50
- /** run the tasks only. The TUI owns this: it renders the checks in its own
51
- * panel, and running the suite twice per refresh proves nothing twice. */
52
- skipChecks?: boolean
52
+ /** the BUILD steps alone. The TUI owns this: it renders the rest in its
53
+ * own panel, and running them twice per refresh proves the same thing
54
+ * twice. */
55
+ buildOnly?: boolean
53
56
  /** line sink — commands pass their context stream; defaults to console */
54
57
  print?: (line: string) => void
55
- /** per-task completion tap — the TUI tasks panel streams results in */
56
- onTaskFinish?: (r: TaskResult) => void
58
+ /** per-CHECK completion tap, handed straight to the runner. The hub passes
59
+ * it, so one gate run colours every row as it lands. */
60
+ onCheck?: CheckOpts['onCheck']
57
61
  /** the already-resolved config. A caller that holds one MUST pass it: two
58
62
  * loads of repo.config.ts can disagree, and the release path classifies
59
- * rule-vs-probe severity from the config it holds. */
63
+ * severity from the config it holds. */
60
64
  config?: RepoConfig
61
- /** re-measure everything: no unit replays from the ledger, and what this
62
- * run measures is stored fresh. Travels to child checks as SHIPKIT_FRESH. */
65
+ /** run everything: no answer comes from the cache, and what this
66
+ * run finds is stored fresh. Travels to children as SHIPKIT_FRESH. */
63
67
  fresh?: boolean
68
+ /** the gate binary this run is keyed on (default: the one running it).
69
+ * Toolchain identity content-hashes the directory the entry lives in, so
70
+ * a caller whose answers must survive an edit to that tree hands in a
71
+ * stable one instead of betting on nobody writing to it mid-run. */
72
+ cliEntry?: string
64
73
  /** read the ledger at all. The release path passes false, unconditionally
65
74
  * and not configurably: every remaining hole in the key is then capped at
66
75
  * "an agent saw a stale local green", never "a stale green authorised a
67
76
  * publish" (ledger/ledger.ts rule 4). */
68
77
  ledgerRead?: boolean
69
78
  /** which test tier the run asks the repo's suite for. 'quick' is the
70
- * working default; 'full' sets SHIPKIT_TEST_FULL=1 for every task, which
71
- * a suite reads to include the slow, real-world tiers it otherwise skips.
72
- * The release path always asks for 'full': a version published behind a
73
- * partial suite is a green nobody measured. Default reads the env var, so
74
- * `SHIPKIT_TEST_FULL=1 shipkit ci` works with no flag. */
79
+ * working default; 'full' sets SHIPKIT_TEST_FULL=1 for every build step,
80
+ * which a suite reads to include the slow tiers it otherwise skips. The
81
+ * release path always asks for 'full': a version published behind a
82
+ * partial suite is a green nobody measured. */
75
83
  tests?: TestTier
84
+ /** who started this run, as the run record files it */
85
+ invokedBy?: InvokedBy
76
86
  }
77
87
 
78
- export type { TestTier } from '../ledger/gate-units.ts'
79
- export { FULL_TESTS_ENV } from '../ledger/gate-units.ts'
88
+ export type { TestTier } from '../ledger/gate-checks.ts'
89
+ export { FULL_TESTS_ENV } from '../ledger/gate-checks.ts'
80
90
 
81
- /** what STATUS.md may claim about this run. `--with` ADDS a task, so it stays
82
- * the full gate; `--only`, `--skip` and skipChecks each remove coverage, and
91
+ /** what STATUS.md may claim about this run. `--with` ADDS a check, so it stays
92
+ * the full gate; `--only`, `--skip` and buildOnly each remove coverage, and
83
93
  * a narrowed run that stamps the full gate's verdict is a green nobody
84
94
  * measured. The label names the invocation either way. */
85
95
  function ciScope(o: CiOpts): { command: string; fullGate: boolean } {
86
96
  const narrowing = [
87
97
  ...(o.onlyIds ?? []).map((i) => `--only ${i}`),
88
98
  ...(o.skipGroups ?? []).map((g) => `--skip ${g}`),
89
- ...(o.skipChecks === true ? ['(tasks only)'] : []),
99
+ ...(o.buildOnly === true ? ['(build steps only)'] : []),
90
100
  ]
91
- const parts = [...narrowing, ...(o.withIds ?? []).map((i) => `--with ${i}`)]
101
+ // the tier is part of what ran: a full tier gate runs every slow suite, one
102
+ // at a time, and a label without it reads as an everyday run
103
+ const tier = (o.tests ?? envTier()) === 'full' ? ['--full'] : []
104
+ const parts = [...narrowing, ...(o.withIds ?? []).map((i) => `--with ${i}`), ...tier]
92
105
  return { command: parts.length === 0 ? 'shipkit ci' : `shipkit ci ${parts.join(' ')}`, fullGate: narrowing.length === 0 }
93
106
  }
94
107
 
95
108
  export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
96
- // ONE gate run: the `contracts` task evaluates, rule SK002 reads what it
97
- // computed instead of evaluating a second time (contracts-once.ts)
98
- return await withContractsScope(() => runCiInScope(root, o))
99
- }
100
-
101
- async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
102
109
  const print = o.print ?? ((l: string) => console.log(l))
103
110
  const collected: string[] = []
104
111
  const sink = (l: string): void => { collected.push(l); print(l) }
105
- const c = o.config ?? (await loadRepoConfig(root))
112
+ let c = o.config ?? (await loadRepoConfig(root))
106
113
  // mechanical failures are not information, so they never cost a round trip
107
114
  let fixFailed = false
108
115
  // a NARROWED run repairs only the mechanical slots. The structural rule
@@ -110,185 +117,142 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
110
117
  // that is the right price for the full gate and the wrong one for
111
118
  // `bun run test`, which routes here and must answer the one question its
112
119
  // name promises without leaving files in the agent's diff that it never
113
- // wrote. Under CI it is worse than surprising, a formatting rewrite comes
120
+ // wrote. Under CI it is worse than surprising: a formatting rewrite comes
114
121
  // back as a test failure.
115
122
  const narrowed = (o.onlyIds ?? []).length > 0
116
- if (o.fix !== false)
117
- fixFailed = (await runFixPass(root, c, { rules: !narrowed && o.fixRules !== false, armHooks: !narrowed && o.armHooks === true, print: sink })).failed
118
- else sink('skip: fix pass (--no-fix)')
119
- const all = allTasks(c, root)
120
- const { disabled, unfilled } = all
121
- const skipIds = new Set(o.skipTaskIds ?? [])
123
+ if (o.fix !== false) {
124
+ const pass = await runFixPass(root, c, { rules: !narrowed && o.fixRules !== false, armHooks: !narrowed && o.armHooks === true, print: sink })
125
+ fixFailed = pass.failed
126
+ // a fixer that rewrote the config or moved a check file leaves the
127
+ // config above describing a repo that is gone, so the gate reads the
128
+ // one on disk. A config that no longer loads is the gate's answer
129
+ if (pass.reshaped) {
130
+ try { c = await loadRepoConfig(root, { fresh: true }) } catch (e) {
131
+ sink(`FAIL: repo.config.ts no longer loads after the fix pass — ${String(e).slice(0, 200)}`)
132
+ return 1
133
+ }
134
+ }
135
+ } else sink('skip: fix pass (--no-fix)')
122
136
  const tier: TestTier = o.tests ?? envTier()
123
- const tasks = all.tasks
124
- .filter((t) => !skipIds.has(t.id))
125
- // the whole run carries the flag rather than one task id: a repo names
126
- // its suites what it likes (`test`, `test:libs`, a prepublish script),
127
- // and a gate that guesses which one is the tests will miss one
128
- .map((t) => (tier === 'full' ? { ...t, env: { ...t.env, [FULL_TESTS_ENV]: '1' } } : t))
129
137
  // announced BEFORE the run, so a log that died mid-way still says which
130
138
  // tier it was asking for
131
139
  sink(tier === 'full'
132
140
  ? `ok: test tier FULL — every suite, the slow ones included (${FULL_TESTS_ENV}=1)`
133
141
  : `skip: full test tier (quick run — \`shipkit ci --full\` or ${FULL_TESTS_ENV}=1 includes it)`)
134
- for (const t of all.tasks) if (skipIds.has(t.id)) sink(`skip: ${t.id} (not part of this release, the unit's own gate covers it)`)
135
- for (const d of disabled) sink(`skip: ${d} (skipped-by-config)`)
136
- for (const n of all.notes ?? []) sink(n)
142
+ for (const l of poolLines()) sink(l)
143
+ // a floor slot nothing could fill is VISIBLE — a thin gate must never look
144
+ // like a full one (the fresh-repo floor gap)
145
+ const floor = await allTasks(c, root)
146
+ for (const n of floor.notes ?? []) sink(n)
147
+ for (const u of floor.unfilled) {
148
+ sink(`skip: ${u.id} (${u.reason})`)
149
+ // asked for BY NAME and unfillable: this is where an agent lands after
150
+ // the harness refused it the raw runner, so the line says what to add
151
+ // rather than leaving it with `unknown check id` and no next move
152
+ if ((o.onlyIds ?? []).includes(u.id))
153
+ sink(` fix: add a '${u.id}:raw' script holding the tool, then \`shipkit ci --only ${u.id}\` runs it (meta/rv-conventions/package-json.md)`)
154
+ }
155
+ // the run record: one run id for every check of this gate, so a breakdown
156
+ // of the wall time is reconstructable. It is never read here, only written
157
+ const scope = ciScope(o)
158
+ const runLog = RunLog.open(root, { invocation: { kind: 'ci', label: scope.command, by: o.invokedBy ?? 'cli', narrowed: !scope.fullGate } })
137
159
  // THE LEDGER, opened once per run and AFTER the fix pass: the fixers
138
- // rewrite source, and a scan taken before them would have every unit
160
+ // rewrite source, and a scan taken before them would have every check
139
161
  // discarded by its own gate's repair work.
140
162
  //
141
163
  // Registration is validated on the way in, and it is loud in the gate
142
- // rather than only in `shipkit keys`, because a unit declaring two
164
+ // rather than only in `shipkit keys`, because a check declaring two
143
165
  // contradictory populations is a bug in what binds every repo. The
144
166
  // burn-down is one line, never a wall.
145
167
  let registrationFailed = false
146
168
  let ledger: Ledger | null = null
169
+ // ONE walk for the whole gate: the ledger is keyed on it and the run
170
+ // measures it, and two walks can disagree about what exists
171
+ const composed = await composeChecks(root, c, { tier })
147
172
  try {
148
- ledger = Ledger.open(root, gateUnits(root, c, tasks), {
149
- cliEntry: new URL('../cli.ts', import.meta.url).pathname,
173
+ ledger = Ledger.open(root, composed.map((g) => g.row.decl), {
174
+ cliEntry: o.cliEntry ?? gateBinary(),
150
175
  read: o.ledgerRead,
151
176
  fresh: o.fresh,
152
177
  })
153
178
  const reg = ledger.layer.registration
154
179
  for (const e of reg.errors) { registrationFailed = true; sink(`FAIL: ${e}`) }
155
- if (registrationFailed) sink(' fix: a unit declares `inputs` (content), or `ttl` (world), or `never: <reason>`, and never two of them')
180
+ if (registrationFailed) sink(' fix: a check declares `inputs` (content), or `ttl` (world), or `never: <reason>`, and never two of them')
156
181
  // both counts, always. Printing only the burn-down made `never:` the
157
182
  // cheap way to shrink the visible number, and that field carries no
158
183
  // verification at all: a growing decided population has to be as
159
184
  // visible as a shrinking undeclared one.
160
- const decided = reg.units - reg.burnDown.length - reg.declared
185
+ const decided = reg.checks - reg.burnDown.length - reg.declared
161
186
  if (reg.burnDown.length > 0 || decided > 0)
162
- sink(`skip: cache declarations — ${reg.declared}/${reg.units} declared, ${decided} decided uncacheable, ${reg.burnDown.length} undeclared, so they are measured every run (shipkit keys --burn-down)`)
187
+ sink(`skip: cache declarations — ${reg.declared}/${reg.checks} declared, ${decided} never cached on purpose, ${reg.burnDown.length} undeclared, so those run every time (shipkit keys --burn-down)`)
163
188
  if (ledger.layer.failure != null)
164
- sink(`WARN the git scan failed, so NOTHING replays and nothing is stored this run — ${ledger.layer.failure}`)
189
+ sink(`WARN the git scan failed, so NOTHING comes from the cache and nothing is stored this run — ${ledger.layer.failure}`)
165
190
  } catch (e) {
166
191
  ledger = null
167
- sink(`WARN ledger unavailable (${String(e).slice(0, 160)}) — every unit is measured this run, so the gate is correct and slower`)
192
+ sink(`WARN cache unavailable (${String(e).slice(0, 160)}) — every check runs this time, so the gate is correct and slower`)
168
193
  }
169
- // a floor slot nothing could fill is VISIBLE — a thin gate must never
170
- // look like a full one (the fresh-repo floor gap)
171
- for (const u of unfilled) {
172
- sink(`skip: ${u.id} (${u.reason})`)
173
- // asked for BY NAME and unfillable: this is where an agent lands after
174
- // the harness refused it the raw runner, so the line says what to add
175
- // rather than leaving it with `unknown task id` and no next move
176
- if ((o.onlyIds ?? []).includes(u.id))
177
- sink(` fix: add a '${u.id}:raw' script holding the tool, then \`shipkit ci --only ${u.id}\` runs it (meta/rv-conventions/package-json.md)`)
178
- }
179
- // REPLAY, before the DAG sees the list. A replayed task is removed from
180
- // the run entirely, which is also what makes its dependents correct: the
181
- // runner treats a filtered dep as satisfied, and only a GREEN is ever
182
- // stored, so a dependent can never start on the back of a red nobody saw.
183
- // --only addresses tasks, rules and checks alike, so the task runner is
184
- // told which ids belong to the other two surfaces rather than answering
185
- // `unknown task id` for a check it was never asked to run
186
- const otherIds = checkSideIds(root, c)
187
- const selectable = new Set(selectTasks({ tasks, logDir: '', skipGroups: o.skipGroups ?? [], withIds: o.withIds ?? [], onlyIds: o.onlyIds ?? [], otherIds }).selected.map((t) => t.id))
188
- const replayedTasks = new Map<string, UnitOutcome>()
189
- const toRun = tasks
190
- .filter((t) => {
191
- if (ledger == null || !selectable.has(t.id)) return true
192
- const hit = ledger.replay(t.id)
193
- if (hit == null) return true
194
- replayedTasks.set(t.id, hit)
195
- for (const l of stampReplayed(hit.lines, hit.measuredMs)) sink(formatLine(l, false))
196
- return false
197
- })
198
- // a dep ON a replayed task is DROPPED, not left dangling. The runner
199
- // validates every dep against the list it was handed and dies with
200
- // `unknown dep` otherwise, and dropping is the honest edge here: only a
201
- // GREEN is ever replayed, so the dependent's precondition is met and
202
- // this is the same "a filtered dep counts as satisfied" the runner
203
- // already applies to --skip and opt-in tasks.
204
- .map((t) => (t.deps?.some((d) => replayedTasks.has(d)) === true ? { ...t, deps: t.deps.filter((d) => !replayedTasks.has(d)) } : t))
205
- // a NAMED task that replayed leaves the list, so its name has to leave the
206
- // filters with it: the runner validates `--only`/`--with` against the list
207
- // it was handed and answers `unknown task id`. `bun run typecheck` and
208
- // `bun run test` route through `--only`, so this is the reflex verb every
209
- // agent in every wired repo types, going red on a warm store.
210
- const onlyIds = (o.onlyIds ?? []).filter((id) => !replayedTasks.has(id))
211
- const withIds = (o.withIds ?? []).filter((id) => !replayedTasks.has(id))
212
- // narrowed to nothing is NOT the same as "no filter": an empty --only would
213
- // widen the run to every task
214
- const nothingToRun = replayedTasks.size > 0 && (toRun.length === 0 || ((o.onlyIds ?? []).length > 0 && onlyIds.length === 0))
215
- // every asked-for task replayed: the DAG has nothing to schedule, and
216
- // running it anyway would print `all 0 tasks green`, which reads as a gate
217
- // that measured nothing rather than one that already knew the answer
218
- const summary = nothingToRun
219
- ? { ok: true, results: [], interrupted: false }
220
- : await runTasks({
221
- tasks: toRun,
222
- logDir: c.logDir ?? join(root, 'tmp/shipkit-ci'),
223
- cwd: root,
224
- bail: o.bail ?? false,
225
- skipGroups: o.skipGroups ?? [],
226
- withIds,
227
- onlyIds,
228
- otherIds,
194
+ // THE RUN. One engine, one DAG, one pass over everything this gate asks
195
+ // for: `gate = false` opts a check out of the everyday gate, and
196
+ // failOnAdvisory (release.requireHealthy) re-arms the world probes,
197
+ // because a repo asking for exactly those must not have them silently
198
+ // dropped by the opt-out.
199
+ const run = await runCheckPass(root, {
200
+ status: false,
201
+ config: c,
202
+ composed,
203
+ audience: 'gate',
204
+ includeProactive: o.failOnAdvisory === true,
205
+ frozen: o.frozen,
206
+ onlyBlocking: o.onlyBlockingChecks,
207
+ only: o.onlyIds,
208
+ withIds: o.withIds,
209
+ skipGroups: o.skipGroups,
210
+ exclude: o.skipTaskIds,
211
+ buildOnly: o.buildOnly,
212
+ bail: o.bail,
213
+ fresh: o.fresh,
214
+ tier,
215
+ ledger,
216
+ runLog,
217
+ onCheck: o.onCheck,
218
+ reportHome: false,
229
219
  print: sink,
230
- onTaskFinish: o.onTaskFinish,
231
220
  })
232
- if (nothingToRun) sink(`ok: all ${replayedTasks.size} task(s) asked for replayed, none measured`)
233
- // what the suite ACTUALLY ran, from its own summary line. A flag that was
234
- // set proves the intent; the count proves the tests
235
- for (const r of summary.results) {
236
- if (ledger == null || r.status !== 'pass') continue
237
- const surfaced = r.lines.filter((l) => l.kind === 'warn' || l.kind === 'fix-warn' || l.kind === 'skip')
238
- ledger.record(r.id, {
239
- status: surfaced.some((l) => l.kind === 'warn' || l.kind === 'fix-warn') ? 'warn' : 'pass',
240
- // the headline is REBUILT rather than captured: the runner renders
241
- // the wall time into it, and a duration baked into a stored line
242
- // would be replayed as if this run had measured it
243
- lines: [{ kind: 'ok', text: r.id }, ...surfaced],
244
- lastLine: r.lastLine,
245
- measuredMs: r.durationMs,
246
- })
247
- }
248
- const ranTests = [...summary.results.map((r) => r.lastLine ?? ''), ...[...replayedTasks.values()].map((o) => o.lastLine ?? '')]
249
- .map((l) => /Ran (\d+) tests?/.exec(l)?.[1])
250
- .find((n) => n != null)
251
- if (ranTests != null) sink(`ok: test tier ${tier}: ${ranTests} tests ran`)
252
- // the checks, in the SAME command. there is no second surface: a check
253
- // declares whether it blocks, and that is the only question about it.
254
- // a narrowed run says what it did NOT look at: a green that only ever
255
- // asked one question must not read like the full gate
256
- if ((o.onlyIds ?? []).length > 0 && o.skipChecks !== true) sink(`skip: checks not named by --only (${(o.onlyIds ?? []).join(', ')})`)
257
- // no release survey is pre-warmed here. It is ~3s of registry and `gh`
258
- // network, and the only thing that ever read it is the lib-drift probe,
259
- // which asks what is PUBLISHED and so has no business in a gate that asks
260
- // whether this change is good. collectChecks owns the share path, so the
261
- // checks that do want it still pay for exactly one survey between them.
262
- const checks = o.skipChecks === true
263
- ? { lines: [], blocked: false, advisoryFailures: [], undeclared: [] }
264
- // `gate = false` opts a check out of the EVERYDAY gate. failOnAdvisory
265
- // is release.requireHealthy, a repo saying it wants a missing cred to
266
- // stop its publish, so it re-arms them: the opt-out must not silently
267
- // drop the one request that asks for exactly these probes.
268
- : await collectChecks(root, { status: false, config: c, frozen: o.frozen, onlyBlocking: o.onlyBlockingChecks, only: o.onlyIds, fresh: o.fresh, ledger, reportHome: false, gateOnly: o.failOnAdvisory !== true })
269
- const color = process.stdout.isTTY && process.env.NO_COLOR == null
270
- for (const l of checks.lines) { collected.push(formatLine(l, false)); print(formatLine(l, color)) }
271
- for (const u of checks.undeclared) {
221
+ // what the suite ACTUALLY ran, from its own headline. A flag that was set
222
+ // proves the intent; the count proves the tests
223
+ // proves the intent; the count proves the tests. Every test check counts,
224
+ // one per unit
225
+ const counts = run.results.map((r) => /Ran (\d+) tests?/.exec(r.lastLine)?.[1]).filter((n) => n != null).map(Number)
226
+ if (counts.length > 0) sink(`ok: test tier ${tier}: ${counts.reduce((a, b) => a + b, 0)} tests ran${counts.length > 1 ? ` in ${counts.length} test checks` : ''}`)
227
+ for (const u of run.undeclared) {
272
228
  sink(`FAIL: ${u} does not declare whether it blocks`)
273
- sink(` fix: add \`export const blocking = true\` (about this repo) or \`false\` (about the world) to that file`)
229
+ sink(` fix: add \`blocking: true\` (about this repo) or \`false\` (about the world) to that check`)
230
+ }
231
+ // a switch wired to no check does nothing, silently, which is the one thing
232
+ // a toggle must never do
233
+ for (const id of run.unknownWhen) {
234
+ sink(`FAIL: when.${id} names no check of this repo`)
235
+ sink(` fix: \`shipkit checks\` lists every id; drop the entry or correct it`)
274
236
  }
275
237
  let advisoryFatal = false
276
- if (checks.advisoryFailures.length > 0) {
238
+ if (run.advisoryFailures.length > 0) {
277
239
  if (o.failOnAdvisory === true) {
278
240
  advisoryFatal = true
279
- sink(`FAIL: ${checks.advisoryFailures.length} non-blocking check(s) failing and release.requireHealthy is on: ${checks.advisoryFailures.join(', ')}`)
241
+ sink(`FAIL: ${run.advisoryFailures.length} non-blocking check(s) failing and release.requireHealthy is on: ${run.advisoryFailures.join(', ')}`)
280
242
  sink(' fix: shipkit check')
281
- } else sink(`WARN ${checks.advisoryFailures.length} non-blocking check(s) failing, about the world rather than this repo: ${checks.advisoryFailures.join(', ')}`)
243
+ } else sink(`WARN ${run.advisoryFailures.length} non-blocking check(s) failing, about the world rather than this repo: ${run.advisoryFailures.join(', ')}`)
282
244
  }
283
245
  // the post-run re-hash, then the store. NOTHING was written while the gate
284
- // ran: a unit whose inputs moved under it answered about a tree that no
246
+ // ran: a check whose inputs moved under it answered about a tree that no
285
247
  // longer exists, and this is where that result is thrown away instead of
286
248
  // stored under a key that describes the file as it was before the edit.
287
249
  if (ledger != null) for (const l of ledger.header(ledger.commit())) sink(formatLine(l, false))
288
- const code = summary.ok && !fixFailed && !registrationFailed && !checks.blocked && !advisoryFatal && checks.undeclared.length === 0 ? 0 : 1
250
+ // one write for the whole gate, after everything has finished
251
+ for (const l of runLog.flush()) sink(formatLine(l, false))
252
+ const code = run.blocked || fixFailed || registrationFailed || advisoryFatal || run.undeclared.length > 0 || run.unknownWhen.length > 0 ? 1 : 0
289
253
  // motherhome row (docsBase wiki on this disk) — never affects the gate,
290
- // and never fires under frozen: a bump/release gate must not re-dirty
291
- // the tree it just proved clean (field wart 2026-08-05, second head)
254
+ // and never fires under frozen: a bump/release gate must not re-dirty the
255
+ // tree it just proved clean
292
256
  if (o.frozen !== true) {
293
257
  const mh = reportToMotherhome(root, c)
294
258
  if (mh.note != null) print(mh.note)
@@ -301,14 +265,13 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
301
265
  ...ciScope(o),
302
266
  lines: collected,
303
267
  exitCode: code,
304
- sidecar: { file: UNIT_TABLE_FILE, says: 'Every unit this repo can run, and whether it blocks you' },
268
+ sidecar: { file: CHECK_TABLE_FILE, says: 'Every check this repo can run, and whether it blocks you' },
305
269
  })
306
270
  if (!st.written) print(`WARN ${st.warn}`)
307
- const units = writeUnitTable(root, { name: c.name, rows: gateInventory(root, c, tieredTasks(c, root, tier)) })
308
- if (!units.written) print(`WARN ${units.warn}`)
309
-
271
+ const checks = writeCheckTable(root, { name: c.name, rows: composed.map((g) => g.row) })
272
+ if (!checks.written) print(`WARN ${checks.warn}`)
310
273
  }
311
274
  if (o.json === true)
312
- print(JSON.stringify({ ok: summary.ok, interrupted: summary.interrupted, disabled, tasks: summary.results.map((r) => ({ ...r, lines: undefined })) }))
275
+ print(JSON.stringify({ ok: !run.blocked, interrupted: run.interrupted, disabled: floor.disabled, checks: run.results.map((r: CheckResult) => ({ ...r, lines: undefined })) }))
313
276
  return code
314
277
  }
@@ -0,0 +1,99 @@
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.
6
+ import { CheckCtx } from './check-ctx.ts'
7
+ import { formatLine } from './wire.ts'
8
+ import { whenOfRule, whenOfCheck, type WhenDecl } from './when.ts'
9
+ import { loadRepoConfig } from '../repo-config.ts'
10
+ import { discoverChecks } from './discover-checks.ts'
11
+ import { runCheckBody, type CheckDef, type CheckEnv } from '../check.ts'
12
+ import type { RepoConfig } from '../repo-config.ts'
13
+
14
+ export type FixOpts = {
15
+ ids?: string[]
16
+ /** drop the closing advisory: inside the ci fix pass it is noise, and it is
17
+ * not wire-shaped, which the frozen output contract forbids */
18
+ quiet?: boolean
19
+ print?: (line: string) => void
20
+ /** the config as it stands on disk now, when the caller holds one: a
21
+ * second load of a rewritten repo.config.ts can hit the module cache */
22
+ config?: RepoConfig
23
+ }
24
+
25
+ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
26
+ const print = o.print ?? ((l: string) => console.log(l))
27
+ const config = o.config ?? (await loadRepoConfig(root))
28
+ const env: CheckEnv = { root, config }
29
+ // a fresh context per body: a fixer may rewrite the config, and the body
30
+ // after it must read the one on disk now
31
+ const ctx = (c: RepoConfig): CheckCtx => CheckCtx.open({ root, config: c })
32
+ const color = process.stdout.isTTY && process.env.NO_COLOR == null
33
+ /** one fixable check, whatever declared it */
34
+ type Target = { id: string; title: string; owner: string; run: CheckDef['run']; fix?: CheckDef['fix']; when: WhenDecl }
35
+ // a rule the repo still registers fixes exactly like a check file, one
36
+ // code path. Only a check that declared a `fix` is a target at all
37
+ const all: Target[] = [
38
+ ...(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) })),
39
+ ...(config.rules ?? []).map((r): Target => ({
40
+ id: r.id, title: r.title, owner: r.owner, when: whenOfRule(config, r.id),
41
+ run: (c) => r.lint(c, { root: c.root, config: c.config }),
42
+ fix: r.fix == null ? undefined : (c) => r.fix!({ root: c.root, config: c.config }),
43
+ })),
44
+ ]
45
+
46
+ let targets = all.filter((r) => r.fix != null)
47
+ if (o.ids != null && o.ids.length > 0) {
48
+ for (const id of o.ids) {
49
+ const r = all.find((x) => x.id === id)
50
+ if (r == null) { print(`FAIL: unknown check '${id}' — see: shipkit checks`); return 1 }
51
+ if (r.fix == null) { print(`FAIL: [${id}] has no autofix — fix by hand: ${config.docsBase ?? ''}${r.owner}`); return 1 }
52
+ }
53
+ const wanted = new Set(o.ids)
54
+ targets = targets.filter((r) => wanted.has(r.id))
55
+ }
56
+
57
+ let exit = 0
58
+ for (const rule of targets) {
59
+ const before = ctx(env.config)
60
+ const off = rule.when
61
+ await runCheckBody(rule, before, off.at === 'off' ? { reason: off.reason } : undefined)
62
+ // a check that is OFF never autofixes: the repo broke that invariant on purpose
63
+ if (off.at === 'off') {
64
+ if (o.quiet !== true) print(`skip: [${rule.id}] ${rule.id} exempt: ${off.reason ?? ''}`)
65
+ continue
66
+ }
67
+ if (!before.failed && !before.warned) {
68
+ // inside the ci fix pass, a line per already-clean rule is a wall of
69
+ // nothing above the run that reports those same rules for real
70
+ if (o.quiet !== true) print(`ok: [${rule.id}] already clean — nothing to fix`)
71
+ continue
72
+ }
73
+ const f = await rule.fix!(ctx(env.config))
74
+ // an unchanged fix is the DESIGNED outcome for judgment findings, so
75
+ // it is an ok, not a warning. It carries a frozen wire prefix like
76
+ // every other line the fix pass emits: `shipkit ci` promises its whole
77
+ // output is parseable, and a bare line breaks that for every reader.
78
+ print(f.changed ? `ok: [${rule.id}] fixed: ${f.summary}` : `ok: [${rule.id}] fix changed nothing — ${f.summary}`)
79
+ // a fixer may rewrite repo.config.ts, and the re-run below and every
80
+ // fixer after it must read what is on disk now, never the config this
81
+ // pass loaded before the rewrite
82
+ if (f.changed) {
83
+ try { env.config = await loadRepoConfig(root, { fresh: true }) } catch (e) {
84
+ print(`FAIL: [${rule.id}] repo.config.ts no longer loads after the fix — ${String(e).slice(0, 200)}`)
85
+ return 1
86
+ }
87
+ }
88
+ const after = ctx(env.config)
89
+ await runCheckBody(rule, after)
90
+ // the fix already ran: advertising it again on surviving findings
91
+ // would send an agent in a loop
92
+ for (const l of after.lines) print(formatLine({ ...l, text: l.text.replace(/ · autofix: shipkit check --fix \S+$/, '') }, color))
93
+ if (after.failed) exit = 1
94
+ }
95
+ const unfixable = all.filter((r) => r.fix == null).length
96
+ if (unfixable > 0 && o.quiet !== true && (o.ids == null || o.ids.length === 0))
97
+ print(`${unfixable} check(s) have no autofix — they report in the run below`)
98
+ return exit
99
+ }