rvlib-shipkit 0.38.1 → 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 +367 -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 +209 -15
  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
package/src/check.ts ADDED
@@ -0,0 +1,343 @@
1
+ // THE CHECK — one shape for everything a gate runs, and one way to add one.
2
+ //
3
+ // A repo drops `<name>.check.ts` anywhere, default-exports `check({...})`, and
4
+ // it shows up in `shipkit ci`, `shipkit check`, `shipkit checks` and the hub
5
+ // with no config edit. Whether it is a build step, a repo guard or a world
6
+ // probe is what the FIELDS say, never which registry it was put in. The
7
+ // toolkit's own built-ins are the same files under `src/checks/`, walked by
8
+ // the same code (core/discover-checks.ts).
9
+ //
10
+ // Two bodies, one object:
11
+ // `run` in process, ONE parameter `c` (core/check-ctx.ts): it reports
12
+ // through `c.ok/warn/fail` and reads the repo through `c.read`,
13
+ // `c.list`, `c.use(scan)`, which records what it read
14
+ // `cmd` argv, a subprocess, exit code is truth
15
+ //
16
+ // What a check declares about ITSELF is what every surface reads: `blocking`
17
+ // (can it stop you), how it is remembered (`ttl` or `never`, else a `run`
18
+ // check is keyed on what it read and a `cmd` check on its `inputs`), `when`
19
+ // (where it runs), `deps` (what must pass first). The file's own path joins
20
+ // its cache key automatically, so nothing declares its own code.
21
+ import type { CheckCtx } from './core/check-ctx.ts'
22
+ import { parseWhen } from './core/when.ts'
23
+ import type { Line } from './core/wire.ts'
24
+ import type { RepoConfig } from './repo-config.ts'
25
+
26
+ export { defineScan, mdPages, packageJson, type MdPage, type PackageJson, type Scan } from './scan.ts'
27
+ export type { CheckCtx, ScanCtx } from './core/check-ctx.ts'
28
+
29
+ /** where a check runs, for the hooks that run BEFORE its body (`cacheKey`) and
30
+ * for the legacy shapes. A body gets `c`, which carries both. */
31
+ export type CheckEnv = { root: string; config: RepoConfig }
32
+
33
+ /** whose check it is: the toolkit ships it, or this repo declared it */
34
+ export type CheckFrom = 'shipkit' | 'repo'
35
+
36
+ export type FixResult = { changed: boolean; summary: string }
37
+
38
+ export type CheckDef = {
39
+ /** the name every surface uses. Default: the file basename without
40
+ * `.check.ts`. A built-in carries `SK###`, stable forever, and a repo
41
+ * picks its own prefix or plain words. */
42
+ id?: string
43
+ /** display sugar, kept because `SK004` alone is unreadable on a row.
44
+ * Default: the id. */
45
+ title?: string
46
+ /** what MUST BE TRUE, in the present tense: ONE line, at most 90
47
+ * characters, as in `package.json routes lint, typecheck and test
48
+ * through the gate`. Never what red means, red is its negation. It
49
+ * renders at the top of the check page and as the tree row's tooltip.
50
+ * A title names the check, this states the invariant it holds. */
51
+ description: string
52
+ /** where the answer is OWNED: a wiki page for a built-in, the file itself
53
+ * for a repo check. It is the fix pointer floor whenever a failing body
54
+ * attached no precise pointer of its own: a file of this repo as it
55
+ * stands, a docs page after `docsBase`. Default: the file's
56
+ * repo-relative path. */
57
+ owner?: string
58
+ /** can this stop you? REQUIRED, no default. true = the failure is about
59
+ * THIS repo and blocks. false = it reports the WORLD (a box that is off,
60
+ * a cred nobody minted), so the failure prints as a warning and never
61
+ * blocks. The only question worth asking about a check, asked once. */
62
+ blocking: boolean
63
+ /** the body, in process. `c` is the one parameter: it reports (`c.ok`,
64
+ * `c.warn`, `c.fail`), says where (`c.root`, `c.config`, `c.frozen`),
65
+ * reads the repo with every read recorded (`c.read`, `c.json`,
66
+ * `c.exists`, `c.list`, `c.use(scan)`), and reaches the world (`c.exec`,
67
+ * `c.fetch`, `c.tool`). With no `inputs`, `ttl` or `never`, the answer is
68
+ * cached on what it read. One of `run` or `cmd`, never both. */
69
+ run?: (c: CheckCtx) => void | Promise<void>
70
+ /** a TOOL run as the body: argv without a shell, the repo root as the
71
+ * working directory (`bun run build`, `tsc`). The exit code is truth: a
72
+ * non-zero exit with no `FAIL:` line still fails loud. It never names the
73
+ * check file itself: a body of the file's own is `run` (SK020). */
74
+ cmd?: string[]
75
+ /** a BUILD STEP: `shipkit ci` runs it, the health pass never does. The
76
+ * gate asks "is my change good" and builds, `shipkit check` asks "is
77
+ * this repo healthy" and reports, and a tool chain has no business in
78
+ * the second question. A floor slot derives it, a file declares it, and
79
+ * it takes the 600s ceiling rather than the 120s one. `when` stays about
80
+ * TIME (always, release, health, off). A build step on `health` is an
81
+ * opt in one: the gate parks it and `shipkit ci --with <id>` runs it. */
82
+ build?: boolean
83
+ /** check ids that must pass first. One runner runs every check as one DAG,
84
+ * so a check naming none is a root of it, and a dep doubles as the race
85
+ * guard between two checks that touch one file. */
86
+ deps?: string[]
87
+ /** WHERE it runs: `always`, `release`, `health`, `off:<reason>`. The
88
+ * repo's `when: {}` map wins over it, so a config can move a check the
89
+ * repo does not own (core/when.ts owns the vocabulary). */
90
+ when?: string
91
+ /** CONTENT population: the repo-relative paths this check reads, so a
92
+ * cached answer is honest while they do not move. For a `cmd` check,
93
+ * whose subprocess reads what it likes. A `run` check that declares none
94
+ * of `inputs`, `ttl` and `never` is keyed on what it READ through `c`
95
+ * instead, the `reads` population (ledger/git-key.ts). A `cmd` check
96
+ * that declares none runs every time, which costs speed and never
97
+ * correctness. At most one of the three. */
98
+ inputs?: string[]
99
+ /** WORLD population: nothing on disk describes the answer, so a clean
100
+ * pass keeps for this long, as in '24h'. */
101
+ ttl?: string
102
+ /** a DECIDED never-cacheable, with the reason. Says somebody looked and
103
+ * no key can describe this, which takes it off the burn-down list. */
104
+ never?: string
105
+ /** it REWRITES the repo while it runs (updates a dependency, untracks a
106
+ * file) unless the run is frozen, so a frozen run asks it a different
107
+ * question: only a check declaring this carries `frozen` in its cache
108
+ * key, and every other check shares one answer between a frozen run and
109
+ * a normal one. Declared, never guessed from the body. */
110
+ writes?: boolean
111
+ /** cache-identity contribution for a `ttl` check whose verdict depends on
112
+ * config: a changed key busts the answer instantly instead of serving
113
+ * a stale cached assertion for the rest of the TTL. */
114
+ cacheKey?: (env: CheckEnv) => string
115
+ /** idempotent auto-repair, never interactive. `shipkit check --fix <id>`
116
+ * applies it, then re-runs the check. It gets the same `c` as the body,
117
+ * writes with `c.write` and `c.remove`, and is never cached. An `fs`
118
+ * import in the check file would keep an undeclared body off its reads
119
+ * (core/raw-access.ts). A repair that needs
120
+ * judgement stays a FAIL with a pointer. */
121
+ fix?: (c: CheckCtx) => FixResult | Promise<FixResult>
122
+ /** wall clock ceiling in ms. A `cmd` check is killed with its whole
123
+ * process tree at the ceiling. */
124
+ timeoutMs?: number
125
+ /** a label several checks share, so `--skip <group>` parks all of them
126
+ * and `--only <group>` runs all of them. */
127
+ group?: string
128
+ /** extra environment for a `cmd` check, merged over the run's own. */
129
+ env?: Record<string, string>
130
+ /** working directory for a `cmd` check (default: the repo root). */
131
+ cwd?: string
132
+ }
133
+
134
+ /** the longest description a row can show whole */
135
+ export const DESCRIPTION_MAX = 90
136
+
137
+ /** what is wrong with a description, or null when it states an invariant
138
+ * the way every surface expects: one line, short, the fact that must hold.
139
+ * A red-means clause is refused because red is always the negation. */
140
+ export function descriptionProblem(description: unknown): string | null {
141
+ if (typeof description !== 'string' || description.trim() === '')
142
+ return 'description is required: ONE line stating what must be true'
143
+ const d = description.trim()
144
+ if (/[\r\n]/.test(d)) return 'description must be ONE line'
145
+ if (d.length > DESCRIPTION_MAX) return `description is ${d.length} characters, the limit is ${DESCRIPTION_MAX}: state only what must be true`
146
+ if (/^this checks\b/i.test(d)) return `description states what must be true, with no 'this checks' opener`
147
+ if (/\b(red|yellow|green|a warning|warn)( one)? means\b|\ba red one\b/i.test(d))
148
+ return 'description states what must be true, never what red means (red is its negation)'
149
+ return null
150
+ }
151
+
152
+ /** the first candidate the shape accepts. A generated row states its
153
+ * invariant from what it knows, and falls back to a shorter spelling when a
154
+ * long path or argv would break the line limit */
155
+ export function describeFirst(...candidates: (string | null | undefined)[]): string {
156
+ for (const d of candidates) if (d != null && descriptionProblem(d) == null) return d.trim()
157
+ throw new Error(`no description candidate passes the shape: ${candidates.map((d) => JSON.stringify(d)).join(', ')}`)
158
+ }
159
+
160
+ /** THE way a check file declares itself. It validates the shape at import
161
+ * time, so a malformed check dies when discovery loads it and never mid
162
+ * gate. The id, the title and the owner are filled in by discovery, which
163
+ * is the only thing that knows the file's name and place. */
164
+ export function check(d: CheckDef): CheckDef {
165
+ const has = (k: keyof CheckDef): boolean => d[k] != null
166
+ const name = d.id == null ? '' : ` ${d.id}`
167
+ const bad = descriptionProblem(d.description)
168
+ if (bad != null) throw new Error(`check${name}: ${bad}`)
169
+ if (typeof d.blocking !== 'boolean')
170
+ throw new Error(`check${d.id == null ? '' : ` ${d.id}`}: blocking is required — true (about this repo, it stops you) or false (about the world, it warns)`)
171
+ if (has('run') === has('cmd'))
172
+ throw new Error(`check${d.id == null ? '' : ` ${d.id}`}: declare exactly one body, \`run\` (in process) or \`cmd\` (argv)`)
173
+ const populations = (['inputs', 'ttl', 'never'] as const).filter((k) => has(k))
174
+ if (populations.length > 1)
175
+ throw new Error(`check${d.id == null ? '' : ` ${d.id}`}: ${populations.join(' and ')} contradict each other — a check reads files, or the world, or nothing can describe it`)
176
+ if (d.when != null) {
177
+ const w = parseWhen(d.when)
178
+ if (typeof w === 'string') throw new Error(`check${d.id == null ? '' : ` ${d.id}`}: when ${w}`)
179
+ }
180
+ return d
181
+ }
182
+
183
+ // ── the legacy TASK shape, for the compatibility window ──────────────────
184
+ // A repo still declaring build steps through `repo.config.ts tasks: []`, and
185
+ // every repo decorating a floor slot through `defaults`, writes this one. It
186
+ // is read as a check whose body is its `cmd` or its `logic`, and it dies with
187
+ // those keys once `shipkit fleet` shows no repo below 0.40.
188
+
189
+ export type TaskDef = {
190
+ /** the name every surface uses: the gate line, `--only`, `--skip`, a
191
+ * `deps` entry, a `when` key and the ledger row. Unique in the repo. */
192
+ id: string
193
+ /** one line stating what must be true (the shape: descriptionProblem).
194
+ * A floor slot and a unit test check get theirs from the
195
+ * generator, a task that names none reads as its command exiting 0 */
196
+ description?: string
197
+ /** argv, run without a shell. One of `cmd` or `logic` per task. */
198
+ cmd?: string[]
199
+ /** the body as a function, for work that is already TypeScript. It gets
200
+ * the wire context, so it reports `ok:` / `WARN` / `FAIL:` lines the way
201
+ * a subprocess does. The legacy spelling of a check's `run`. */
202
+ logic?: (t: CheckCtx) => unknown
203
+ /** ids that must pass first. The DAG runs everything else in parallel, so
204
+ * a dep doubles as the race guard between two that touch one file. */
205
+ deps?: string[]
206
+ /** extra environment for a `cmd` task, merged over the run's own */
207
+ env?: Record<string, string>
208
+ /** working directory for a `cmd` task (default: the repo root) */
209
+ cwd?: string
210
+ /** a label several tasks share, so `--skip <group>` parks all of them at
211
+ * once (a local bench, a slow integration tier). */
212
+ group?: string
213
+ /** DEPRECATED, read into `when` as `health`: out of the gate until
214
+ * `--with <id>` names it. */
215
+ optIn?: boolean
216
+ /** wall clock ceiling in ms, default 600000 for a build step. One that
217
+ * hits it is killed with its whole process tree, because the cmd is
218
+ * usually a wrapper. */
219
+ timeoutMs?: number
220
+ /** CONTENT population: the repo-relative paths this task reads. Declaring
221
+ * them is what makes it keyable (ledger/git-key.ts). Undeclared is the
222
+ * DEFAULT and means never cached, so forgetting one costs speed and
223
+ * never correctness. A list MISSING a path the command reads is the one
224
+ * that costs correctness: the key cannot move when that path changes, so
225
+ * a cached answer comes back over a tree it no longer describes. `['.']` is the
226
+ * whole TRACKED tree, and it is NOT a catch-all: an ignored path enters a
227
+ * key by name and never by content, so a task reading a gitignored
228
+ * fixture, a built dir or a .env under declares with `['.']` exactly as
229
+ * it would with a narrow list. */
230
+ inputs?: string[]
231
+ /** WORLD population: it reads the network, a keychain, $PATH or the clock,
232
+ * so nothing on disk describes its answer and a clean pass is remembered
233
+ * for this long, as in '24h'. */
234
+ ttl?: string
235
+ /** a DECIDED never-cacheable, with the reason. */
236
+ never?: string
237
+ }
238
+
239
+ /** every field a `TaskDef` may carry. Declared as a value so `defineRepo` can
240
+ * refuse a typo inside a `defaults` slot: a slot with no `cmd` DECORATES the
241
+ * resolved task, so a misspelled field is not a type error at the call site
242
+ * and would land as a task that is quietly never cached. Add a field above,
243
+ * add it here. */
244
+ export const TASK_DEF_KEYS = ['id', 'cmd', 'logic', 'deps', 'env', 'cwd', 'group', 'optIn', 'timeoutMs', 'inputs', 'ttl', 'never'] as const
245
+
246
+ // ── the legacy rule shape, for the compatibility window ──────────────────
247
+ // A repo still registering through `repo.config.ts rules: []` writes this
248
+ // one. It is read as a check whose body is `lint`, and it dies with that key
249
+ // once `shipkit fleet` shows no repo below 0.40.
250
+
251
+ export type RuleEnv = CheckEnv
252
+
253
+ export type Rule = {
254
+ /** stable PREFIX + 3 digits, never reused or renumbered. `SK###` is
255
+ * reserved for the toolkit, so a repo picks its own (`RV###`). A check
256
+ * FILE takes its id from its name and may use plain words instead. */
257
+ id: string
258
+ /** display name, stable: the row in STATUS.md, the TUI, the suite cache */
259
+ title: string
260
+ /** can this stop you? REQUIRED, no default, exactly as a check declares it */
261
+ blocking: boolean
262
+ /** wiki page owning the invariant — the body runner appends
263
+ * `docsBase + owner` as the fix: pointer whenever a failing lint
264
+ * attached none. A check file defaults this to its own path. */
265
+ owner: string
266
+ /** WORLD population: clean-pass cache duration for a rule that probes the
267
+ * network, as in '24h'. A skip, a WARN and a FAIL are never cached. */
268
+ ttl?: string
269
+ /** CONTENT population: the repo-relative paths this rule reads. Neither
270
+ * `inputs` nor `ttl` means never cached, which is the safe default, and
271
+ * declaring both is a registration failure. */
272
+ inputs?: string[]
273
+ /** repo-relative entry file(s) of this rule's OWN code, so fixing a bug in
274
+ * the rule moves its key. A check FILE needs none: its own path is its
275
+ * closure, which is the field this shape exists without. */
276
+ code?: string[]
277
+ /** a DECIDED never-cacheable, with the reason: it reads git history, a
278
+ * path outside the repo, or the wall clock, so no key can describe it. */
279
+ never?: string
280
+ /** cache-identity contribution for a TTL'd rule whose verdict depends on
281
+ * config: a changed key busts the answer instead of serving it cached */
282
+ cacheKey?: (env: RuleEnv) => string
283
+ /** the invariant itself, the legacy spelling of a check's `run` */
284
+ lint: (t: CheckCtx, env: RuleEnv) => void | Promise<void>
285
+ /** idempotent auto-repair, never interactive. `check --fix` applies it */
286
+ fix?: (env: RuleEnv) => FixResult | Promise<FixResult>
287
+ }
288
+
289
+ // ── running a body, and what every line it prints carries ────────────────
290
+
291
+ /** the config part of a check's cache identity. Where it RUNS is part of it:
292
+ * moving one off the gate, or editing the reason it is off, has to ask the
293
+ * question again rather than serve the cached answer from before the move. */
294
+ export function checkCacheExtra(u: { id: string; cacheKey?: (env: CheckEnv) => string }, env: CheckEnv, at: { at: string; reason: string | null; source: string }): string | undefined {
295
+ const key = u.cacheKey?.(env)
296
+ if (at.source === 'default') return key
297
+ return at.at === 'off' ? `${key ?? ''}|exempt:${at.reason ?? ''}` : `${key ?? ''}|when:${at.at}`
298
+ }
299
+
300
+ /** THE one way to run an in-process body (the check suite and the `--fix`
301
+ * runner both come through here): a throwing body still fails with its id,
302
+ * a failing or warning run always carries at least one `fix:` pointer (the
303
+ * body's own precise ones win, else `docsBase` plus the check's owner), and
304
+ * every line gets tagged. */
305
+ export async function runCheckBody(
306
+ u: { id: string; title: string; owner: string; run?: CheckDef['run']; fix?: unknown },
307
+ c: CheckCtx,
308
+ /** the check is OFF: it still enters the suite, so the reason stays
309
+ * visible in every output instead of the row disappearing from the list */
310
+ off?: { reason: string | null },
311
+ ): Promise<void> {
312
+ if (off != null) {
313
+ c.lines.push({ kind: 'skip', text: `${u.id} exempt: ${off.reason ?? ''}` })
314
+ c.lines = tagCheckLines(u, c.lines)
315
+ return
316
+ }
317
+ try {
318
+ await u.run?.(c)
319
+ } catch (e) {
320
+ // a 429 is no answer at all, so it warns and nothing remembers it
321
+ if (e instanceof Error && e.name === 'RateLimited') c.unreachable(`${u.title} not checked this run (${e.message}), retry later`)
322
+ else c.fail(`${u.title} body errored (${String(e).slice(0, 200)}) — network down, or a bug in this check`)
323
+ }
324
+ // the pass's config, never `c.config`: the pointer is the runner's line,
325
+ // and it must not enter the body's read set
326
+ if ((c.failed || c.warned) && !c.lines.some((l) => l.kind === 'fix-fail' || l.kind === 'fix-warn'))
327
+ c.lines.push({ kind: c.failed ? 'fix-fail' : 'fix-warn', text: c.ownerPointer(u.owner) })
328
+ c.lines = tagCheckLines(u, c.lines)
329
+ }
330
+
331
+ /** tag every severity line with the check id; a fix pointer of a fixable check
332
+ * advertises the autofix. Idempotent, since an already-tagged line passes
333
+ * back through here on a cached suite replay. */
334
+ export function tagCheckLines(u: { id: string; fix?: unknown }, lines: Line[]): Line[] {
335
+ return lines.map((l) => {
336
+ if (l.kind === 'fix-warn' || l.kind === 'fix-fail')
337
+ return u.fix == null || l.text.includes('--fix ')
338
+ ? l
339
+ : { ...l, text: `${l.text} · autofix: shipkit check --fix ${u.id}` }
340
+ if (l.kind === 'note') return l
341
+ return l.text.startsWith(`[${u.id}]`) ? l : { ...l, text: `[${u.id}] ${l.text}` }
342
+ })
343
+ }
@@ -1,21 +1,20 @@
1
1
  // SK001 — canonical package.json shape (meta/rv-conventions/package-json.md):
2
2
  // private:true, no license, capability-required script names FAIL, banned
3
3
  // aliases + missing baseline scripts WARN. Blocking, so it runs in `shipkit
4
- // ci` like every other rule. Pure local fs, no TTL.
4
+ // ci` like every other rule. It reads only through `c`, so it is cached on
5
+ // what it read: package.json, the probes below, the test file listing, and
6
+ // whatever the floor resolution read.
5
7
  //
6
8
  // It also enforces the package.json half of ONE WAY IN (build/shipkit.md):
7
9
  // `test`, `lint` and `typecheck` ROUTE to `shipkit ci --only <id>` and the
8
10
  // tool body they used to hold sits at `<id>:raw`. That is what makes a cache
9
11
  // worth having: `bun run test` is the reflex, and the reflex has to land in
10
12
  // shipkit rather than beside it.
11
- import { existsSync, readdirSync, readFileSync } from 'fs'
12
- import { join } from 'path'
13
13
  import { canonicalBodies, conditionalBodies, inferBin, ROUTED_SCRIPTS, rawName, routedBody, routesToShipkit } from '../core/canonical-scripts.ts'
14
14
  import type { CheckCtx } from '../core/check-ctx.ts'
15
- import { resolveFloor } from '../core/floor.ts'
15
+ import { checkFloorReader, resolveFloor } from '../core/floor-resolve.ts'
16
16
  import type { RepoConfig } from '../repo-config.ts'
17
- import type { FixResult, Rule, RuleEnv } from './rule.ts'
18
- import { atomicWrite } from '../core/locked-write.ts'
17
+ import { check, type FixResult } from '../check.ts'
19
18
 
20
19
  type Pkg = { name?: string; private?: boolean; license?: string; scripts?: Record<string, string> }
21
20
 
@@ -35,10 +34,13 @@ const BASELINE = ['test', 'lint', 'fmt'] as const
35
34
  // no per-line fix pointers here — the rule framework appends ONE pointer
36
35
  // (docsBase + OWNER) to a failing/warning run, the same one-per-severity
37
36
  // doctrine the contracts engine follows
38
- export async function evaluatePackageJson(t: CheckCtx, root: string, c: RepoConfig): Promise<void> {
37
+ export function evaluatePackageJson(t: CheckCtx): void {
38
+ const c = t.config
39
39
  let pkg: Pkg
40
40
  try {
41
- pkg = (await Bun.file(join(root, 'package.json')).json()) as Pkg
41
+ const read = t.json<Pkg>('package.json')
42
+ if (read == null) throw new Error('no package.json')
43
+ pkg = read
42
44
  } catch (e) {
43
45
  t.fail(`package.json unreadable (${String(e).slice(0, 120)})`)
44
46
  return
@@ -72,7 +74,7 @@ export async function evaluatePackageJson(t: CheckCtx, root: string, c: RepoConf
72
74
 
73
75
  const has = (name: string): boolean => scripts[name] != null || aliased.has(name)
74
76
 
75
- const required = requiredScripts(root, c)
77
+ const required = requiredScripts(t)
76
78
  const missing = required.filter((r) => !has(r.name))
77
79
  for (const m of missing) t.fail(`package.json: missing script '${m.name}' (${m.why})`)
78
80
 
@@ -80,7 +82,7 @@ export async function evaluatePackageJson(t: CheckCtx, root: string, c: RepoConf
80
82
  // reason a cache gets bypassed: an agent types `bun run test` and shipkit
81
83
  // never hears about it. A slot the repo turned off has no shipkit task to
82
84
  // route to, so it is not governed.
83
- const floor = resolveFloor(c, root)
85
+ const floor = resolveFloor(c, t.root, checkFloorReader(t))
84
86
  for (const id of ROUTED_SCRIPTS) {
85
87
  if (routedExempt(c).has(id) || c.defaults?.[id] === false) continue
86
88
  const body = scripts[id]
@@ -90,13 +92,13 @@ export async function evaluatePackageJson(t: CheckCtx, root: string, c: RepoConf
90
92
  continue
91
93
  }
92
94
  const gap = floor.unfilled.find((u) => u.id === id)
93
- if (gap != null) t.fail(`package.json: ${gap.reason} — \`bun run ${id}\` would measure nothing`)
95
+ if (gap != null) t.fail(`package.json: ${gap.reason} — \`bun run ${id}\` would run nothing`)
94
96
  }
95
97
 
96
98
  // `test` is asked for only once the repo HAS tests: the fixer deliberately
97
99
  // refuses to scaffold it before that (a fixer never manufactures a
98
100
  // failure), so nagging for it would be a WARN no machine may clear
99
- const baseline = BASELINE.filter((n) => n !== 'test' || hasTestFiles(root))
101
+ const baseline = BASELINE.filter((n) => n !== 'test' || hasTestFiles(testFiles(t)))
100
102
  const missingBaseline = baseline.filter((n) => !has(n))
101
103
  if (missingBaseline.length > 0)
102
104
  t.warn(`package.json: missing baseline script(s): ${missingBaseline.join(', ')}`)
@@ -111,13 +113,13 @@ export async function evaluatePackageJson(t: CheckCtx, root: string, c: RepoConf
111
113
  // a shipkit verb is never on this list. `shipkit ci`, `check`, `contracts`,
112
114
  // `tui`, `mv`, `assets`, `asks` are typed as themselves, and a package.json
113
115
  // row that only forwards to one is a second name for one capability
114
- function requiredScripts(root: string, _c: RepoConfig): Array<{ name: string; why: string }> {
116
+ function requiredScripts(c: CheckCtx): Array<{ name: string; why: string }> {
115
117
  const required: Array<{ name: string; why: string }> = [
116
118
  // watch-mode serve — the ONE shipkit surface the bare verb cannot give
117
119
  { name: 'hub', why: 'shipkit repo' },
118
120
  ]
119
- if (existsSync(join(root, '.rv-comfy'))) required.push({ name: 'comfy:up', why: '.rv-comfy/ exists' })
120
- if (existsSync(join(root, 'deploy.sh'))) required.push({ name: 'up', why: 'deploy.sh exists' })
121
+ if (c.exists('.shipkit/comfy')) required.push({ name: 'comfy:up', why: '.shipkit/comfy/ exists' })
122
+ if (c.exists('deploy.sh')) required.push({ name: 'up', why: 'deploy.sh exists' })
121
123
  return required
122
124
  }
123
125
 
@@ -145,42 +147,36 @@ function routedExempt(c: RepoConfig): Set<string> {
145
147
  }
146
148
 
147
149
  const TEST_SKIP = new Set(['node_modules', 'dist', 'tmp', 'references', 'coverage'])
148
-
149
- /** any `*.test.ts` in the repo, so the test script is only scaffolded once it
150
- * has something to run */
151
- export function hasTestFiles(root: string): boolean {
152
- const walk = (dir: string, depth: number): boolean => {
153
- if (depth > 4) return false
154
- let entries
155
- try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return false }
156
- for (const e of entries) {
157
- if (e.isDirectory()) { if (!TEST_SKIP.has(e.name) && !e.name.startsWith('.') && walk(join(dir, e.name), depth + 1)) return true }
158
- else if (/\.(test|spec)\.[cm]?[jt]sx?$/.test(e.name)) return true
159
- }
160
- return false
161
- }
162
- return walk(root, 0)
150
+ const TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$/
151
+
152
+ /** the repo's test file candidates as git sees them, one recorded listing:
153
+ * a new test file reruns the check, an edit anywhere else does not */
154
+ const testFiles = (c: CheckCtx): string[] => c.list('**/*.{test,spec}.*')
155
+
156
+ /** any `*.test.ts` among these repo-relative paths, outside installs, build
157
+ * output and dot dirs, so the test script is only scaffolded once it has
158
+ * something to run */
159
+ export function hasTestFiles(paths: readonly string[]): boolean {
160
+ return paths.some((f) => {
161
+ const parts = f.split('/')
162
+ return TEST_FILE.test(parts.at(-1)!) && parts.slice(0, -1).every((d) => !TEST_SKIP.has(d) && !d.startsWith('.'))
163
+ })
163
164
  }
164
165
 
165
- export const packageJsonShape: Rule = {
166
+ export const packageJsonShape = check({
166
167
  id: 'SK001',
167
168
  blocking: true,
168
169
  title: 'package.json',
170
+ description: 'package.json routes lint, typecheck and test through the gate',
169
171
  owner: OWNER,
170
- // the whole tree: package.json is most of it, but the baseline-script
171
- // check also walks the repo for a `*.test.ts` and probes `.rv-comfy`
172
- // and `deploy.sh`, so naming three files would key it on less than it
173
- // reads
174
- inputs: ['.'],
175
- lint: (t: CheckCtx, env: RuleEnv) => evaluatePackageJson(t, env.root, env.config),
172
+ run: (c) => evaluatePackageJson(c),
176
173
  // mechanical repairs only: field flips + scripts whose canonical body the
177
174
  // vocabulary page states. Bodies it does NOT state (assets, comfy:up) are
178
175
  // named in the summary, never invented. rvlib-* roots untouched — their
179
176
  // license/private are publish decisions.
180
- fix: (env: RuleEnv): FixResult => {
181
- const pkgPath = join(env.root, 'package.json')
182
- if (!existsSync(pkgPath)) return { changed: false, summary: 'no package.json — nothing to fix' }
183
- const raw = readFileSync(pkgPath, 'utf8')
177
+ fix: (c): FixResult => {
178
+ const raw = c.read('package.json')
179
+ if (raw == null) return { changed: false, summary: 'no package.json — nothing to fix' }
184
180
  let pkg: Pkg & { scripts?: Record<string, string> }
185
181
  try { pkg = JSON.parse(raw) as Pkg } catch { return { changed: false, summary: 'package.json unparseable — repair it by hand first' } }
186
182
  if (pkg.name?.startsWith('rvlib-') === true) return { changed: false, summary: 'rvlib-* package — license/private are publish decisions, not autofixable' }
@@ -207,7 +203,7 @@ export const packageJsonShape: Rule = {
207
203
  const aliasSatisfied = new Set<string>()
208
204
  for (const [name, a] of Object.entries(ALIASES))
209
205
  if (pkg.scripts[name] != null && a.bodyMatch.test(pkg.scripts[name]!)) aliasSatisfied.add(a.canonical)
210
- for (const m of requiredScripts(env.root, env.config).filter((r) => pkg.scripts![r.name] == null && !aliasSatisfied.has(r.name))) {
206
+ for (const m of requiredScripts(c).filter((r) => pkg.scripts![r.name] == null && !aliasSatisfied.has(r.name))) {
211
207
  const body = bodies[m.name]
212
208
  if (body == null) { skipped.push(m.name); continue }
213
209
  pkg.scripts[m.name] = body
@@ -216,13 +212,13 @@ export const packageJsonShape: Rule = {
216
212
  // only once the repo HAS tests. `bun test` exits non-zero when it finds
217
213
  // none, so scaffolding this into a repo without any turns the gate red
218
214
  // on work the fixer itself did. a fixer must never manufacture a failure.
219
- if (pkg.scripts['test'] == null && hasTestFiles(env.root)) { pkg.scripts['test'] = 'bun test'; changes.push(`script 'test' added`) }
215
+ if (pkg.scripts['test'] == null && hasTestFiles(testFiles(c))) { pkg.scripts['test'] = 'bun test'; changes.push(`script 'test' added`) }
220
216
  // ONE WAY IN, last so it also routes whatever this pass just added:
221
217
  // the tool body slides to `<id>:raw` and `<id>` becomes the shipkit
222
218
  // call. Both keys keep their place in the file, so the diff a human
223
219
  // reads is the two lines that changed and not a reordered block
224
220
  for (const id of ROUTED_SCRIPTS) {
225
- if (routedExempt(env.config).has(id) || env.config.defaults?.[id] === false) continue
221
+ if (routedExempt(c.config).has(id) || c.config.defaults?.[id] === false) continue
226
222
  const body = pkg.scripts[id]
227
223
  if (body == null || routesToShipkit(body)) continue
228
224
  const raw = rawName(id)
@@ -236,7 +232,7 @@ export const packageJsonShape: Rule = {
236
232
  }
237
233
  if (changes.length > 0) {
238
234
  const indent = raw.match(/^(\s+)"/m)?.[1] ?? ' '
239
- atomicWrite(pkgPath, JSON.stringify(pkg, null, indent) + '\n')
235
+ c.write('package.json', JSON.stringify(pkg, null, indent) + '\n')
240
236
  }
241
237
  const summary = [
242
238
  changes.length > 0 ? changes.join(', ') : 'nothing mechanical to change',
@@ -245,4 +241,6 @@ export const packageJsonShape: Rule = {
245
241
  ].join(' · ')
246
242
  return { changed: changes.length > 0, summary }
247
243
  },
248
- }
244
+ })
245
+
246
+ export default packageJsonShape
@@ -0,0 +1,24 @@
1
+ // SK002 — .shipkit/* contract fulfillness (meta/contracts.md). The rule is the
2
+ // registration point; the evaluation machinery stays in
3
+ // contracts/contracts-engine.ts (venue manifests are DATA rows, not rules —
4
+ // one SK id covers the invariant "declared contracts are fulfilled, folders
5
+ // can't rot undeclared"). Every surface reporting fulfillness (`check`,
6
+ // `shipkit contracts`, the ci `contracts` check) tags with this id via
7
+ // contracts/apply-contracts.ts.
8
+ import { applyContractsResult, contractsResult } from '../contracts/apply-contracts.ts'
9
+ import { check } from '../check.ts'
10
+
11
+ export const contractsFulfillness = check({
12
+ id: 'SK002',
13
+ blocking: true,
14
+ title: 'contracts',
15
+ description: 'every declared venue has a complete contract folder',
16
+ owner: 'meta/contracts.md',
17
+ // keyed on what the contracts read: each artifact, each detector, and each
18
+ // folder a glob walked through `c.entries`, a gitignored one included
19
+ run: async (c) => {
20
+ applyContractsResult(c, await c.use(contractsResult), contractsFulfillness)
21
+ },
22
+ })
23
+
24
+ export default contractsFulfillness
@@ -0,0 +1,66 @@
1
+ // SK003 — the CLAUDE.md two-line contract (meta/agent-folder.md): CLAUDE.md
2
+ // is an include index, never a doc; `@./agent/index.md` comes LAST (include
3
+ // order is SEMANTIC — base context first, repo docs after so they
4
+ // override); a
5
+ // public repo's `@./CLAUDE.local.md` line-1 indirection must be gitignored.
6
+ // The rule asserts no base include path of its own: `shipkit start` owns
7
+ // which one lands (its house template, or `--include`). A repo with neither
8
+ // CLAUDE.md nor agent/ is not a two-line-contract repo and passes.
9
+ import { check, type FixResult } from '../check.ts'
10
+
11
+ /** the repo router line, always LAST: start writes it whenever agent/index.md exists */
12
+ export const AGENT_LINE = '@./agent/index.md'
13
+ const LOCAL_LINE = '@./CLAUDE.local.md'
14
+
15
+ export const claudeMdShape = check({
16
+ id: 'SK003',
17
+ blocking: true,
18
+ title: 'CLAUDE.md',
19
+ description: 'CLAUDE.md loads the house wiki into every session',
20
+ owner: 'meta/agent-folder.md',
21
+ // keyed on what it reads: CLAUDE.md and whether agent/index.md exists. The
22
+ // gitignore probe runs git, so an answer that took it is never stored
23
+ run: async (c) => {
24
+ const hasAgent = c.exists('agent/index.md')
25
+ const claude = c.read('CLAUDE.md')
26
+ if (claude == null) {
27
+ if (hasAgent) c.fail(`agent/index.md exists but there is no CLAUDE.md routing it — the two-line contract wants ${AGENT_LINE}`)
28
+ else c.ok('no CLAUDE.md / agent — not a two-line-contract repo')
29
+ return
30
+ }
31
+ const lines = claude.split('\n').filter((l) => l.trim() !== '')
32
+ const includes = lines.filter((l) => l.startsWith('@'))
33
+ const prose = lines.length - includes.length
34
+ if (prose > 0)
35
+ c.fail(`CLAUDE.md carries ${prose} non-include line(s) — it is a tiny include index, content belongs in agent/index.md`)
36
+ if (includes.length > 2)
37
+ c.fail(`CLAUDE.md has ${includes.length} include lines — the contract is 2: base context, then ${AGENT_LINE}`)
38
+ if (hasAgent && !lines.includes(AGENT_LINE))
39
+ c.fail(`agent/index.md exists but CLAUDE.md does not import it — add ${AGENT_LINE} as the LAST line`)
40
+ if (lines.includes(AGENT_LINE) && lines.at(-1) !== AGENT_LINE)
41
+ c.fail(`include order inverted — base context first, ${AGENT_LINE} LAST (order is semantic: repo docs override the base)`)
42
+ if (lines.includes(LOCAL_LINE)) {
43
+ const r = await c.exec(['git', 'check-ignore', '-q', 'CLAUDE.local.md'], { cwd: c.root, timeoutMs: 5_000 })
44
+ if (r.code === 1)
45
+ c.fail(`${LOCAL_LINE} is imported but CLAUDE.local.md is not gitignored — the machine-local include must never be tracked`)
46
+ else if (r.code !== 0)
47
+ c.note(`CLAUDE.local.md gitignore not verifiable (git says: ${r.out.trim().split('\n')[0] ?? `exit ${r.code}`})`)
48
+ }
49
+ if (!c.failed) c.ok(`CLAUDE.md include-index ok (${lines.length} line(s))`)
50
+ },
51
+ // the one mechanical repair: the missing agent/index.md route, appended
52
+ // to an EXISTING CLAUDE.md. Never creates the file — a created one-line
53
+ // CLAUDE.md would lint green with the base include forever missing,
54
+ // trading a loud FAIL for a permanent silent gap. Creation and prose
55
+ // relocation stay human/machine-local (`shipkit start --include`).
56
+ fix: (c): FixResult => {
57
+ if (!c.exists('agent/index.md')) return { changed: false, summary: 'no agent/index.md — nothing mechanical to fix' }
58
+ const cur = c.read('CLAUDE.md')
59
+ if (cur == null) return { changed: false, summary: 'CLAUDE.md missing — creation needs the base include (shipkit start <name> writes it), not autofixable' }
60
+ if (cur.split('\n').includes(AGENT_LINE)) return { changed: false, summary: `${AGENT_LINE} already routed — remaining findings need a human` }
61
+ c.write('CLAUDE.md', `${cur.endsWith('\n') || cur === '' ? cur : `${cur}\n`}${AGENT_LINE}\n`)
62
+ return { changed: true, summary: `${AGENT_LINE} appended as the last line` }
63
+ },
64
+ })
65
+
66
+ export default claudeMdShape