rvlib-shipkit 0.38.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. package/CHANGELOG.md +379 -16
  2. package/README.md +26 -14
  3. package/package.json +2 -2
  4. package/src/assets/gen/comfyts-gen.ts +27 -10
  5. package/src/assets/gen/derived.ts +1 -1
  6. package/src/assets/gen/generate-roll.ts +1 -1
  7. package/src/assets/kit-config.ts +6 -6
  8. package/src/assets/run-assets.ts +14 -10
  9. package/src/assets/server/studio-server.ts +6 -5
  10. package/src/check.ts +343 -0
  11. package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
  12. package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
  13. package/src/checks/SK003-claude-md-shape.check.ts +66 -0
  14. package/src/checks/SK004-gitignore-policy.check.ts +309 -0
  15. package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +49 -32
  16. package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
  17. package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
  18. package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
  19. package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
  20. package/src/checks/SK010-undeclared-unit.check.ts +111 -0
  21. package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
  22. package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
  23. package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
  24. package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
  25. package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
  26. package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
  27. package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
  28. package/src/checks/SK018-test-files-run.check.ts +28 -0
  29. package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
  30. package/src/checks/SK020-self-spawning-check.check.ts +147 -0
  31. package/src/checks/contracts.check.ts +20 -0
  32. package/src/cli/cmd_Asks.ts +3 -2
  33. package/src/cli/cmd_Assets.ts +2 -2
  34. package/src/cli/cmd_Bump.ts +1 -1
  35. package/src/cli/cmd_Check.ts +6 -6
  36. package/src/cli/cmd_Checks.ts +95 -0
  37. package/src/cli/cmd_Ci.ts +48 -13
  38. package/src/cli/cmd_Commands.ts +2 -6
  39. package/src/cli/cmd_ConfigKeys.ts +69 -0
  40. package/src/cli/cmd_Contracts.ts +1 -1
  41. package/src/cli/cmd_Fleet.ts +16 -3
  42. package/src/cli/cmd_Hooks.ts +1 -1
  43. package/src/cli/cmd_Keys.ts +43 -18
  44. package/src/cli/cmd_Mv.ts +2 -2
  45. package/src/cli/cmd_Prose.ts +10 -1
  46. package/src/cli/cmd_Releases.ts +2 -2
  47. package/src/cli/cmd_Serve.ts +1 -1
  48. package/src/cli/cmd_Start.ts +6 -6
  49. package/src/cli/cmd_Status.ts +25 -0
  50. package/src/cli/cmd_Tui.ts +2 -2
  51. package/src/cli.ts +7 -4
  52. package/src/collections/actions.ts +4 -4
  53. package/src/collections/checks.ts +257 -0
  54. package/src/collections/collection-types.ts +254 -1
  55. package/src/collections/comfy.ts +23 -0
  56. package/src/collections/md-body.ts +14 -0
  57. package/src/collections/pings.ts +38 -12
  58. package/src/collections/posts.ts +19 -3
  59. package/src/collections/providers.ts +198 -91
  60. package/src/collections/reflections.ts +41 -0
  61. package/src/collections/state-rules.ts +73 -0
  62. package/src/collections/test-time.ts +288 -0
  63. package/src/collections/tests.ts +181 -0
  64. package/src/collections/wikis.ts +66 -0
  65. package/src/comfy/comfy-panel.ts +292 -0
  66. package/src/contracts/apply-contracts.ts +17 -12
  67. package/src/contracts/contract-types.ts +16 -4
  68. package/src/contracts/contracts-engine.ts +78 -42
  69. package/src/contracts/manifests/discord.contract.ts +6 -6
  70. package/src/contracts/manifests/identity.contract.ts +3 -3
  71. package/src/contracts/manifests/itch.contract.ts +12 -15
  72. package/src/contracts/manifests/social.contract.ts +5 -5
  73. package/src/contracts/manifests/steam.contract.ts +16 -16
  74. package/src/contracts/run-contracts.ts +4 -3
  75. package/src/core/attach.ts +96 -0
  76. package/src/core/canonical-scripts.ts +25 -0
  77. package/src/core/check-ctx.ts +204 -14
  78. package/src/core/check-file-names.ts +112 -0
  79. package/src/core/check-pass.ts +84 -0
  80. package/src/core/check-runner.ts +796 -0
  81. package/src/core/config-keys.ts +256 -0
  82. package/src/core/config-rewrite.ts +77 -0
  83. package/src/core/discover-checks.ts +166 -90
  84. package/src/core/discover-legacy-checks.ts +26 -0
  85. package/src/core/doc-block.ts +13 -0
  86. package/src/core/floor-resolve.ts +264 -0
  87. package/src/core/floor.ts +20 -137
  88. package/src/core/git-bin.ts +24 -0
  89. package/src/core/git-root.ts +108 -0
  90. package/src/core/instance.ts +195 -0
  91. package/src/core/jobs.ts +118 -0
  92. package/src/core/layout.ts +52 -0
  93. package/src/core/legacy-shapes.ts +207 -0
  94. package/src/core/lib-root.ts +5 -0
  95. package/src/core/locked-write.ts +110 -39
  96. package/src/core/machine-commit.ts +5 -4
  97. package/src/core/migrate-layout.ts +616 -0
  98. package/src/core/motherhome.ts +2 -8
  99. package/src/core/nesting.ts +4 -12
  100. package/src/core/one-way-in-policy.ts +73 -17
  101. package/src/core/outcome-words.ts +45 -0
  102. package/src/core/package-manager.ts +88 -0
  103. package/src/core/parallel.ts +21 -0
  104. package/src/core/process-tree.ts +44 -0
  105. package/src/core/raw-access.ts +93 -0
  106. package/src/core/read-set.ts +152 -0
  107. package/src/core/run-check.ts +155 -213
  108. package/src/core/run-ci.ts +152 -189
  109. package/src/core/run-fix.ts +99 -0
  110. package/src/core/run-fixers.ts +64 -33
  111. package/src/core/self-update.ts +11 -3
  112. package/src/core/slots.ts +83 -0
  113. package/src/core/spawn-timeout.ts +34 -3
  114. package/src/core/when-block.ts +401 -0
  115. package/src/core/when.ts +135 -0
  116. package/src/core/write-status.ts +5 -2
  117. package/src/fleet/scan-fleet.ts +16 -0
  118. package/src/hooks/leak-guard.ts +20 -9
  119. package/src/hooks/one-way-in-guard.ts +13 -5
  120. package/src/hooks/root-identity.ts +2 -1
  121. package/src/hooks/run-hooks.ts +38 -26
  122. package/src/init-repo.ts +39 -23
  123. package/src/ledger/check-stats.ts +155 -0
  124. package/src/ledger/check-table.ts +179 -0
  125. package/src/ledger/db.ts +255 -0
  126. package/src/ledger/gate-checks.ts +433 -0
  127. package/src/ledger/git-key.ts +116 -77
  128. package/src/ledger/git-scan.ts +96 -35
  129. package/src/ledger/ledger.ts +116 -42
  130. package/src/ledger/live-runs.ts +111 -0
  131. package/src/ledger/live-timeline.ts +97 -0
  132. package/src/ledger/repo-view.ts +73 -0
  133. package/src/ledger/run-log.ts +448 -0
  134. package/src/ledger/run-timeline.ts +173 -0
  135. package/src/ledger/store.ts +23 -12
  136. package/src/ledger/suite-log.ts +161 -0
  137. package/src/ledger/toolchain-identity.ts +7 -1
  138. package/src/ledger/world-cache.ts +111 -0
  139. package/src/mv/mv-md.ts +16 -6
  140. package/src/problems/extract.ts +182 -0
  141. package/src/problems/problem-board.ts +37 -0
  142. package/src/problems/problem-line.ts +77 -0
  143. package/src/prose/prose-view.ts +90 -0
  144. package/src/prose/run-prose.ts +60 -19
  145. package/src/prose/ste.ts +140 -24
  146. package/src/reflections/reflections-queue.ts +81 -0
  147. package/src/release/git-facts.ts +15 -9
  148. package/src/release/run-bump.ts +6 -5
  149. package/src/release/run-release.ts +25 -11
  150. package/src/release/run-releases.ts +2 -2
  151. package/src/release/unit-gate.ts +7 -54
  152. package/src/release/unit-resolve.ts +282 -0
  153. package/src/release/units.ts +33 -217
  154. package/src/repo-config.ts +157 -88
  155. package/src/rules/rule.ts +36 -96
  156. package/src/rules/untrack-generated.ts +5 -4
  157. package/src/scaffold/files/vscode-tasks.json +10 -31
  158. package/src/scaffold/jsonc-spans.ts +113 -0
  159. package/src/scaffold/project-scaffold.ts +16 -43
  160. package/src/scan.ts +98 -0
  161. package/src/serve/gate-trigger.ts +148 -0
  162. package/src/serve/health-schedule.ts +102 -0
  163. package/src/serve/hub-app/chart-view.ts +179 -0
  164. package/src/serve/hub-app/check-view.tsx +345 -0
  165. package/src/serve/hub-app/check-words.ts +164 -0
  166. package/src/serve/hub-app/checks-svc.ts +280 -0
  167. package/src/serve/hub-app/clock-svc.ts +33 -0
  168. package/src/serve/hub-app/col-card.tsx +23 -39
  169. package/src/serve/hub-app/cols-svc.ts +44 -20
  170. package/src/serve/hub-app/comfy-svc.ts +171 -0
  171. package/src/serve/hub-app/comfy-view.tsx +81 -0
  172. package/src/serve/hub-app/dur.ts +41 -0
  173. package/src/serve/hub-app/hash-route.ts +121 -0
  174. package/src/serve/hub-app/header-bar.tsx +117 -0
  175. package/src/serve/hub-app/health-svc.ts +23 -28
  176. package/src/serve/hub-app/help.tsx +29 -0
  177. package/src/serve/hub-app/hub-app.tsx +122 -119
  178. package/src/serve/hub-app/hub-st.ts +115 -42
  179. package/src/serve/hub-app/hub-types.ts +117 -2
  180. package/src/serve/hub-app/hub.css +534 -192
  181. package/src/serve/hub-app/index.html +2 -0
  182. package/src/serve/hub-app/live-svc.ts +174 -0
  183. package/src/serve/hub-app/md.ts +15 -0
  184. package/src/serve/hub-app/media-svc.ts +4 -1
  185. package/src/serve/hub-app/media-view.tsx +15 -0
  186. package/src/serve/hub-app/next-run.ts +68 -0
  187. package/src/serve/hub-app/project-gutter.css +65 -0
  188. package/src/serve/hub-app/project-gutter.tsx +128 -0
  189. package/src/serve/hub-app/projects-svc.ts +226 -0
  190. package/src/serve/hub-app/prose-hits-st.ts +124 -0
  191. package/src/serve/hub-app/prose-hits-view.tsx +176 -0
  192. package/src/serve/hub-app/prose-hits.css +21 -0
  193. package/src/serve/hub-app/run-strip.ts +47 -0
  194. package/src/serve/hub-app/runs-tabs.ts +24 -0
  195. package/src/serve/hub-app/test-view.tsx +107 -0
  196. package/src/serve/hub-app/tests-svc.ts +62 -0
  197. package/src/serve/hub-app/tests-view.tsx +238 -0
  198. package/src/serve/hub-app/time-ago.tsx +34 -0
  199. package/src/serve/hub-app/timeline-chart.tsx +274 -0
  200. package/src/serve/hub-app/timeline-geometry.ts +314 -0
  201. package/src/serve/hub-app/timeline-svc.ts +62 -0
  202. package/src/serve/hub-app/timeline-view.tsx +245 -0
  203. package/src/serve/hub-app/tree-build.ts +410 -0
  204. package/src/serve/hub-app/tree-row.tsx +42 -0
  205. package/src/serve/hub-app/tree-svc.ts +174 -0
  206. package/src/serve/hub-app/wikis-view.tsx +53 -0
  207. package/src/serve/other-repos.ts +79 -0
  208. package/src/serve/project-gutter-route.ts +350 -0
  209. package/src/serve/project-gutter.ts +449 -0
  210. package/src/serve/run-serve.ts +245 -16
  211. package/src/serve/same-origin.ts +14 -0
  212. package/src/serve/serve-hub.ts +213 -18
  213. package/src/shipkit.ts +19 -12
  214. package/src/smoke/run-smoke.ts +9 -4
  215. package/src/start-repo.ts +75 -36
  216. package/src/suite/judge-suites.ts +209 -15
  217. package/src/suite/kept-timings.ts +58 -0
  218. package/src/suite/runner-shapes.ts +88 -0
  219. package/src/suite/suite-cli.ts +147 -24
  220. package/src/suite/suite-cpu-preload.ts +92 -0
  221. package/src/suite/test-units.ts +243 -0
  222. package/src/ts/config.ts +4 -69
  223. package/src/ts/jsonc.ts +69 -0
  224. package/src/ts/lex.ts +143 -0
  225. package/src/ts/rename.ts +73 -10
  226. package/src/ts/source-graph.ts +28 -8
  227. package/src/ts/tsconfig-projects.ts +6 -97
  228. package/src/ts/tsconfig-resolve.ts +108 -0
  229. package/src/tui/build-steps-panel.ts +61 -0
  230. package/src/tui/checks-panel.ts +24 -16
  231. package/src/tui/run-tui.ts +2 -2
  232. package/src/tui/tui-panel.ts +5 -4
  233. package/src/wiki/graph-colors.ts +68 -0
  234. package/src/wiki/graph-mount.ts +7 -9
  235. package/src/wiki/graph-page.ts +42 -25
  236. package/src/wiki/graph-positions.ts +105 -0
  237. package/src/wiki/run-wiki.ts +1 -1
  238. package/src/wiki/scan-md.ts +1 -1
  239. package/src/wiki/serve-graph.ts +33 -3
  240. package/src/cli/cmd_Rules.ts +0 -51
  241. package/src/cli/cmd_Units.ts +0 -82
  242. package/src/collections/graphs.ts +0 -32
  243. package/src/contracts/contracts-once.ts +0 -43
  244. package/src/core/check-suite.ts +0 -302
  245. package/src/core/repair-contract-dirs.ts +0 -136
  246. package/src/core/task-runner.ts +0 -339
  247. package/src/ledger/gate-units.ts +0 -138
  248. package/src/ledger/unit-table.ts +0 -105
  249. package/src/rules/SK002-contracts-fulfillness.ts +0 -24
  250. package/src/rules/SK003-claude-md-shape.ts +0 -70
  251. package/src/rules/SK004-gitignore-policy.ts +0 -238
  252. package/src/rules/SK010-undeclared-release-unit.ts +0 -127
  253. package/src/rules/rule-registry.ts +0 -64
  254. package/src/rules/run-fix.ts +0 -72
  255. package/src/tui/tasks-panel.ts +0 -60
@@ -14,50 +14,56 @@
14
14
  // erroring, unreadable answer) is a loud skip — an UNKNOWN, never red,
15
15
  // never cached.
16
16
  import { carveGhJson, firstGhLine } from '../git-bootstrap.ts'
17
+ import { probeGitRoot } from '../core/git-root.ts'
17
18
  import { nestedIn } from '../core/nesting.ts'
18
- import type { Rule } from './rule.ts'
19
+ import { check } from '../check.ts'
19
20
 
20
- export const githubVisibility: Rule = {
21
+ export const githubVisibility = check({
21
22
  id: 'SK007',
22
23
  // blocking despite being measured over the network: the fact is about THIS
23
24
  // REPO (it is public while it declares itself private), and every
24
25
  // unverifiable path above is a skip, so an offline machine never goes red
25
26
  blocking: true,
26
27
  title: 'github visibility',
28
+ description: 'the github visibility of this repo matches the one it declares',
27
29
  owner: 'meta/contracts.md',
28
30
  ttl: '7d',
29
31
  cacheKey: (env) => env.config.visibility ?? 'private',
30
- lint: async (t, env) => {
31
- const declared = env.config.visibility ?? 'private'
32
- const skip = (why: string): void => void t.lines.push({ kind: 'skip', text: `github visibility unverified — ${why} (declared: ${declared})` })
32
+ run: async (c) => {
33
+ const declared = c.config.visibility ?? 'private'
34
+ const skip = (why: string): void => void c.lines.push({ kind: 'skip', text: `github visibility unverified — ${why} (declared: ${declared})` })
33
35
  const firstLine = firstGhLine
34
- const nested = nestedIn(env.root)
36
+ const repo = probeGitRoot(c.root)
37
+ if (repo.root == null) return skip(`git says: ${firstLine(repo.error)}`)
38
+ const nested = nestedIn(c.root)
35
39
  if (nested != null)
36
- return void t.lines.push({ kind: 'skip', text: `github visibility unverified, nested in ${nested}: the remote belongs to the parent repo (declared: ${declared})` })
40
+ return void c.lines.push({ kind: 'skip', text: `github visibility unverified, nested in ${nested}: the remote belongs to the parent repo (declared: ${declared})` })
37
41
 
38
- const remotes = await t.exec(['git', 'remote'], { cwd: env.root })
42
+ const remotes = await c.exec(['git', 'remote'], { cwd: c.root })
39
43
  if (remotes.code !== 0) return skip(`git says: ${firstLine(remotes.out) || `exit ${remotes.code}`}`)
40
44
  const names = remotes.out.trim().split('\n').filter((l) => l !== '')
41
45
  if (names.length === 0) return skip('no git remote')
42
46
  // prefer a `github`-named remote over `origin` when both exist
43
47
  const pick = ['github', 'origin'].find((n) => names.includes(n)) ?? names[0]!
44
- const url = await t.exec(['git', 'remote', 'get-url', pick], { cwd: env.root })
48
+ const url = await c.exec(['git', 'remote', 'get-url', pick], { cwd: c.root })
45
49
  if (url.code !== 0) return skip(`git says: ${firstLine(url.out)}`)
46
50
  const m = url.out.match(/github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\s*$/m)
47
51
  if (m == null) return skip(`remote '${pick}' is not github (${firstLine(url.out)})`)
48
52
  const target = m[1]!
49
53
 
50
- const r = await t.exec(['gh', 'repo', 'view', target, '--json', 'isPrivate,nameWithOwner'], { cwd: env.root, timeoutMs: 30_000 })
54
+ const r = await c.exec(['gh', 'repo', 'view', target, '--json', 'isPrivate,nameWithOwner'], { cwd: c.root, timeoutMs: 30_000 })
51
55
  if (r.code !== 0) return skip(`gh failed (${firstLine(r.out)})`)
52
56
  // an unreadable answer stays an UNKNOWN, never a FAIL
53
57
  const parsed = carveGhJson<{ isPrivate?: boolean; nameWithOwner?: string }>(r.out)
54
58
  if (parsed == null) return skip(`gh answer unreadable (${firstLine(r.out)})`)
55
59
  const actual = parsed.isPrivate === true ? 'private' : 'public'
56
60
  const who = parsed.nameWithOwner ?? target
57
- if (actual === declared) t.ok(`github ${who} is ${actual} — matches repo.config visibility`)
61
+ if (actual === declared) c.ok(`github ${who} is ${actual} — matches repo.config visibility`)
58
62
  else if (actual === 'public')
59
- t.fail(`github ${who} is PUBLIC but repo.config declares '${declared}' — the leak case; make it private again or run the pre-public gate and declare visibility: 'public'`)
63
+ c.fail(`github ${who} is PUBLIC but repo.config declares '${declared}' — the leak case; make it private again or run the pre-public gate and declare visibility: 'public'`)
60
64
  else
61
- t.fail(`github ${who} is private but repo.config declares 'public' — stale config lying to agents; fix the declaration`)
65
+ c.fail(`github ${who} is private but repo.config declares 'public' — stale config lying to agents; fix the declaration`)
62
66
  },
63
- }
67
+ })
68
+
69
+ export default githubVisibility
@@ -11,9 +11,14 @@ import { existsSync, mkdirSync, readFileSync } from 'fs'
11
11
  import { homedir } from 'os'
12
12
  import { dirname, isAbsolute, join } from 'path'
13
13
  import type { RepoConfig } from '../repo-config.ts'
14
- import type { FixResult, Rule, RuleEnv } from './rule.ts'
14
+ import { check, type FixResult, type CheckEnv } from '../check.ts'
15
+ import { LAYOUT } from '../core/layout.ts'
15
16
  import { atomicWrite } from '../core/locked-write.ts'
16
17
 
18
+ /** where a review lands when its caller named no output file:
19
+ * `YYYYMMDD-HHMMSS-<review-name>.md`, local time, gitignored */
20
+ export const REVIEWS_DIR = LAYOUT.reviews
21
+
17
22
  /** default read-only toolset — an ALLOWLIST, not a denylist: a tool that can
18
23
  * edit by proxy (spawning a subagent, an MCP writer) is exactly what a
19
24
  * denylist of today's edit-tool names would wave through */
@@ -60,6 +65,13 @@ purpose: your product is findings, never fixes. Bash is for READING the state
60
65
  of things (\`git diff\`, the gates, running a claim to check it) — never for
61
66
  writing files by another route.
62
67
 
68
+ ONE exception: your review itself. Unless the caller named an output file,
69
+ write it with one Bash heredoc to
70
+ \`${REVIEWS_DIR}/YYYYMMDD-HHMMSS-<review-name>.md\` (local time, 24h, a
71
+ kebab name for what you reviewed; \`mkdir -p\` first), then return the same
72
+ findings to the caller. The file is the copy that outlives the conversation,
73
+ gitignored.
74
+
63
75
  FIRST ACTION, always: read \`${reviewer.brief}\` and follow it exactly. It tells
64
76
  you what else to read, when to refuse the job, the order of the questions, and
65
77
  the output shape.
@@ -68,36 +80,39 @@ That brief overrides any habit of reviewing a diff for correctness alone.
68
80
  `
69
81
  }
70
82
 
71
- export const reviewerAgent: Rule = {
83
+ export const reviewerAgent = check({
72
84
  id: 'SK008',
73
85
  blocking: true,
74
86
  title: 'reviewer agent',
87
+ description: 'the declared reviewer agent has its brief, its definition and read only tools',
75
88
  owner: 'identity/how-we-work.md',
76
89
  // the brief is a CONFIG path, and a `~/...` or absolute one puts it
77
90
  // outside the repo, where no tree id can see whether it still exists
78
91
  never: 'the reviewer brief may live outside the repo, where no tree id reaches it',
79
- lint: (t, env) => {
80
- const r = env.config.reviewer
81
- if (r == null) return t.ok('no reviewer agent declared (repo.config reviewer:) — skipped')
92
+ run: (c) => {
93
+ const r = c.config.reviewer
94
+ if (r == null) return c.ok('no reviewer agent declared (repo.config reviewer:) — skipped')
82
95
  const rel = reviewerAgentPath(r.name)
83
- const p = join(env.root, rel)
84
- if (briefMissing(env.root, r.brief)) t.fail(`the declared brief '${r.brief}' does not exist — every review's mandated first action would fail`)
85
- if (!existsSync(p)) return t.fail(`${rel} missing — the declared reviewer type is unavailable, so any review runs on whatever framing the spawner improvised`)
96
+ const p = join(c.root, rel)
97
+ if (briefMissing(c.root, r.brief)) c.fail(`the declared brief '${r.brief}' does not exist — every review's mandated first action would fail`)
98
+ if (!existsSync(p)) return c.fail(`${rel} missing — the declared reviewer type is unavailable, so any review runs on whatever framing the spawner improvised`)
86
99
  const issues = reviewerAgentIssues(readFileSync(p, 'utf8'), r)
87
- for (const i of issues) t.fail(`${rel}: ${i}`)
88
- if (issues.length === 0 && !t.failed) t.ok(`${rel} → ${r.brief}`)
100
+ for (const i of issues) c.fail(`${rel}: ${i}`)
101
+ if (issues.length === 0 && !c.failed) c.ok(`${rel} → ${r.brief}`)
89
102
  },
90
103
  // create-if-missing only: an existing definition may carry a repo's own
91
104
  // tuned prompt, and silently rewriting that would cost more than the
92
105
  // finding it repairs. Violations in a present file stay a loud FAIL.
93
- fix: (env: RuleEnv): FixResult => {
94
- const r = env.config.reviewer
106
+ fix: (c): FixResult => {
107
+ const r = c.config.reviewer
95
108
  if (r == null) return { changed: false, summary: 'no reviewer declared — nothing to write' }
96
109
  const rel = reviewerAgentPath(r.name)
97
- const p = join(env.root, rel)
110
+ const p = join(c.root, rel)
98
111
  if (existsSync(p)) return { changed: false, summary: `${rel} exists — remaining findings are about its content, fix them by hand` }
99
112
  mkdirSync(dirname(p), { recursive: true })
100
113
  atomicWrite(p, canonicalAgent(r))
101
114
  return { changed: true, summary: `${rel} written — points at ${r.brief}, no edit tools` }
102
115
  },
103
- }
116
+ })
117
+
118
+ export default reviewerAgent
@@ -12,7 +12,7 @@ import { readFileSync } from 'fs'
12
12
  import { join } from 'path'
13
13
  import { hasAiAttribution, hasDashPunctuation } from '../hooks/commit-message.ts'
14
14
  import { nestedIn } from '../core/nesting.ts'
15
- import type { Rule } from './rule.ts'
15
+ import { check } from '../check.ts'
16
16
 
17
17
  // the documented silencer: .claude/settings.json attribution.commit/.pr both
18
18
  // empty — the harness then never writes the trailer or the PR footer
@@ -25,27 +25,28 @@ const attributionSilenced = (root: string): boolean => {
25
25
  }
26
26
  }
27
27
 
28
- export const commitAiTells: Rule = {
28
+ export const commitAiTells = check({
29
29
  id: 'SK009',
30
30
  blocking: true,
31
31
  title: 'commit ai tells',
32
+ description: 'the history of a public repo carries no AI attribution and no dash punctuation',
32
33
  owner: 'identity/how-we-commit.md',
33
34
  // it scans the full `git log` and every ref name. History and refs are not
34
35
  // work-tree content, so nothing a scan can produce describes this answer
35
36
  never: 'reads the full commit history and every ref name, neither of which is work-tree content',
36
- lint: async (t, env) => {
37
- const nested = nestedIn(env.root)
37
+ run: async (c) => {
38
+ const nested = nestedIn(c.root)
38
39
  if (nested != null)
39
- return void t.lines.push({ kind: 'skip', text: `commit history not scanned, nested in ${nested}: that history belongs to the parent repo` })
40
- const declared = env.config.visibility ?? 'private'
40
+ return void c.lines.push({ kind: 'skip', text: `commit history not scanned, nested in ${nested}: that history belongs to the parent repo` })
41
+ const declared = c.config.visibility ?? 'private'
41
42
  if (declared !== 'public')
42
- return t.ok(`declared ${declared} — the no-AI-attribution ban still applies from commit one (identity/how-we-commit.md); the full-history scan arms at visibility: 'public'`)
43
+ return c.ok(`declared ${declared} — the no-AI-attribution ban still applies from commit one (identity/how-we-commit.md); the full-history scan arms at visibility: 'public'`)
43
44
  // %x1f separates hash from body, %x1e terminates the record — commit
44
45
  // bodies are free text, so only unprintable separators are safe
45
- const log = await t.exec(['git', 'log', '--format=%h%x1f%B%x1e'], { cwd: env.root, timeoutMs: 30_000 })
46
+ const log = await c.exec(['git', 'log', '--format=%h%x1f%B%x1e'], { cwd: c.root, timeoutMs: 30_000 })
46
47
  if (log.code !== 0) {
47
48
  const first = log.out.trim().split('\n').find((l) => l.trim() !== '')?.slice(0, 120) ?? `exit ${log.code}`
48
- return void t.lines.push({ kind: 'skip', text: `commit history unverifiable — git says: ${first}` })
49
+ return void c.lines.push({ kind: 'skip', text: `commit history unverifiable — git says: ${first}` })
49
50
  }
50
51
  const commits = log.out
51
52
  .split('\x1e')
@@ -55,30 +56,32 @@ export const commitAiTells: Rule = {
55
56
  const cut = r.indexOf('\x1f')
56
57
  return { hash: r.slice(0, cut), body: r.slice(cut + 1) }
57
58
  })
58
- if (commits.length === 0) return void t.lines.push({ kind: 'skip', text: 'commit history unverifiable — no commits yet' })
59
+ if (commits.length === 0) return void c.lines.push({ kind: 'skip', text: 'commit history unverifiable — no commits yet' })
59
60
  const remedy = 'an unpushed TIP commit may be amended; anything deeper is the owner\'s decision (history is never rewritten as a side effect)'
60
61
  const sample = (hits: { hash: string }[]): string => hits.slice(0, 5).map((h) => h.hash).join(' ') + (hits.length > 5 ? ' …' : '')
61
- const attributed = commits.filter((c) => hasAiAttribution(c.body))
62
- const dashed = commits.filter((c) => hasDashPunctuation(c.body))
62
+ const attributed = commits.filter((m) => hasAiAttribution(m.body))
63
+ const dashed = commits.filter((m) => hasDashPunctuation(m.body))
63
64
  // ref NAMES tell too (a FABLE-checkpoint-3 tag outs the agent as
64
65
  // loudly as a trailer). bare match only for names with no other
65
66
  // meaning; vendor and codename words (cursor, openai, gemini, opus,
66
67
  // fable, …) name legitimate branches and features, they only match in
67
68
  // a checkpoint/session shape
68
69
  const refTell = /\b(?:claude|chatgpt|copilot|aider|devin)\b|(?:^|[-_/])(?:fable|sonnet|haiku|opus|gemini|codex)[-_](?:checkpoint|session|ckpt)/i
69
- const refs = await t.exec(['git', 'for-each-ref', '--format=%(refname:short)', 'refs/tags', 'refs/heads', 'refs/remotes'], { cwd: env.root, timeoutMs: 30_000 })
70
- if (refs.code !== 0) t.lines.push({ kind: 'skip', text: `ref names unverifiable — git for-each-ref exit ${refs.code}` })
70
+ const refs = await c.exec(['git', 'for-each-ref', '--format=%(refname:short)', 'refs/tags', 'refs/heads', 'refs/remotes'], { cwd: c.root, timeoutMs: 30_000 })
71
+ if (refs.code !== 0) c.lines.push({ kind: 'skip', text: `ref names unverifiable — git for-each-ref exit ${refs.code}` })
71
72
  const tellRefs = refs.code === 0 ? refs.out.split('\n').map((r) => r.trim()).filter((r) => r !== '' && refTell.test(r)) : []
72
73
  if (tellRefs.length > 0)
73
- t.fail(`${tellRefs.length} ref name(s) carry an AI tell in a declared-public repo (${tellRefs.slice(0, 5).join(' ')}${tellRefs.length > 5 ? ' …' : ''}) — rename or delete the refs before the flip; a pushed tag also needs the remote delete`)
74
+ c.fail(`${tellRefs.length} ref name(s) carry an AI tell in a declared-public repo (${tellRefs.slice(0, 5).join(' ')}${tellRefs.length > 5 ? ' …' : ''}) — rename or delete the refs before the flip; a pushed tag also needs the remote delete`)
74
75
  if (attributed.length > 0)
75
- t.fail(`${attributed.length}/${commits.length} commits carry AI attribution (a trailer naming an assistant / a session trailer or claude.ai url / "Generated with" / 🤖) in a declared-public repo — e.g. ${sample(attributed)}; ${remedy}`)
76
+ c.fail(`${attributed.length}/${commits.length} commits carry AI attribution (a trailer naming an assistant / a session trailer or claude.ai url / "Generated with" / 🤖) in a declared-public repo — e.g. ${sample(attributed)}; ${remedy}`)
76
77
  if (dashed.length > 0)
77
- t.fail(`${dashed.length}/${commits.length} commit messages carry dash punctuation (em/en dash or inline " - ") in a declared-public repo — e.g. ${sample(dashed)}; ${remedy} (identity/writing-voice.md owns the punctuation rule)`)
78
- const silenced = attributionSilenced(env.root)
78
+ c.fail(`${dashed.length}/${commits.length} commit messages carry dash punctuation (em/en dash or inline " - ") in a declared-public repo — e.g. ${sample(dashed)}; ${remedy} (identity/writing-voice.md owns the punctuation rule)`)
79
+ const silenced = attributionSilenced(c.root)
79
80
  if (!silenced)
80
- t.fail(`.claude/settings.json does not silence AI attribution — track it with {"attribution": {"commit": "", "pr": ""}} so no agent's harness ever writes the trailer or the PR footer`)
81
+ c.fail(`.claude/settings.json does not silence AI attribution — track it with {"attribution": {"commit": "", "pr": ""}} so no agent's harness ever writes the trailer or the PR footer`)
81
82
  if (attributed.length === 0 && dashed.length === 0 && tellRefs.length === 0 && silenced)
82
- t.ok(`${commits.length} commits + ${refs.code === 0 ? refs.out.split('\n').filter((r) => r.trim() !== '').length : 0} ref names scanned, attribution silenced in .claude/settings.json — no AI tells`)
83
+ c.ok(`${commits.length} commits + ${refs.code === 0 ? refs.out.split('\n').filter((r) => r.trim() !== '').length : 0} ref names scanned, attribution silenced in .claude/settings.json — no AI tells`)
83
84
  },
84
- }
85
+ })
86
+
87
+ export default commitAiTells
@@ -0,0 +1,111 @@
1
+ // SK010 — undeclared unit (build/shipkit/units.md). 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. Three declarations clear it, so the
6
+ // green is always reachable: `units: [{ dir }]` for a releasable package,
7
+ // `units: [{ dir, release: false }]` for one whose tests run in their own
8
+ // check, `notReleasable: [dir]` for one that ships inside something else. That second
9
+ // escape has teeth on purpose, because it is the one an agent staring at a red
10
+ // gate reaches for: an entry must cover at least one real package and never the
11
+ // whole repo, and the green counts the PACKAGES it excused, so a wide entry
12
+ // cannot read as a narrow one.
13
+ import { dirname, join, relative } from 'path'
14
+ import type { CheckCtx } from '../core/check-ctx.ts'
15
+ import { checkUnitReader, resolveUnits } from '../release/unit-resolve.ts'
16
+ import { check } from '../check.ts'
17
+
18
+ const SKIP = new Set(['node_modules', 'references', 'tmp', 'stats', 'dist', 'build'])
19
+
20
+ /** every package.json below the root that carries a name, root excluded, as
21
+ * absolute dirs. The files git sees, so a gitignored vendor tree or a python
22
+ * venv never trips the gate */
23
+ export function packageDirs(c: CheckCtx): string[] {
24
+ const named = c.list('**/package.json').filter((f) => {
25
+ if (f === 'package.json') return false
26
+ if (dirname(f).split('/').some((seg) => SKIP.has(seg) || seg.startsWith('.'))) return false
27
+ try {
28
+ const pkg = c.json<{ name?: string }>(f)
29
+ return typeof pkg?.name === 'string' && pkg.name !== ''
30
+ } catch { return false } // a broken package.json is SK001's story, not this rule's
31
+ })
32
+ return [...new Set(named.map((f) => join(c.root, dirname(f))))].sort()
33
+ }
34
+
35
+ export const undeclaredUnit = check({
36
+ id: 'SK010',
37
+ blocking: true,
38
+ title: 'units declared',
39
+ description: 'every package.json is a declared unit or declared not releasable',
40
+ owner: 'build/shipkit/units.md',
41
+ // keyed on what it reads: the listing of every package.json, each one it
42
+ // opens, and the declared units' package.json
43
+ run: (c) => {
44
+ const { units, errors, testOnly } = resolveUnits(c.root, c.config, checkUnitReader(c))
45
+ // a DECLARED unit that does not resolve is a declaration bug and
46
+ // belongs here. A root package that is not release-shaped (no version
47
+ // yet) is not: the release path refuses on its own, the day it matters.
48
+ const declared = errors.filter((e) => e.id !== 'root')
49
+ if (declared.length > 0) {
50
+ for (const e of declared) c.fail(`declared unit '${e.id}' does not resolve: ${e.message}`, 'repo.config units[]')
51
+ return
52
+ }
53
+ // an entry is a PREFIX over the packages under it, so `examples/` can
54
+ // excuse examples/a and examples/b without enumerating them. Two shapes
55
+ // are refused, both being the kill switch this rule exists to prevent:
56
+ // an entry covering the whole repo, and one covering nothing today that
57
+ // stands ready to excuse whatever lands there tomorrow. defineRepo
58
+ // refuses the root too, and a repo.config.ts that never calls it is
59
+ // exactly why the check also lives in the blocking rule.
60
+ const decls = c.config.notReleasable ?? []
61
+ const covered = (list: string[], d: string): boolean => list.some((x) => d === x || d.startsWith(`${x}/`))
62
+ const pkgs = packageDirs(c)
63
+ const wide = decls.filter((d) => join(c.root, d) === c.root)
64
+ if (wide.length > 0) {
65
+ for (const d of wide)
66
+ c.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`)
67
+ return
68
+ }
69
+ const empty = decls.filter((d) => !pkgs.some((p) => covered([join(c.root, d)], p)))
70
+ if (empty.length > 0) {
71
+ for (const d of empty)
72
+ c.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`)
73
+ return
74
+ }
75
+ const excused = decls.map((d) => join(c.root, d))
76
+ const declaredDirs = units.filter((u) => u.rel !== '').map((u) => u.dir)
77
+ // an excuse that also covers a declared UNIT is the kill switch one
78
+ // level down: `notReleasable: ['libs']` beside `units: [libs/a]` reads
79
+ // as green while every new package under libs/ hides. The two lists
80
+ // describe opposite intents, so they must not overlap.
81
+ const overlapping = decls.filter((d) => declaredDirs.some((u) => u === join(c.root, d) || u.startsWith(`${join(c.root, d)}/`)))
82
+ if (overlapping.length > 0) {
83
+ for (const d of overlapping)
84
+ c.fail(`notReleasable '${d}' also covers a declared unit, so it excuses every package under it while reading as green`, `repo.config notReleasable: name the dirs that are NOT units, never a container holding one`)
85
+ return
86
+ }
87
+ // a `release: false` unit already says what its dir is, so an excuse
88
+ // naming it too is a second statement of one fact, and the two drift
89
+ const doubled = decls.filter((d) => testOnly.some((u) => u.dir === join(c.root, d) || u.dir.startsWith(`${join(c.root, d)}/`)))
90
+ if (doubled.length > 0) {
91
+ for (const d of doubled)
92
+ c.fail(`notReleasable '${d}' also covers a unit declared release: false, which already claims its dir`, `repo.config notReleasable: drop '${d}', the units[] entry says it`)
93
+ return
94
+ }
95
+ const claimed = [...declaredDirs, ...testOnly.map((u) => u.dir), ...excused]
96
+ const orphans = pkgs.filter((d) => !covered(claimed, d))
97
+ if (orphans.length === 0) {
98
+ // PACKAGES, not entries: one entry over a nested tree excuses more
99
+ // than one, and the count is the only place that shows it
100
+ const n = pkgs.filter((d) => covered(excused, d)).length
101
+ const t = testOnly.length
102
+ return c.ok(`${units.length} releasing unit(s) declared${units.length === 1 ? ' (the repo root)' : ''}${t > 0 ? ` + ${t} test only unit(s)` : ''}${n > 0 ? ` + ${n} package(s) declared not releasable` : ''} — no package hides from a release`)
103
+ }
104
+ for (const d of orphans) {
105
+ const rel = relative(c.root, d)
106
+ c.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, units: [{ dir: '${rel}', release: false }] if only its tests run, else notReleasable: ['${rel}']`)
107
+ }
108
+ },
109
+ })
110
+
111
+ export default undeclaredUnit
@@ -1,5 +1,5 @@
1
- // SK011 — a dependency range on a SIBLING release unit must be satisfied by
2
- // the version that sibling actually carries (build/shipkit.md, release units).
1
+ // SK011 — a dependency range on a SIBLING unit must be satisfied by
2
+ // the version that sibling actually carries (build/shipkit/units.md).
3
3
  //
4
4
  // The failure it exists for: one unit is edited and its dependents keep a
5
5
  // range that names a version nobody published, or an older one that lacks the
@@ -9,10 +9,9 @@
9
9
  // interrupted.
10
10
  //
11
11
  // Local protocols (link:, file:) are SK006's story, not this rule's.
12
- import { existsSync, readFileSync } from 'fs'
13
- import { join } from 'path'
14
- import { resolveUnits } from '../release/units.ts'
15
- import type { Rule } from './rule.ts'
12
+ import { join, relative } from 'path'
13
+ import { checkUnitReader, resolveUnits } from '../release/unit-resolve.ts'
14
+ import { check } from '../check.ts'
16
15
 
17
16
  type Pkg = {
18
17
  dependencies?: Record<string, string>
@@ -54,24 +53,24 @@ export function satisfiesSimple(version: string, range: string): boolean | null
54
53
  return va === ra && vi === ri && vp === rp
55
54
  }
56
55
 
57
- export const siblingUnitRanges: Rule = {
56
+ export const siblingUnitRanges = check({
58
57
  id: 'SK011',
59
58
  blocking: true,
60
59
  title: 'sibling unit ranges',
61
- owner: 'build/shipkit.md',
62
- // every declared unit's package.json, at dirs the config picks
63
- inputs: ['.'],
64
- lint: (t, env) => {
65
- const { units } = resolveUnits(env.root, env.config)
60
+ description: 'every range on a sibling unit floors at the version that sibling carries',
61
+ owner: 'build/shipkit/built-in-checks.md',
62
+ // keyed on what it reads: every declared unit's package.json, at the dirs
63
+ // the config picks
64
+ run: (c) => {
65
+ const { units } = resolveUnits(c.root, c.config, checkUnitReader(c))
66
66
  const version = new Map(units.map((u) => [u.name, u.version]))
67
- if (units.length < 2) return t.ok('one release unit — no sibling ranges to keep honest')
67
+ if (units.length < 2) return c.ok('one releasing unit — no sibling ranges to keep honest')
68
68
  const bad: string[] = []
69
69
  let checked = 0
70
70
  for (const u of units) {
71
- const p = join(u.dir, 'package.json')
72
- if (!existsSync(p)) continue
73
- let pkg: Pkg
74
- try { pkg = JSON.parse(readFileSync(p, 'utf8')) as Pkg } catch { continue }
71
+ let pkg: Pkg | null
72
+ try { pkg = c.json<Pkg>(relative(c.root, join(u.dir, 'package.json'))) } catch { continue }
73
+ if (pkg == null) continue
75
74
  for (const block of BLOCKS)
76
75
  for (const [dep, range] of Object.entries(pkg[block] ?? {})) {
77
76
  const actual = version.get(dep)
@@ -87,7 +86,9 @@ export const siblingUnitRanges: Rule = {
87
86
  }
88
87
  }
89
88
  if (bad.length > 0)
90
- t.fail(`${bad.join(' · ')} — a sibling range FLOORS at the version it names, so a stale floor lets a consumer keep an older copy and miss the release. \`shipkit bump\` moves every floor forward in the version commit, so a red here is a bump nobody propagated`)
91
- else t.ok(`${checked} sibling range(s) floor at exactly the version each unit carries`)
89
+ c.fail(`${bad.join(' · ')} — a sibling range FLOORS at the version it names, so a stale floor lets a consumer keep an older copy and miss the release. \`shipkit bump\` moves every floor forward in the version commit, so a red here is a bump nobody propagated`)
90
+ else c.ok(`${checked} sibling range(s) floor at exactly the version each unit carries`)
92
91
  },
93
- }
92
+ })
93
+
94
+ export default siblingUnitRanges
@@ -10,10 +10,7 @@
10
10
  // A barrel is a fine PUBLIC surface and a terrible one to import from a config
11
11
  // file that exists to be read before anything else happens. This rule is
12
12
  // narrow on purpose: it is about repo.config.ts, not about barrels in general.
13
- import { existsSync, readFileSync } from 'fs'
14
- import { join } from 'path'
15
- import type { FixResult, Rule, RuleEnv } from './rule.ts'
16
- import { atomicWrite } from '../core/locked-write.ts'
13
+ import { check, type FixResult } from '../check.ts'
17
14
 
18
15
  const CONFIG = 'repo.config.ts'
19
16
  const BARREL = /(^|\n)(\s*import\s+(?:type\s+)?\{[^}]*\}\s+from\s+)['"]rvlib-shipkit['"]/
@@ -27,38 +24,36 @@ const CONFIG_SYMBOLS = new Set(['defineRepo', 'RepoConfig', 'TaskDef', 'ServeCon
27
24
  const importedSymbols = (line: string): string[] =>
28
25
  (/\{([^}]*)\}/.exec(line)?.[1] ?? '').split(',').map((s) => s.replace(/^\s*type\s+/, '').trim()).filter((s) => s !== '')
29
26
 
30
- export const configBarrelImport: Rule = {
27
+ export const configBarrelImport = check({
31
28
  id: 'SK012',
32
29
  blocking: true,
33
30
  title: 'config import path',
34
- owner: 'build/shipkit.md',
35
- // ONE file, and the one file shipkit guarantees exists: loadRepoConfig
36
- // refuses a repo without it
37
- inputs: ['repo.config.ts'],
38
- lint: (t, env) => {
39
- const p = join(env.root, CONFIG)
40
- if (!existsSync(p)) return void t.ok(`${CONFIG} absent — nothing to narrow`)
41
- const src = readFileSync(p, 'utf8')
42
- if (!BARREL.test(src)) return void t.ok(`${CONFIG} imports the narrow config module`)
31
+ description: 'repo.config.ts imports `defineRepo` from the narrow module, never the barrel',
32
+ owner: 'build/shipkit/config-keys.md',
33
+ run: (c) => {
34
+ const src = c.read(CONFIG)
35
+ if (src == null) return void c.ok(`${CONFIG} absent — nothing to narrow`)
36
+ if (!BARREL.test(src)) return void c.ok(`${CONFIG} imports the narrow config module`)
43
37
  const line = src.split('\n').find((l) => /from\s+['"]rvlib-shipkit['"]/.test(l)) ?? ''
44
38
  const symbols = importedSymbols(line)
45
39
  const strays = symbols.filter((s) => !CONFIG_SYMBOLS.has(s))
46
40
  if (strays.length > 0) {
47
- t.fail(`${CONFIG} imports ${strays.join(', ')} from the package barrel, which costs ~400ms of module load on every shipkit run`)
48
- return void t.lines.push({ kind: 'fix-fail', text: `move ${strays.join(', ')} to their own module, then import from ${NARROW}` })
41
+ c.fail(`${CONFIG} imports ${strays.join(', ')} from the package barrel, which costs ~400ms of module load on every shipkit run`)
42
+ return void c.lines.push({ kind: 'fix-fail', text: `move ${strays.join(', ')} to their own module, then import from ${NARROW}` })
49
43
  }
50
- t.fail(`${CONFIG} imports from the package barrel (~400ms of module load on every shipkit run, the pre-commit hook included)`)
44
+ c.fail(`${CONFIG} imports from the package barrel (~400ms of module load on every shipkit run, the pre-commit hook included)`)
51
45
  },
52
- fix: (env: RuleEnv): FixResult => {
53
- const p = join(env.root, CONFIG)
54
- if (!existsSync(p)) return { changed: false, summary: `${CONFIG} absent` }
55
- const src = readFileSync(p, 'utf8')
46
+ fix: (c): FixResult => {
47
+ const src = c.read(CONFIG)
48
+ if (src == null) return { changed: false, summary: `${CONFIG} absent` }
56
49
  if (!BARREL.test(src)) return { changed: false, summary: 'already narrow' }
57
50
  const line = src.split('\n').find((l) => /from\s+['"]rvlib-shipkit['"]/.test(l)) ?? ''
58
51
  // only rewrite what the narrow module actually exports: silently
59
52
  // repointing an import it cannot serve turns a slow gate into a broken one
60
53
  if (importedSymbols(line).some((s) => !CONFIG_SYMBOLS.has(s))) return { changed: false, summary: 'imports symbols the narrow module does not export — move them by hand' }
61
- atomicWrite(p, src.replace(BARREL, `$1$2'${NARROW}'`))
54
+ c.write(CONFIG, src.replace(BARREL, `$1$2'${NARROW}'`))
62
55
  return { changed: true, summary: `${CONFIG} now imports ${NARROW} (~400ms saved per run)` }
63
56
  },
64
- }
57
+ })
58
+
59
+ export default configBarrelImport