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
@@ -1,22 +1,25 @@
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.
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.
8
9
  import { join } from 'path'
9
- import { allTasks, loadRepoConfig, type RepoConfig } from '../repo-config.ts'
10
+ import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
10
11
  import { reportToMotherhome } from './motherhome.ts'
11
- import { checkSideIds, collectChecks } from './run-check.ts'
12
+ import { runCheckPass, type CheckOpts } from './run-check.ts'
13
+ import { poolLines } from './slots.ts'
12
14
  import { runFixPass } from './run-fixers.ts'
13
- import { formatLine } from './wire.ts'
14
- import { runTasks, selectTasks, type TaskResult } from './task-runner.ts'
15
+ import { formatLine, type Line } from './wire.ts'
15
16
  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'
17
+ import { allTasks, composeChecks, envTier, FULL_TESTS_ENV, gateInventory, type TestTier } from '../ledger/gate-checks.ts'
18
+ import { CHECK_TABLE_FILE, writeCheckTable } from '../ledger/check-table.ts'
19
+ import { Ledger } from '../ledger/ledger.ts'
20
+ import { RunLog, type InvokedBy } from '../ledger/run-log.ts'
21
+ import { gateBinary } from '../ledger/toolchain-identity.ts'
22
+ import type { CheckResult } from './check-runner.ts'
20
23
 
21
24
  export type CiOpts = {
22
25
  bail?: boolean
@@ -33,76 +36,81 @@ export type CiOpts = {
33
36
  fixRules?: boolean
34
37
  /** self-arm the git hooks in the fix pass (interactive ci entry only) */
35
38
  armHooks?: boolean
36
- /** run only the checks that can block (core/run-check onlyBlocking) */
39
+ /** run only the checks that can block (core/run-checks onlyBlocking) */
37
40
  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. */
41
+ /** check ids the caller does not need. A sub-unit release skips the steps
42
+ * that gate the whole repo, since the check's own gate already ran. */
40
43
  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. */
44
+ /** report-only for self-maintaining checks (SHIPKIT_FROZEN travels to
45
+ * children). The release path passes it: a check that rewrites
46
+ * package.json between the gate and `npm publish` packs a tree that
47
+ * exists in no commit. */
45
48
  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
49
+ /** treat a NON-blocking failure as fatal too. `release.requireHealthy` is
50
+ * the only caller: a repo that wants a missing cred to stop a release
48
51
  * asks for it, and every other run keeps the declared severity. */
49
52
  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
53
+ /** the BUILD steps alone. The TUI owns this: it renders the rest in its
54
+ * own panel, and running them twice per refresh proves the same thing
55
+ * twice. */
56
+ buildOnly?: boolean
53
57
  /** line sink — commands pass their context stream; defaults to console */
54
58
  print?: (line: string) => void
55
- /** per-task completion tap — the TUI tasks panel streams results in */
56
- onTaskFinish?: (r: TaskResult) => void
59
+ /** per-CHECK completion tap, handed straight to the runner. The hub passes
60
+ * it, so one gate run colours every row as it lands. */
61
+ onCheck?: CheckOpts['onCheck']
57
62
  /** the already-resolved config. A caller that holds one MUST pass it: two
58
63
  * loads of repo.config.ts can disagree, and the release path classifies
59
- * rule-vs-probe severity from the config it holds. */
64
+ * severity from the config it holds. */
60
65
  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. */
66
+ /** run everything: no answer comes from the cache, and what this
67
+ * run finds is stored fresh. Travels to children as SHIPKIT_FRESH. */
63
68
  fresh?: boolean
69
+ /** the gate binary this run is keyed on (default: the one running it).
70
+ * Toolchain identity content-hashes the directory the entry lives in, so
71
+ * a caller whose answers must survive an edit to that tree hands in a
72
+ * stable one instead of betting on nobody writing to it mid-run. */
73
+ cliEntry?: string
64
74
  /** read the ledger at all. The release path passes false, unconditionally
65
75
  * and not configurably: every remaining hole in the key is then capped at
66
76
  * "an agent saw a stale local green", never "a stale green authorised a
67
77
  * publish" (ledger/ledger.ts rule 4). */
68
78
  ledgerRead?: boolean
69
79
  /** 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. */
80
+ * working default; 'full' sets SHIPKIT_TEST_FULL=1 for every build step,
81
+ * which a suite reads to include the slow tiers it otherwise skips. The
82
+ * release path always asks for 'full': a version published behind a
83
+ * partial suite is a green nobody measured. */
75
84
  tests?: TestTier
85
+ /** who started this run, as the run record files it */
86
+ invokedBy?: InvokedBy
76
87
  }
77
88
 
78
- export type { TestTier } from '../ledger/gate-units.ts'
79
- export { FULL_TESTS_ENV } from '../ledger/gate-units.ts'
89
+ export type { TestTier } from '../ledger/gate-checks.ts'
90
+ export { FULL_TESTS_ENV } from '../ledger/gate-checks.ts'
80
91
 
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
92
+ /** what STATUS.md may claim about this run. `--with` ADDS a check, so it stays
93
+ * the full gate; `--only`, `--skip` and buildOnly each remove coverage, and
83
94
  * a narrowed run that stamps the full gate's verdict is a green nobody
84
95
  * measured. The label names the invocation either way. */
85
96
  function ciScope(o: CiOpts): { command: string; fullGate: boolean } {
86
97
  const narrowing = [
87
98
  ...(o.onlyIds ?? []).map((i) => `--only ${i}`),
88
99
  ...(o.skipGroups ?? []).map((g) => `--skip ${g}`),
89
- ...(o.skipChecks === true ? ['(tasks only)'] : []),
100
+ ...(o.buildOnly === true ? ['(build steps only)'] : []),
90
101
  ]
91
- const parts = [...narrowing, ...(o.withIds ?? []).map((i) => `--with ${i}`)]
102
+ // the tier is part of what ran: a full tier gate runs every slow suite, one
103
+ // at a time, and a label without it reads as an everyday run
104
+ const tier = (o.tests ?? envTier()) === 'full' ? ['--full'] : []
105
+ const parts = [...narrowing, ...(o.withIds ?? []).map((i) => `--with ${i}`), ...tier]
92
106
  return { command: parts.length === 0 ? 'shipkit ci' : `shipkit ci ${parts.join(' ')}`, fullGate: narrowing.length === 0 }
93
107
  }
94
108
 
95
109
  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
110
  const print = o.print ?? ((l: string) => console.log(l))
103
111
  const collected: string[] = []
104
112
  const sink = (l: string): void => { collected.push(l); print(l) }
105
- const c = o.config ?? (await loadRepoConfig(root))
113
+ let c = o.config ?? (await loadRepoConfig(root))
106
114
  // mechanical failures are not information, so they never cost a round trip
107
115
  let fixFailed = false
108
116
  // a NARROWED run repairs only the mechanical slots. The structural rule
@@ -110,185 +118,142 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
110
118
  // that is the right price for the full gate and the wrong one for
111
119
  // `bun run test`, which routes here and must answer the one question its
112
120
  // 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
121
+ // wrote. Under CI it is worse than surprising: a formatting rewrite comes
114
122
  // back as a test failure.
115
123
  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 ?? [])
124
+ if (o.fix !== false) {
125
+ const pass = await runFixPass(root, c, { rules: !narrowed && o.fixRules !== false, armHooks: !narrowed && o.armHooks === true, print: sink })
126
+ fixFailed = pass.failed
127
+ // a fixer that rewrote the config or moved a check file leaves the
128
+ // config above describing a repo that is gone, so the gate reads the
129
+ // one on disk. A config that no longer loads is the gate's answer
130
+ if (pass.reshaped) {
131
+ try { c = await loadRepoConfig(root, { fresh: true }) } catch (e) {
132
+ sink(`FAIL: repo.config.ts no longer loads after the fix pass — ${String(e).slice(0, 200)}`)
133
+ return 1
134
+ }
135
+ }
136
+ } else sink('skip: fix pass (--no-fix)')
122
137
  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
138
  // announced BEFORE the run, so a log that died mid-way still says which
130
139
  // tier it was asking for
131
140
  sink(tier === 'full'
132
141
  ? `ok: test tier FULL — every suite, the slow ones included (${FULL_TESTS_ENV}=1)`
133
142
  : `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)
143
+ for (const l of poolLines()) sink(l)
144
+ // a floor slot nothing could fill is VISIBLE — a thin gate must never look
145
+ // like a full one (the fresh-repo floor gap)
146
+ const floor = await allTasks(c, root)
147
+ for (const n of floor.notes ?? []) sink(n)
148
+ for (const u of floor.unfilled) {
149
+ sink(`skip: ${u.id} (${u.reason})`)
150
+ // asked for BY NAME and unfillable: this is where an agent lands after
151
+ // the harness refused it the raw runner, so the line says what to add
152
+ // rather than leaving it with `unknown check id` and no next move
153
+ if ((o.onlyIds ?? []).includes(u.id))
154
+ 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)`)
155
+ }
156
+ // the run record: one run id for every check of this gate, so a breakdown
157
+ // of the wall time is reconstructable. It is never read here, only written
158
+ const scope = ciScope(o)
159
+ const runLog = RunLog.open(root, { invocation: { kind: 'ci', label: scope.command, by: o.invokedBy ?? 'cli', narrowed: !scope.fullGate } })
137
160
  // 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
161
+ // rewrite source, and a scan taken before them would have every check
139
162
  // discarded by its own gate's repair work.
140
163
  //
141
164
  // 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
165
+ // rather than only in `shipkit keys`, because a check declaring two
143
166
  // contradictory populations is a bug in what binds every repo. The
144
167
  // burn-down is one line, never a wall.
145
168
  let registrationFailed = false
146
169
  let ledger: Ledger | null = null
170
+ // ONE walk for the whole gate: the ledger is keyed on it and the run
171
+ // measures it, and two walks can disagree about what exists
172
+ const composed = await composeChecks(root, c, { tier })
147
173
  try {
148
- ledger = Ledger.open(root, gateUnits(root, c, tasks), {
149
- cliEntry: new URL('../cli.ts', import.meta.url).pathname,
174
+ ledger = Ledger.open(root, composed.map((g) => g.row.decl), {
175
+ cliEntry: o.cliEntry ?? gateBinary(),
150
176
  read: o.ledgerRead,
151
177
  fresh: o.fresh,
152
178
  })
153
179
  const reg = ledger.layer.registration
154
180
  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')
181
+ if (registrationFailed) sink(' fix: a check declares `inputs` (content), or `ttl` (world), or `never: <reason>`, and never two of them')
156
182
  // both counts, always. Printing only the burn-down made `never:` the
157
183
  // cheap way to shrink the visible number, and that field carries no
158
184
  // verification at all: a growing decided population has to be as
159
185
  // visible as a shrinking undeclared one.
160
- const decided = reg.units - reg.burnDown.length - reg.declared
186
+ const decided = reg.checks - reg.burnDown.length - reg.declared
161
187
  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)`)
188
+ 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
189
  if (ledger.layer.failure != null)
164
- sink(`WARN the git scan failed, so NOTHING replays and nothing is stored this run — ${ledger.layer.failure}`)
190
+ sink(`WARN the git scan failed, so NOTHING comes from the cache and nothing is stored this run — ${ledger.layer.failure}`)
165
191
  } catch (e) {
166
192
  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`)
193
+ sink(`WARN cache unavailable (${String(e).slice(0, 160)}) — every check runs this time, so the gate is correct and slower`)
168
194
  }
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,
195
+ // THE RUN. One engine, one DAG, one pass over everything this gate asks
196
+ // for: `gate = false` opts a check out of the everyday gate, and
197
+ // failOnAdvisory (release.requireHealthy) re-arms the world probes,
198
+ // because a repo asking for exactly those must not have them silently
199
+ // dropped by the opt-out.
200
+ const run = await runCheckPass(root, {
201
+ status: false,
202
+ config: c,
203
+ composed,
204
+ audience: 'gate',
205
+ includeProactive: o.failOnAdvisory === true,
206
+ frozen: o.frozen,
207
+ onlyBlocking: o.onlyBlockingChecks,
208
+ only: o.onlyIds,
209
+ withIds: o.withIds,
210
+ skipGroups: o.skipGroups,
211
+ exclude: o.skipTaskIds,
212
+ buildOnly: o.buildOnly,
213
+ bail: o.bail,
214
+ fresh: o.fresh,
215
+ tier,
216
+ ledger,
217
+ runLog,
218
+ onCheck: o.onCheck,
219
+ reportHome: false,
229
220
  print: sink,
230
- onTaskFinish: o.onTaskFinish,
231
221
  })
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) {
222
+ // what the suite ACTUALLY ran, from its own headline. A flag that was set
223
+ // proves the intent; the count proves the tests
224
+ // proves the intent; the count proves the tests. Every test check counts,
225
+ // one per unit
226
+ const counts = run.results.map((r) => /Ran (\d+) tests?/.exec(r.lastLine)?.[1]).filter((n) => n != null).map(Number)
227
+ 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` : ''}`)
228
+ for (const u of run.undeclared) {
272
229
  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`)
230
+ sink(` fix: add \`blocking: true\` (about this repo) or \`false\` (about the world) to that check`)
231
+ }
232
+ // a switch wired to no check does nothing, silently, which is the one thing
233
+ // a toggle must never do
234
+ for (const id of run.unknownWhen) {
235
+ sink(`FAIL: when.${id} names no check of this repo`)
236
+ sink(` fix: \`shipkit checks\` lists every id; drop the entry or correct it`)
274
237
  }
275
238
  let advisoryFatal = false
276
- if (checks.advisoryFailures.length > 0) {
239
+ if (run.advisoryFailures.length > 0) {
277
240
  if (o.failOnAdvisory === true) {
278
241
  advisoryFatal = true
279
- sink(`FAIL: ${checks.advisoryFailures.length} non-blocking check(s) failing and release.requireHealthy is on: ${checks.advisoryFailures.join(', ')}`)
242
+ sink(`FAIL: ${run.advisoryFailures.length} non-blocking check(s) failing and release.requireHealthy is on: ${run.advisoryFailures.join(', ')}`)
280
243
  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(', ')}`)
244
+ } else sink(`WARN ${run.advisoryFailures.length} non-blocking check(s) failing, about the world rather than this repo: ${run.advisoryFailures.join(', ')}`)
282
245
  }
283
246
  // 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
247
+ // ran: a check whose inputs moved under it answered about a tree that no
285
248
  // longer exists, and this is where that result is thrown away instead of
286
249
  // stored under a key that describes the file as it was before the edit.
287
250
  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
251
+ // one write for the whole gate, after everything has finished
252
+ for (const l of runLog.flush()) sink(formatLine(l, false))
253
+ const code = run.blocked || fixFailed || registrationFailed || advisoryFatal || run.undeclared.length > 0 || run.unknownWhen.length > 0 ? 1 : 0
289
254
  // 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)
255
+ // and never fires under frozen: a bump/release gate must not re-dirty the
256
+ // tree it just proved clean
292
257
  if (o.frozen !== true) {
293
258
  const mh = reportToMotherhome(root, c)
294
259
  if (mh.note != null) print(mh.note)
@@ -301,14 +266,13 @@ async function runCiInScope(root: string, o: CiOpts = {}): Promise<number> {
301
266
  ...ciScope(o),
302
267
  lines: collected,
303
268
  exitCode: code,
304
- sidecar: { file: UNIT_TABLE_FILE, says: 'Every unit this repo can run, and whether it blocks you' },
269
+ sidecar: { file: CHECK_TABLE_FILE, says: 'Every check this repo can run, and whether it blocks you' },
305
270
  })
306
271
  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
-
272
+ const checks = writeCheckTable(root, { name: c.name, rows: composed.map((g) => g.row) })
273
+ if (!checks.written) print(`WARN ${checks.warn}`)
310
274
  }
311
275
  if (o.json === true)
312
- print(JSON.stringify({ ok: summary.ok, interrupted: summary.interrupted, disabled, tasks: summary.results.map((r) => ({ ...r, lines: undefined })) }))
276
+ print(JSON.stringify({ ok: !run.blocked, interrupted: run.interrupted, disabled: floor.disabled, checks: run.results.map((r: CheckResult) => ({ ...r, lines: undefined })) }))
313
277
  return code
314
278
  }
@@ -0,0 +1,96 @@
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
+ }
21
+
22
+ export async function runFix(root: string, o: FixOpts = {}): Promise<number> {
23
+ const print = o.print ?? ((l: string) => console.log(l))
24
+ const config = await loadRepoConfig(root)
25
+ const env: CheckEnv = { root, config }
26
+ // a fresh context per body: a fixer may rewrite the config, and the body
27
+ // after it must read the one on disk now
28
+ const ctx = (c: RepoConfig): CheckCtx => CheckCtx.open({ root, config: c })
29
+ const color = process.stdout.isTTY && process.env.NO_COLOR == null
30
+ /** one fixable check, whatever declared it */
31
+ type Target = { id: string; title: string; owner: string; run: CheckDef['run']; fix?: CheckDef['fix']; when: WhenDecl }
32
+ // a rule the repo still registers fixes exactly like a check file, one
33
+ // code path. Only a check that declared a `fix` is a target at all
34
+ const all: Target[] = [
35
+ ...(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) })),
36
+ ...(config.rules ?? []).map((r): Target => ({
37
+ id: r.id, title: r.title, owner: r.owner, when: whenOfRule(config, r.id),
38
+ run: (c) => r.lint(c, { root: c.root, config: c.config }),
39
+ fix: r.fix == null ? undefined : (c) => r.fix!({ root: c.root, config: c.config }),
40
+ })),
41
+ ]
42
+
43
+ let targets = all.filter((r) => r.fix != null)
44
+ if (o.ids != null && o.ids.length > 0) {
45
+ for (const id of o.ids) {
46
+ const r = all.find((x) => x.id === id)
47
+ if (r == null) { print(`FAIL: unknown check '${id}' — see: shipkit checks`); return 1 }
48
+ if (r.fix == null) { print(`FAIL: [${id}] has no autofix — fix by hand: ${config.docsBase ?? ''}${r.owner}`); return 1 }
49
+ }
50
+ const wanted = new Set(o.ids)
51
+ targets = targets.filter((r) => wanted.has(r.id))
52
+ }
53
+
54
+ let exit = 0
55
+ for (const rule of targets) {
56
+ const before = ctx(env.config)
57
+ const off = rule.when
58
+ await runCheckBody(rule, before, off.at === 'off' ? { reason: off.reason } : undefined)
59
+ // a check that is OFF never autofixes: the repo broke that invariant on purpose
60
+ if (off.at === 'off') {
61
+ if (o.quiet !== true) print(`skip: [${rule.id}] ${rule.id} exempt: ${off.reason ?? ''}`)
62
+ continue
63
+ }
64
+ if (!before.failed && !before.warned) {
65
+ // inside the ci fix pass, a line per already-clean rule is a wall of
66
+ // nothing above the run that reports those same rules for real
67
+ if (o.quiet !== true) print(`ok: [${rule.id}] already clean — nothing to fix`)
68
+ continue
69
+ }
70
+ const f = await rule.fix!(ctx(env.config))
71
+ // an unchanged fix is the DESIGNED outcome for judgment findings, so
72
+ // it is an ok, not a warning. It carries a frozen wire prefix like
73
+ // every other line the fix pass emits: `shipkit ci` promises its whole
74
+ // output is parseable, and a bare line breaks that for every reader.
75
+ print(f.changed ? `ok: [${rule.id}] fixed: ${f.summary}` : `ok: [${rule.id}] fix changed nothing — ${f.summary}`)
76
+ // a fixer may rewrite repo.config.ts, and the re-run below and every
77
+ // fixer after it must read what is on disk now, never the config this
78
+ // pass loaded before the rewrite
79
+ if (f.changed) {
80
+ try { env.config = await loadRepoConfig(root, { fresh: true }) } catch (e) {
81
+ print(`FAIL: [${rule.id}] repo.config.ts no longer loads after the fix — ${String(e).slice(0, 200)}`)
82
+ return 1
83
+ }
84
+ }
85
+ const after = ctx(env.config)
86
+ await runCheckBody(rule, after)
87
+ // the fix already ran: advertising it again on surviving findings
88
+ // would send an agent in a loop
89
+ for (const l of after.lines) print(formatLine({ ...l, text: l.text.replace(/ · autofix: shipkit check --fix \S+$/, '') }, color))
90
+ if (after.failed) exit = 1
91
+ }
92
+ const unfixable = all.filter((r) => r.fix == null).length
93
+ if (unfixable > 0 && o.quiet !== true && (o.ids == null || o.ids.length === 0))
94
+ print(`${unfixable} check(s) have no autofix — they report in the run below`)
95
+ return exit
96
+ }