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,105 +0,0 @@
1
- // THE inventory render — every gate unit as one markdown row, so the question
2
- // "what runs here, does it block me, and how often does it really run" has a
3
- // surface that is read rather than reconstructed from three commands.
4
- //
5
- // Deliberately carries NO live run state: no timestamps, no key hashes, no
6
- // last verdict. Everything here is what a unit DECLARED about itself, so two
7
- // runs a week apart over an unchanged repo write identical bytes and the file
8
- // never churns. It is still generated, gitignored AND untracked like STATUS.md
9
- // (rules/untrack-generated.ts), so it is read on disk, never in git history.
10
- // Live keys are `shipkit keys`, live verdicts are STATUS.md.
11
- import { join } from 'path'
12
- import { atomicWrite, withFileLock } from '../core/locked-write.ts'
13
- import { classify } from './git-key.ts'
14
- import type { UnitRow } from './gate-units.ts'
15
-
16
- /** the sidecar every gate run refreshes beside STATUS.md */
17
- export const UNIT_TABLE_FILE = 'STATUS.full.md'
18
-
19
- /** a cell can never break the table it sits in */
20
- const cell = (s: string): string => s.replace(/\|/g, '\\|').replace(/\n/g, ' ')
21
-
22
- export type UnitTableInput = {
23
- /** repo name, for the headline */
24
- name: string
25
- rows: UnitRow[]
26
- }
27
-
28
- /** what a unit's cache declaration means for how often it actually runs */
29
- function cacheOf(r: UnitRow): { population: string; ttl: string; reads: string; why: string } {
30
- const c = classify(r.decl)
31
- const inputs = r.decl.inputs ?? []
32
- if (c.error != null) return { population: 'BROKEN', ttl: '', reads: '', why: c.error }
33
- if (c.population === 'content') return { population: 'content', ttl: '', reads: inputs.join(', '), why: '' }
34
- // no `reads` for these two: the cache column already says "world"/"never",
35
- // and restating it on every such row is a column of noise
36
- if (c.population === 'world') return { population: 'world', ttl: r.decl.ttl ?? '', reads: '', why: '' }
37
- if (c.decided === true) return { population: 'never', ttl: '', reads: '', why: r.decl.never ?? '' }
38
- return { population: 'UNDECLARED', ttl: '', reads: '', why: 'nobody has declared what this reads, so it re-runs every single time' }
39
- }
40
-
41
- export function renderUnitTable(input: UnitTableInput): string {
42
- const { name, rows } = input
43
- const counts = { task: 0, rule: 0, check: 0 }
44
- for (const r of rows) counts[r.decl.kind]++
45
-
46
- const body: string[] = []
47
- body.push(`<!-- generated by \`shipkit units\`, refreshed by every gate run, gitignored (SK004) -->`)
48
- body.push(`# ${name} units — ${rows.length} total`)
49
- body.push('')
50
- body.push(`Everything \`shipkit ci\` and \`shipkit check\` know how to run: ${counts.task} task(s), ${counts.rule} rule(s), ${counts.check} discovered check(s).`)
51
- body.push('')
52
- body.push('- **runs in** — `gate` means `shipkit ci` runs it, so a red one says THIS change is broken. `proactive` means only `shipkit check` runs it: it reports the world (a box that is off, a cred nobody minted, a page nobody refreshed), it can sit red for months with nothing wrong, and it never stands between you and a commit.')
53
- body.push('- **on failure** — `blocks` stops the run. `warns` prints and carries on. `UNDECLARED` is a defect, not a setting: the unit never said and the repo set no default, so the runner treats it as fatal by guess.')
54
- body.push('- **cache** — `content` replays while the files it reads are untouched. `world` replays for its TTL, and a measured RED replays too, capped at one hour so the run after a fix sees the fix. `never` re-runs every time and somebody wrote down why. `UNDECLARED` re-runs every time and nobody has looked yet, which is the burn-down (`shipkit keys --burn-down`). `SHIPKIT_FRESH=1` busts all of it.')
55
- const proactive = rows.filter((r) => !r.gate && !r.optIn).length
56
- const warns = rows.filter((r) => r.blocking === false).length
57
- const neverSaid = rows.filter((r) => r.blocking == null).length
58
- const undeclared = rows.filter((r) => cacheOf(r).population === 'UNDECLARED')
59
- body.push('')
60
- // the most useful line in the file, so it sits ABOVE the 68 rows rather
61
- // than under them
62
- body.push(
63
- `**${rows.length - proactive} in the gate, ${proactive} proactive · ${warns} warn instead of blocking${neverSaid > 0 ? `, ${neverSaid} never said` : ''} · ${undeclared.length} uncacheable with nobody having looked.**`,
64
- )
65
- body.push('')
66
- body.push('| unit | kind | runs in | on failure | cache | ttl | reads | owner |')
67
- body.push('|---|---|---|---|---|---|---|---|')
68
- for (const r of rows) {
69
- const c = cacheOf(r)
70
- const blocking = r.blocking == null ? '**UNDECLARED**' : r.blocking ? 'blocks' : 'warns'
71
- const runsIn = r.optIn ? 'opt-in' : r.gate ? 'gate' : 'proactive'
72
- const population = c.population === 'UNDECLARED' ? '**UNDECLARED**' : c.population
73
- body.push(
74
- `| \`${cell(r.decl.id)}\` | ${r.decl.kind} | ${runsIn} | ${blocking} | ${population} | ${cell(c.ttl)} | ${cell(c.reads)} | ${r.owner == null ? '' : `\`${cell(r.owner)}\``} |`,
75
- )
76
- }
77
- body.push('')
78
-
79
- const decided = rows.filter((r) => cacheOf(r).population === 'never')
80
- if (undeclared.length > 0) {
81
- body.push(`## ${undeclared.length} unit(s) nothing can cache yet`)
82
- body.push('')
83
- body.push('Each of these runs on every single pass. Declaring `inputs` (it reads files), `ttl` (it reads the world) or `never` (nothing can describe it) is the fix, and the last one counts: a written reason is a decision, not a hole.')
84
- body.push('')
85
- for (const r of undeclared) body.push(`- \`${cell(r.decl.id)}\` (${r.decl.kind})${r.owner == null ? '' : ` — \`${cell(r.owner)}\``}`)
86
- body.push('')
87
- }
88
- if (decided.length > 0) {
89
- body.push(`## ${decided.length} unit(s) decided uncacheable`)
90
- body.push('')
91
- for (const r of decided) body.push(`- \`${cell(r.decl.id)}\` — ${cell(r.decl.never ?? '')}`)
92
- body.push('')
93
- }
94
-
95
- return body.join('\n')
96
- }
97
-
98
- /** refresh the sidecar beside STATUS.md. Lock-guarded exactly like STATUS.md:
99
- * several runners in one checkout is the normal case, and the loser skipping
100
- * a render it would have written identically costs nothing. */
101
- export function writeUnitTable(root: string, input: UnitTableInput): { written: boolean; warn?: string } {
102
- const target = join(root, UNIT_TABLE_FILE)
103
- const r = withFileLock(target, () => { atomicWrite(target, `${renderUnitTable(input).trimEnd()}\n`) })
104
- return r.ok ? { written: true } : { written: false, warn: `${UNIT_TABLE_FILE} locked by another runner — skipped this write` }
105
- }
@@ -1,24 +0,0 @@
1
- // SK002 — .rv-* 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` task) tags with this id via
7
- // contracts/apply-contracts.ts.
8
- import { activeContracts, applyContractsResult, contractsConfigFrom } from '../contracts/apply-contracts.ts'
9
- import { evaluateContractsOnce } from '../contracts/contracts-once.ts'
10
- import type { Rule } from './rule.ts'
11
-
12
- export const contractsFulfillness: Rule = {
13
- id: 'SK002',
14
- blocking: true,
15
- title: 'contracts',
16
- owner: 'meta/contracts.md',
17
- // the contract engine is pure filesystem (no fetch, no spawn anywhere in
18
- // contracts/), and WHICH folders it reads depends on the venues declared,
19
- // so the honest input is the work tree
20
- inputs: ['.'],
21
- lint: async (t, env) => {
22
- applyContractsResult(t, await evaluateContractsOnce(activeContracts(env.config), contractsConfigFrom(env.config, env.root)), contractsFulfillness)
23
- },
24
- }
@@ -1,70 +0,0 @@
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 lib stays generic: no personal include path is asserted — `shipkit
7
- // start --include` flags own which base include a machine wires in. A repo
8
- // with neither CLAUDE.md nor agent/ is not a two-line-contract repo and
9
- // passes.
10
- import { existsSync, readFileSync } from 'fs'
11
- import { join } from 'path'
12
- import { spawnWithTimeout } from '../core/spawn-timeout.ts'
13
- import type { FixResult, Rule, RuleEnv } from './rule.ts'
14
- import { atomicWrite } from '../core/locked-write.ts'
15
-
16
- const AGENT_LINE = '@./agent/index.md'
17
- const LOCAL_LINE = '@./CLAUDE.local.md'
18
-
19
- export const claudeMdShape: Rule = {
20
- id: 'SK003',
21
- blocking: true,
22
- title: 'CLAUDE.md',
23
- owner: 'meta/agent-folder.md',
24
- // CLAUDE.md plus whether agent/index.md exists — and a repo that has
25
- // neither is the case this rule exists for, so the tree rather than two
26
- // paths that may not be there
27
- inputs: ['.'],
28
- lint: async (t, env) => {
29
- const claudePath = join(env.root, 'CLAUDE.md')
30
- const hasAgent = existsSync(join(env.root, 'agent/index.md'))
31
- if (!existsSync(claudePath)) {
32
- if (hasAgent) t.fail(`agent/index.md exists but there is no CLAUDE.md routing it — the two-line contract wants ${AGENT_LINE}`)
33
- else t.ok('no CLAUDE.md / agent — not a two-line-contract repo')
34
- return
35
- }
36
- const lines = readFileSync(claudePath, 'utf8').split('\n').filter((l) => l.trim() !== '')
37
- const includes = lines.filter((l) => l.startsWith('@'))
38
- const prose = lines.length - includes.length
39
- if (prose > 0)
40
- t.fail(`CLAUDE.md carries ${prose} non-include line(s) — it is a tiny include index, content belongs in agent/index.md`)
41
- if (includes.length > 2)
42
- t.fail(`CLAUDE.md has ${includes.length} include lines — the contract is 2: base context, then ${AGENT_LINE}`)
43
- if (hasAgent && !lines.includes(AGENT_LINE))
44
- t.fail(`agent/index.md exists but CLAUDE.md does not import it — add ${AGENT_LINE} as the LAST line`)
45
- if (lines.includes(AGENT_LINE) && lines.at(-1) !== AGENT_LINE)
46
- t.fail(`include order inverted — base context first, ${AGENT_LINE} LAST (order is semantic: repo docs override the base)`)
47
- if (lines.includes(LOCAL_LINE)) {
48
- const r = await spawnWithTimeout(['git', 'check-ignore', '-q', 'CLAUDE.local.md'], { cwd: env.root, timeoutMs: 5_000 })
49
- if (r.code === 1)
50
- t.fail(`${LOCAL_LINE} is imported but CLAUDE.local.md is not gitignored — the machine-local include must never be tracked`)
51
- else if (r.code !== 0)
52
- t.note(`CLAUDE.local.md gitignore not verifiable (git says: ${r.out.trim().split('\n')[0] ?? `exit ${r.code}`})`)
53
- }
54
- if (!t.failed) t.ok(`CLAUDE.md include-index ok (${lines.length} line(s))`)
55
- },
56
- // the one mechanical repair: the missing agent/index.md route, appended
57
- // to an EXISTING CLAUDE.md. Never creates the file — a created one-line
58
- // CLAUDE.md would lint green with the base include forever missing,
59
- // trading a loud FAIL for a permanent silent gap. Creation and prose
60
- // relocation stay human/machine-local (`shipkit start --include`).
61
- fix: (env: RuleEnv): FixResult => {
62
- const claudePath = join(env.root, 'CLAUDE.md')
63
- if (!existsSync(join(env.root, 'agent/index.md'))) return { changed: false, summary: 'no agent/index.md — nothing mechanical to fix' }
64
- if (!existsSync(claudePath)) return { changed: false, summary: 'CLAUDE.md missing — creation needs the base include (shipkit start --include), not autofixable' }
65
- const cur = readFileSync(claudePath, 'utf8')
66
- if (cur.split('\n').includes(AGENT_LINE)) return { changed: false, summary: `${AGENT_LINE} already routed — remaining findings need a human` }
67
- atomicWrite(claudePath, `${cur.endsWith('\n') || cur === '' ? cur : `${cur}\n`}${AGENT_LINE}\n`)
68
- return { changed: true, summary: `${AGENT_LINE} appended as the last line` }
69
- },
70
- }
@@ -1,238 +0,0 @@
1
- // SK004 — the .gitignore policy block (meta/contracts.md git policy; tmp/
2
- // included because shipkit writes its per-run logs there in every repo). A marker-keyed managed block so the always-gitignored contract
3
- // surfaces can never silently rot per repo. Ecosystem-specific names the
4
- // published lib may not carry ride as repo-contributed lines:
5
- // `defineRepo({ gitignore: [...] })` renders them INSIDE the block, so the
6
- // whole policy is machine-enforced. A well-formed block is rewritten IN
7
- // PLACE — lines below it (e.g. a live venue's `!.rv-itch/` override, where
8
- // last-match-wins) keep their position; everything outside the block is
9
- // never touched. `shipkit init` writes the same block through this fixer.
10
- import { execFileSync } from 'child_process'
11
- import { existsSync, readFileSync } from 'fs'
12
- import { join } from 'path'
13
- import type { RepoConfig } from '../repo-config.ts'
14
- import { GENERATED_IGNORE_LINES, trackedGeneratedPaths, untrackGenerated } from './untrack-generated.ts'
15
- import type { FixResult, Rule, RuleEnv } from './rule.ts'
16
- import { atomicWrite } from '../core/locked-write.ts'
17
-
18
- const BEGIN = '# >>> shipkit gitignore policy (SK004) — managed block, edit outside only'
19
- const END = '# <<< shipkit gitignore policy'
20
- const CONTRIB = '# repo-contributed lines (repo.config.ts gitignore:)'
21
-
22
- export const POLICY_LINES = [
23
- // the leak guard reads .rv-private/banned-keywords.txt: the file listing
24
- // what must never be committed cannot itself be committable
25
- '.rv-private/',
26
- '.rv-reflections/',
27
- '.rv-social/',
28
- '.rv-journal/',
29
- '.rv-domain/',
30
- '.rv-assets/out/',
31
- '# venue folders stay ignored until the listing is live — override below the block',
32
- '.rv-itch/',
33
- '.rv-steam/',
34
- '.rv-discord/',
35
- 'CLAUDE.local.md',
36
- 'tmp/',
37
- // atomicWrite lands every file via `.<name>.tmp-<pid>` next to its target,
38
- // so a run killed mid write leaves one behind ANYWHERE in the tree, and an
39
- // agent finds untracked debris it did not create. The ignore row ships in
40
- // the same policy that started writing them.
41
- '.*.tmp-*',
42
- // comfy-ts state (schema dumps, drafts) and per-workflow lora tags name a
43
- // private model collection; shipkit itself writes .comfy-ts/ at hub boot,
44
- // so every consumer repo needs both ignored before its first commit
45
- '.comfy-ts/',
46
- '*.loras.local.txt',
47
- // shipkit's own per-run output. Ignoring is only half of it: the fixer
48
- // also removes these from the index, because .gitignore does nothing to a
49
- // file already tracked (untrack-generated.ts owns the pair)
50
- ...GENERATED_IGNORE_LINES,
51
- ]
52
-
53
- export function expectedBlock(config: RepoConfig): string {
54
- const extra = config.gitignore ?? []
55
- return [BEGIN, ...POLICY_LINES, ...(extra.length > 0 ? [CONTRIB, ...extra] : []), END].join('\n')
56
- }
57
-
58
- export function policyPatterns(config: RepoConfig): string[] {
59
- return [...POLICY_LINES.filter((l) => !l.startsWith('#')), ...(config.gitignore ?? [])]
60
- }
61
-
62
- // the three venue folders are DESIGNED to be unignored below the block once a
63
- // listing is live (last-match-wins, the comment inside POLICY_LINES says so),
64
- // so an override on one of them is the mechanism working. Every other policy
65
- // path is a claim the block makes, and a claim git contradicts is a bug.
66
- function endsWithNewline(root: string): boolean {
67
- const p = join(root, '.gitignore')
68
- if (!existsSync(p)) return true
69
- const text = readFileSync(p, 'utf8')
70
- return text === '' || text.endsWith('\n')
71
- }
72
-
73
- const OVERRIDABLE = new Set(['.rv-itch/', '.rv-steam/', '.rv-discord/'])
74
-
75
- /** a path git can be asked about, for a pattern that may be a glob or a dir */
76
- function probePath(pattern: string): string {
77
- if (pattern.endsWith('/')) return `${pattern}probe`
78
- // `.*.tmp-*` and `*.loras.local.txt`: a literal standing in for the glob
79
- return pattern.includes('*') ? pattern.replace(/\*/g, 'probe') : pattern
80
- }
81
-
82
- /** which of `paths` git does NOT ignore. null when this is not a git repo, so
83
- * a rule can say it did not measure rather than report a green it invented */
84
- export function notIgnored(root: string, paths: string[]): string[] | null {
85
- if (paths.length === 0) return []
86
- try {
87
- // --stdin -z -n: one call, every path, and `-n` makes git print a row
88
- // for the MISSES too, which is the answer we want
89
- const out = execFileSync('git', ['check-ignore', '--stdin', '-z', '-n', '-v'], {
90
- cwd: root, input: paths.join('\0'), encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
91
- })
92
- // -v -z rows are source\0line\0pattern\0path; an unignored path has an
93
- // empty source and an empty pattern
94
- const f = out.split('\0')
95
- const miss: string[] = []
96
- for (let i = 0; i + 3 < f.length + 1; i += 4) {
97
- if (f[i] === '' && f[i + 2] === '' && f[i + 3] != null && f[i + 3] !== '') miss.push(f[i + 3]!)
98
- }
99
- return miss
100
- } catch (e) {
101
- // exit 1 means "none of them are ignored", which is an ANSWER, and git
102
- // still wrote the rows we parse. Anything else (no repo, no git) is not
103
- const err = e as { status?: number; stdout?: string }
104
- if (err.status === 1 && typeof err.stdout === 'string') {
105
- const f = err.stdout.split('\0')
106
- const miss: string[] = []
107
- for (let i = 0; i + 3 < f.length + 1; i += 4) {
108
- if (f[i] === '' && f[i + 2] === '' && f[i + 3] != null && f[i + 3] !== '') miss.push(f[i + 3]!)
109
- }
110
- return miss
111
- }
112
- return null
113
- }
114
- }
115
-
116
- type Parsed = { text: string; crlf: boolean; lines: string[]; b: number; e: number }
117
-
118
- function parse(root: string): Parsed | null {
119
- const p = join(root, '.gitignore')
120
- if (!existsSync(p)) return null
121
- const text = readFileSync(p, 'utf8')
122
- const crlf = text.includes('\r\n')
123
- const lines = text.split('\n').map((l) => l.replace(/\r$/, ''))
124
- return { text, crlf, lines, b: lines.indexOf(BEGIN), e: lines.indexOf(END) }
125
- }
126
-
127
- type BlockState = 'missing-file' | 'missing-block' | 'corrupted' | 'stale' | 'current'
128
-
129
- function blockState(root: string, config: RepoConfig): BlockState {
130
- const f = parse(root)
131
- if (f == null) return 'missing-file'
132
- if (f.b === -1 && f.e === -1) return 'missing-block'
133
- if (f.b === -1 || f.e === -1 || f.e < f.b) return 'corrupted'
134
- return f.lines.slice(f.b, f.e + 1).join('\n') === expectedBlock(config) ? 'current' : 'stale'
135
- }
136
-
137
- function writeBlock(root: string, config: RuleEnv['config']): void {
138
- const p = join(root, '.gitignore')
139
- const f = parse(root)
140
- const block = expectedBlock(config).split('\n')
141
- let out: string[]
142
- if (f == null) out = block
143
- else if (f.b !== -1 && f.e !== -1 && f.e >= f.b)
144
- // well-formed block: rewrite IN PLACE so below-block overrides
145
- // (last-match-wins) keep working
146
- out = [...f.lines.slice(0, f.b), ...block, ...f.lines.slice(f.e + 1)]
147
- else {
148
- // absent or corrupted: drop stray markers, keep every repo line
149
- // visible, append the canonical block
150
- const kept = (f.b === -1 && f.e === -1 ? f.lines : f.lines.filter((l) => l !== BEGIN && l !== END))
151
- while (kept.length > 0 && kept.at(-1)!.trim() === '') kept.pop()
152
- out = kept.length > 0 ? [...kept, '', ...block] : block
153
- }
154
- const eol = f?.crlf === true ? '\r\n' : '\n'
155
- atomicWrite(p, `${out.join(eol)}${out.at(-1) === '' ? '' : eol}`)
156
- }
157
-
158
- export const gitignorePolicy: Rule = {
159
- id: 'SK004',
160
- blocking: true,
161
- title: '.gitignore',
162
- owner: 'meta/contracts.md',
163
- // .gitignore plus what git currently TRACKS, which the tree carries and a
164
- // single path does not
165
- inputs: ['.'],
166
- lint: (t, env) => {
167
- // a generated path still in the index is the half-applied state the
168
- // managed block cannot fix on its own
169
- const tracked = trackedGeneratedPaths(env.root)
170
- if (tracked.length > 0) {
171
- t.fail(`${tracked.join(', ')} generated on every run but TRACKED — gitignore does not apply to a tracked file, so it dirties every tree`)
172
- t.lines.push({ kind: 'fix-fail', text: 'shipkit ci (or check --fix SK004) untracks it in one commit, the file stays on disk' })
173
- }
174
- const s = blockState(env.root, env.config)
175
- switch (s) {
176
- case 'missing-file': return t.fail('.gitignore missing — the contract git policy is unenforced')
177
- case 'missing-block': return t.fail('.gitignore has no shipkit policy block — contract surfaces can leak into git')
178
- case 'corrupted': return t.fail('.gitignore policy block corrupted (unpaired marker)')
179
- case 'stale': return t.fail('.gitignore policy block is stale — the policy or repo-contributed lines moved since it was written')
180
- }
181
- // an unterminated last line is a TRAP, not a cosmetic defect: the next
182
- // `>> .gitignore` fuses onto it and the result is one pattern that
183
- // ignores neither path, with the file ending in a newline afterwards
184
- // so nothing textual looks wrong
185
- if (!endsWithNewline(env.root)) {
186
- t.fail('.gitignore does not end with a newline — the next append fuses onto its last line and ignores neither path')
187
- t.lines.push({ kind: 'fix-fail', text: 'shipkit ci (or check --fix SK004) terminates it' })
188
- return
189
- }
190
- // the block states what must be ignored. git is the only thing that
191
- // knows whether it IS: a stray negation below the block, or a line
192
- // fused into another, leaves the text current over a broken repo
193
- const claimed = policyPatterns(env.config).filter((p) => !OVERRIDABLE.has(p))
194
- const probes = new Map(claimed.map((p) => [probePath(p), p]))
195
- const miss = notIgnored(env.root, [...probes.keys()])
196
- if (miss == null) {
197
- // not a git repo (a fixture, or a dir before `git init`). Say the
198
- // verification did not run rather than reporting a green nothing
199
- // measured, and stay quiet: there is nothing wrong here to fix
200
- t.ok(`.gitignore policy block current (${claimed.length} patterns)`)
201
- t.lines.push({ kind: 'skip', text: 'not a git repo here, so nothing verified the block against what git actually ignores' })
202
- return
203
- }
204
- if (miss.length > 0) {
205
- const names = [...new Set(miss.map((m) => probes.get(m) ?? m))]
206
- t.fail(`.gitignore block claims ${names.join(', ')} ignored, git says otherwise — a negation below the block, or a line fused into its neighbour`)
207
- t.lines.push({ kind: 'fix-fail', text: `git check-ignore -v ${miss[0]} names the line that wins` })
208
- return
209
- }
210
- t.ok(`.gitignore policy block current, ${claimed.length} patterns verified against git`)
211
- },
212
- fix: (env: RuleEnv): FixResult => {
213
- const before = blockState(env.root, env.config)
214
- // terminate an unterminated file even when the block itself is current:
215
- // that missing byte is what the next append fuses onto
216
- let terminated: string | null = null
217
- if (before === 'current' && !endsWithNewline(env.root)) {
218
- const p = join(env.root, '.gitignore')
219
- atomicWrite(p, `${readFileSync(p, 'utf8')}\n`)
220
- terminated = '.gitignore terminated with a newline (the next append would have fused onto its last line)'
221
- }
222
- const blockSummary = before === 'current'
223
- ? null
224
- : before === 'missing-file' ? '.gitignore created with the policy block'
225
- : before === 'missing-block' ? 'policy block appended (repo lines untouched)'
226
- : before === 'corrupted' ? 'policy block rebuilt (stray markers dropped, repo lines kept visible)'
227
- : 'policy block rewritten in place (repo lines untouched)'
228
- if (blockSummary != null) writeBlock(env.root, env.config)
229
- // the block must be written FIRST: untracking a path the ignore list
230
- // does not yet carry leaves it staged for re-add on the next `git add`
231
- const un = untrackGenerated(env.root, { enabled: env.config.untrackGenerated })
232
- const parts = [blockSummary, terminated]
233
- if (un.did === 'untracked') parts.push(`untracked ${un.paths.join(', ')} (generated, now gitignored, committed as ${un.commit})`)
234
- if (un.did === 'refused') parts.push(`left ${trackedGeneratedPaths(env.root).join(', ')} tracked: ${un.why}`)
235
- const summary = parts.filter((x): x is string => x != null).join(' · ')
236
- return { changed: blockSummary != null || terminated != null || un.did === 'untracked', summary: summary === '' ? 'policy block already current' : summary }
237
- },
238
- }
@@ -1,127 +0,0 @@
1
- // SK010 — undeclared release unit (build/shipkit.md). Release units are
2
- // DECLARED, never inferred, so resolution is deterministic. This rule is the
3
- // other half of that deal: a package.json sitting in the repo that no unit
4
- // claims would be invisible to every release surface, and its version would
5
- // drift forever with nothing reporting it. Two declarations clear it, so the
6
- // green is always reachable: `units: [{ dir }]` for a releasable package,
7
- // `notReleasable: [dir]` for one that ships inside something else. That second
8
- // escape has teeth on purpose, because it is the one an agent staring at a red
9
- // gate reaches for: an entry must cover at least one real package and never the
10
- // whole repo, and the green counts the PACKAGES it excused, so a wide entry
11
- // cannot read as a narrow one.
12
- import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
13
- import { join, relative } from 'path'
14
- import { resolveUnits } from '../release/units.ts'
15
- import type { Rule } from './rule.ts'
16
-
17
- const SKIP = new Set(['node_modules', 'references', 'tmp', 'stats', 'dist', 'build'])
18
-
19
- /** package dirs git knows about (tracked + untracked-not-ignored), so a
20
- * gitignored vendor tree or a python venv never trips the gate. Null when
21
- * git cannot answer, and the caller falls back to walking. */
22
- function gitPackageDirs(root: string): string[] | null {
23
- const p = Bun.spawnSync(['git', 'ls-files', '--cached', '--others', '--exclude-standard', '-z'], { cwd: root })
24
- if (p.exitCode !== 0) return null
25
- return p.stdout.toString().split('\0')
26
- .filter((f) => f === 'package.json' || f.endsWith('/package.json'))
27
- .map((f) => join(root, f, '..'))
28
- }
29
-
30
- /** the fs fallback: an unreadable entry (a dangling symlink, an unmounted
31
- * volume) is skipped, never thrown — a broken link is not a release fact */
32
- function walkPackageDirs(root: string): string[] {
33
- const out: string[] = [root]
34
- const walk = (dir: string): void => {
35
- let entries: string[]
36
- try { entries = readdirSync(dir) } catch { return }
37
- for (const e of entries) {
38
- if (SKIP.has(e) || e.startsWith('.')) continue
39
- const p = join(dir, e)
40
- try { if (!statSync(p).isDirectory()) continue } catch { continue }
41
- if (existsSync(join(p, 'package.json'))) out.push(p)
42
- walk(p)
43
- }
44
- }
45
- walk(root)
46
- return out
47
- }
48
-
49
- /** every package.json below the root that carries a name, root excluded */
50
- export function packageDirs(root: string): string[] {
51
- const dirs = gitPackageDirs(root) ?? walkPackageDirs(root)
52
- const named = dirs.filter((d) => {
53
- if (d === root) return false
54
- if (relative(root, d).split('/').some((seg) => SKIP.has(seg) || seg.startsWith('.'))) return false
55
- try {
56
- const pkg = JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')) as { name?: string }
57
- return typeof pkg.name === 'string' && pkg.name !== ''
58
- } catch { return false } // a broken package.json is SK001's story, not this rule's
59
- })
60
- return [...new Set(named)].sort()
61
- }
62
-
63
- export const undeclaredReleaseUnit: Rule = {
64
- id: 'SK010',
65
- blocking: true,
66
- title: 'release units declared',
67
- owner: 'build/shipkit.md',
68
- // `git ls-files` plus a walk for every package.json in the repo
69
- inputs: ['.'],
70
- lint: (t, env) => {
71
- const { units, errors } = resolveUnits(env.root, env.config)
72
- // a DECLARED unit that does not resolve is a declaration bug and
73
- // belongs here. A root package that is not release-shaped (no version
74
- // yet) is not: the release path refuses on its own, the day it matters.
75
- const declared = errors.filter((e) => e.id !== 'root')
76
- if (declared.length > 0) {
77
- for (const e of declared) t.fail(`declared unit '${e.id}' does not resolve: ${e.message}`, 'repo.config units[]')
78
- return
79
- }
80
- // an entry is a PREFIX over the packages under it, so `examples/` can
81
- // excuse examples/a and examples/b without enumerating them. Two shapes
82
- // are refused, both being the kill switch this rule exists to prevent:
83
- // an entry covering the whole repo, and one covering nothing today that
84
- // stands ready to excuse whatever lands there tomorrow. defineRepo
85
- // refuses the root too, and a repo.config.ts that never calls it is
86
- // exactly why the check also lives in the blocking rule.
87
- const decls = env.config.notReleasable ?? []
88
- const covered = (list: string[], d: string): boolean => list.some((c) => d === c || d.startsWith(`${c}/`))
89
- const pkgs = packageDirs(env.root)
90
- const wide = decls.filter((d) => join(env.root, d) === env.root)
91
- if (wide.length > 0) {
92
- for (const d of wide)
93
- t.fail(`notReleasable '${d}' resolves to the repo root, so it excuses every package at once`, `repo.config notReleasable: name one package dir (or one container dir) per entry`)
94
- return
95
- }
96
- const empty = decls.filter((d) => !pkgs.some((p) => covered([join(env.root, d)], p)))
97
- if (empty.length > 0) {
98
- for (const d of empty)
99
- t.fail(`notReleasable '${d}' covers no package.json, so it excuses no package today and every package that lands there tomorrow`, `repo.config notReleasable: point it at a package dir (or a dir holding some), or drop the entry`)
100
- return
101
- }
102
- const excused = decls.map((d) => join(env.root, d))
103
- const declaredDirs = units.filter((u) => u.rel !== '').map((u) => u.dir)
104
- // an excuse that also covers a RELEASE UNIT is the kill switch one
105
- // level down: `notReleasable: ['libs']` beside `units: [libs/a]` reads
106
- // as green while every new package under libs/ hides. The two lists
107
- // describe opposite intents, so they must not overlap.
108
- const overlapping = decls.filter((d) => declaredDirs.some((u) => u === join(env.root, d) || u.startsWith(`${join(env.root, d)}/`)))
109
- if (overlapping.length > 0) {
110
- for (const d of overlapping)
111
- t.fail(`notReleasable '${d}' also covers a declared release unit, so it excuses every package under it while reading as green`, `repo.config notReleasable: name the dirs that are NOT release units, never a container holding one`)
112
- return
113
- }
114
- const claimed = [...declaredDirs, ...excused]
115
- const orphans = pkgs.filter((d) => !covered(claimed, d))
116
- if (orphans.length === 0) {
117
- // PACKAGES, not entries: one entry over a nested tree excuses more
118
- // than one, and the count is the only place that shows it
119
- const n = pkgs.filter((d) => covered(excused, d)).length
120
- return t.ok(`${units.length} release unit(s) declared${units.length === 1 ? ' (the repo root)' : ''}${n > 0 ? ` + ${n} package(s) declared not releasable` : ''} — no package hides from a release`)
121
- }
122
- for (const d of orphans) {
123
- const rel = relative(env.root, d)
124
- t.fail(`${rel}/package.json is claimed by nothing — declare what it is, or its version drifts with nothing watching`, `repo.config units: [{ dir: '${rel}' }] if it releases, else notReleasable: ['${rel}']`)
125
- }
126
- },
127
- }
@@ -1,64 +0,0 @@
1
- // THE rule registry — single source of every SK rule. `shipkit check` runs
2
- // them, `shipkit rules` lists them, fix pointers and wiki pages name their
3
- // ids. A duplicate or malformed id throws at import time: every surface
4
- // that loads rules dies loud rather than run with an ambiguous registry.
5
- import type { Rule } from './rule.ts'
6
- import { packageJsonShape } from './SK001-package-json-shape.ts'
7
- import { contractsFulfillness } from './SK002-contracts-fulfillness.ts'
8
- import { claudeMdShape } from './SK003-claude-md-shape.ts'
9
- import { gitignorePolicy } from './SK004-gitignore-policy.ts'
10
- import { rvlibVersions } from './SK005-rvlib-versions.ts'
11
- import { npmPublishSafety } from './SK006-npm-publish-safety.ts'
12
- import { githubVisibility } from './SK007-github-visibility.ts'
13
- import { reviewerAgent } from './SK008-reviewer-agent.ts'
14
- import { commitAiTells } from './SK009-commit-ai-tells.ts'
15
- import { undeclaredReleaseUnit } from './SK010-undeclared-release-unit.ts'
16
- import { siblingUnitRanges } from './SK011-sibling-unit-ranges.ts'
17
- import { configBarrelImport } from './SK012-config-barrel-import.ts'
18
- import { oneWayInPolicy } from './SK013-one-way-in-policy.ts'
19
- import { proseSte } from './SK014-prose-ste.ts'
20
- import { nestedRepo } from './SK015-nested-repo.ts'
21
-
22
- export const RULES: readonly Rule[] = [
23
- packageJsonShape,
24
- contractsFulfillness,
25
- claudeMdShape,
26
- gitignorePolicy,
27
- rvlibVersions,
28
- npmPublishSafety,
29
- githubVisibility,
30
- reviewerAgent,
31
- commitAiTells,
32
- undeclaredReleaseUnit,
33
- siblingUnitRanges,
34
- configBarrelImport,
35
- oneWayInPolicy,
36
- proseSte,
37
- nestedRepo,
38
- ]
39
-
40
- // THE id shape, one place: 2-4 uppercase letters + exactly 3 digits. `SK` is
41
- // reserved for the built-ins above so a repo rule can never shadow one.
42
- export const RULE_ID_RE = /^[A-Z]{2,4}\d{3}$/
43
-
44
- /** validate a rule set and its ids against everything already registered.
45
- * Throws on a malformed id, a duplicate, a missing owner page, or a repo
46
- * rule squatting the reserved SK prefix. Shared by the built-in registry
47
- * and by `defineRepo`, so both die loud at import time. */
48
- export function validateRules(rules: readonly Rule[], o: { label: string; builtin?: boolean; taken?: Iterable<string> } = { label: 'rule-registry' }): void {
49
- const seen = new Set<string>(o.taken ?? [])
50
- for (const r of rules) {
51
- if (!RULE_ID_RE.test(r.id)) throw new Error(`${o.label}: bad id '${r.id}' — rules are PREFIX+3 digits (e.g. SK001, RV001)`)
52
- if (o.builtin !== true && r.id.startsWith('SK'))
53
- throw new Error(`${o.label}: id '${r.id}' uses the reserved SK prefix — repo rules pick their own (e.g. RV${r.id.slice(-3)})`)
54
- if (seen.has(r.id)) throw new Error(`${o.label}: duplicate id ${r.id}`)
55
- if (r.owner === '') throw new Error(`${o.label}: ${r.id} has no owner page`)
56
- seen.add(r.id)
57
- }
58
- }
59
-
60
- validateRules(RULES, { label: 'rule-registry', builtin: true })
61
-
62
- export const BUILTIN_RULE_IDS: readonly string[] = RULES.map((r) => r.id)
63
-
64
- export const ruleById = (id: string): Rule | undefined => RULES.find((r) => r.id === id)