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
@@ -12,13 +12,11 @@
12
12
  // Both are generated from ONE table (core/one-way-in-policy.ts). The file is
13
13
  // TRACKED on purpose: `.claude/settings.local.json` is gitignored by the house
14
14
  // scaffold, so a policy written there would bind exactly one checkout.
15
- import { existsSync, mkdirSync, readFileSync } from 'fs'
16
- import { dirname, isAbsolute, join } from 'path'
15
+ import { isAbsolute, join } from 'path'
17
16
  import { inferBin } from '../core/canonical-scripts.ts'
18
17
  import { denyRules, RAW_RUNNERS } from '../core/one-way-in-policy.ts'
19
18
  import type { RepoConfig } from '../repo-config.ts'
20
- import type { FixResult, Rule, RuleEnv } from './rule.ts'
21
- import { atomicWrite } from '../core/locked-write.ts'
19
+ import { check, type CheckCtx, type FixResult } from '../check.ts'
22
20
 
23
21
  const OWNER = 'build/shipkit.md'
24
22
  export const SETTINGS_PATH = '.claude/settings.json'
@@ -46,12 +44,23 @@ export function guardCommand(bin?: string): string {
46
44
  return isAbsolute(path) ? `bun "${path}"` : `bun "$CLAUDE_PROJECT_DIR/${path}"`
47
45
  }
48
46
 
49
- const readPkg = (root: string): { scripts?: Record<string, string>; dependencies?: Record<string, string>; devDependencies?: Record<string, string> } | null => {
50
- try { return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as never } catch { return null }
47
+ /** the helpers below see the repo through the check context. A guard at an
48
+ * absolute path lives outside the repo, so probing it goes through `c.exec`,
49
+ * which keeps that answer out of the cache */
50
+ type Disk = Pick<CheckCtx, 'read' | 'exists' | 'exec'>
51
+
52
+ const existsOutside = async (d: Disk, abs: string): Promise<boolean> => (await d.exec(['test', '-e', abs])).code === 0
53
+
54
+ const parsed = <T>(text: string | null): T | null => {
55
+ if (text == null) return null
56
+ try { return JSON.parse(text) as T } catch { return null }
51
57
  }
52
58
 
53
- const declaresShipkit = (root: string): boolean => {
54
- const pkg = readPkg(root)
59
+ type Pkg = { scripts?: Record<string, string>; dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
60
+ const readPkg = (d: Disk): Pkg | null => parsed<Pkg>(d.read('package.json'))
61
+
62
+ const declaresShipkit = (d: Disk): boolean => {
63
+ const pkg = readPkg(d)
55
64
  return pkg?.dependencies?.['rvlib-shipkit'] != null || pkg?.devDependencies?.['rvlib-shipkit'] != null
56
65
  }
57
66
 
@@ -59,13 +68,9 @@ const declaresShipkit = (root: string): boolean => {
59
68
  * declares rvlib-shipkit, says the dependency is not installed, which
60
69
  * `bun install` fixes and no edit to this repo can. Without the declaration
61
70
  * no install ever brings it, so it is a path this repo got wrong like any other. */
62
- const isUninstalledDep = (root: string, rel: string): boolean => rel.startsWith('node_modules/') && declaresShipkit(root)
71
+ const isUninstalledDep = (d: Disk, rel: string): boolean => rel.startsWith('node_modules/') && declaresShipkit(d)
63
72
 
64
- const readSettings = (root: string): Settings | null => {
65
- const p = join(root, SETTINGS_PATH)
66
- if (!existsSync(p)) return null
67
- try { return JSON.parse(readFileSync(p, 'utf8')) as Settings } catch { return null }
68
- }
73
+ const readSettings = (d: Disk): Settings | null => parsed<Settings>(d.read(SETTINGS_PATH))
69
74
 
70
75
  const guardHookCommand = (s: Settings): string | null => {
71
76
  for (const m of s.hooks?.['PreToolUse'] ?? [])
@@ -82,11 +87,11 @@ const hasGuardHook = (s: Settings): boolean => guardHookCommand(s) != null
82
87
  * nothing, so the agent sees only the bare deny, which is the one failure
83
88
  * this rule exists to prevent. Declaring it and never running it is worse
84
89
  * than not declaring it, so it FAILs. */
85
- function guardScriptMissing(root: string, command: string): string | null {
90
+ async function guardScriptMissing(d: Disk, command: string): Promise<string | null> {
86
91
  const rel = /["']?\$(?:\{)?CLAUDE_PROJECT_DIR(?:\})?\/([^"'\s]+)["']?/.exec(command)?.[1]
87
- if (rel != null) return existsSync(join(root, rel)) ? null : rel
92
+ if (rel != null) return d.exists(rel) ? null : rel
88
93
  const abs = /^bun\s+["']?(\/[^"'\s]+)["']?/.exec(command.trim())?.[1]
89
- if (abs != null) return existsSync(abs) ? null : abs
94
+ if (abs != null) return (await existsOutside(d, abs)) ? null : abs
90
95
  return null // a form this rule cannot resolve is not a form it may condemn
91
96
  }
92
97
 
@@ -94,42 +99,44 @@ function guardScriptMissing(root: string, command: string): string | null {
94
99
  * beside it (a repo's own `src/cli.ts` matches the same shape), else the
95
100
  * repo's own dependency, else the copy of shipkit doing the fixing, which is
96
101
  * the one the launcher falls back to in a repo that has none */
97
- export function guardCommandFor(root: string): string {
98
- const bin = inferBin(readPkg(root)?.scripts ?? {})
102
+ export async function guardCommandFor(d: Disk): Promise<string> {
103
+ const bin = inferBin(readPkg(d)?.scripts ?? {})
99
104
  if (bin != null) {
100
105
  const fromBin = guardCommand(bin)
101
- if (guardScriptMissing(root, fromBin) == null) return fromBin
106
+ if ((await guardScriptMissing(d, fromBin)) == null) return fromBin
102
107
  }
103
- if (declaresShipkit(root)) return guardCommand()
108
+ if (declaresShipkit(d)) return guardCommand()
104
109
  return guardCommand(join(import.meta.dir, '..', 'cli.ts'))
105
110
  }
106
111
 
107
- export const oneWayInPolicy: Rule = {
112
+ export const oneWayInPolicy = check({
108
113
  id: 'SK013',
109
114
  blocking: true,
110
115
  title: 'one way in policy',
116
+ description: 'the agent settings deny every raw runner this repo governs and name the gate',
111
117
  owner: OWNER,
112
- // .claude/settings.json, package.json, and whether the declared guard
113
- // script resolves — the last one is a config-chosen path
114
- inputs: ['.'],
115
- lint: (t, env) => {
116
- const want = denyRules(exemptSlots(env.config))
117
- if (want.length === 0) return t.ok('every governed slot is exempt (repo.config oneWayIn/defaults) — nothing to deny')
118
- const s = readSettings(env.root)
118
+ // keyed on what it reads: .claude/settings.json, package.json, and whether
119
+ // the declared guard script resolves. A guard at an absolute path lives
120
+ // outside the repo, so an answer that probed one is never stored
121
+ run: async (c) => {
122
+ const d = c
123
+ const want = denyRules(exemptSlots(c.config))
124
+ if (want.length === 0) return c.ok('every governed slot is exempt (repo.config oneWayIn/defaults) — nothing to deny')
125
+ const s = readSettings(d)
119
126
  if (s == null) {
120
- t.fail(`${SETTINGS_PATH} ${existsSync(join(env.root, SETTINGS_PATH)) ? 'is unparseable' : 'missing'} — nothing stops an agent typing \`bun test\`, and a package.json script cannot intercept a bun builtin`)
127
+ c.fail(`${SETTINGS_PATH} ${d.exists(SETTINGS_PATH) ? 'is unparseable' : 'missing'} — nothing stops an agent typing \`bun test\`, and a package.json script cannot intercept a bun builtin`)
121
128
  return
122
129
  }
123
130
  const have = new Set(s.permissions?.deny ?? [])
124
131
  const missing = want.filter((r) => !have.has(r))
125
132
  if (missing.length > 0)
126
- t.fail(`${SETTINGS_PATH}: ${missing.length} raw runner(s) still permitted (${missing.slice(0, 3).join(', ')}${missing.length > 3 ? ', …' : ''}) — those calls never reach shipkit, so nothing is cached and no run can be attached to`)
133
+ c.fail(`${SETTINGS_PATH}: ${missing.length} raw runner(s) still permitted (${missing.slice(0, 3).join(', ')}${missing.length > 3 ? ', …' : ''}) — those calls never reach shipkit, so nothing is cached and no run can be attached to`)
127
134
  const guard = guardHookCommand(s)
128
135
  let guardRuns = false
129
136
  if (guard == null)
130
- t.fail(`${SETTINGS_PATH}: no PreToolUse guard — a bare deny tells the model only that it was denied, so it goes looking for another runner instead of the shipkit command`)
137
+ c.fail(`${SETTINGS_PATH}: no PreToolUse guard — a bare deny tells the model only that it was denied, so it goes looking for another runner instead of the shipkit command`)
131
138
  else {
132
- const missing = guardScriptMissing(env.root, guard)
139
+ const missing = await guardScriptMissing(d, guard)
133
140
  guardRuns = missing == null
134
141
  // the hook is DECLARED either way, so it starts working the moment
135
142
  // the dependency lands: no second fix run, nothing to remember. An
@@ -137,13 +144,13 @@ export const oneWayInPolicy: Rule = {
137
144
  // that fixes it, not a red gate on a repo nobody has installed yet,
138
145
  // and the deny list is the floor throughout. A path this repo chose
139
146
  // and got wrong is still a FAIL: it will never resolve on its own.
140
- if (missing != null && isUninstalledDep(env.root, missing))
141
- t.warn(
147
+ if (missing != null && isUninstalledDep(d, missing))
148
+ c.warn(
142
149
  `${SETTINGS_PATH}: the PreToolUse guard points at ${missing}, which is not installed yet — until then a refusal names no replacement, and the deny list is the whole floor`,
143
150
  `bun install (or bun add -d rvlib-shipkit)`,
144
151
  )
145
152
  else if (missing != null)
146
- t.fail(
153
+ c.fail(
147
154
  `${SETTINGS_PATH}: the PreToolUse guard points at ${missing}, which does not exist — every Bash call errors and no refusal ever names the shipkit command`,
148
155
  `point it at this repo's own copy of hooks/one-way-in-guard.ts, or delete the hook and let the deny list stand alone`,
149
156
  )
@@ -151,18 +158,18 @@ export const oneWayInPolicy: Rule = {
151
158
  // the summary states only what the file actually delivers: claiming the
152
159
  // replacement is named while the guard cannot run is the green that
153
160
  // measured nothing, one line under the WARN that says otherwise
154
- if (!t.failed)
155
- t.ok(`${SETTINGS_PATH} closes ${want.length}/${RAW_RUNNERS.length} raw runner(s)${guardRuns ? ', with the replacement named' : ' (the guard that names the replacement is declared, not yet runnable)'}`)
161
+ if (!c.failed)
162
+ c.ok(`${SETTINGS_PATH} closes ${want.length}/${RAW_RUNNERS.length} raw runner(s)${guardRuns ? ', with the replacement named' : ' (the guard that names the replacement is declared, not yet runnable)'}`)
156
163
  },
157
164
  // MERGES, never rewrites: this file also carries the attribution silencer,
158
165
  // a repo's own allow list and its own hooks, and clobbering those to fix a
159
166
  // deny list would cost more than the finding it repairs
160
- fix: (env: RuleEnv): FixResult => {
161
- const want = denyRules(exemptSlots(env.config))
167
+ fix: async (c): Promise<FixResult> => {
168
+ const d = c
169
+ const want = denyRules(exemptSlots(c.config))
162
170
  if (want.length === 0) return { changed: false, summary: 'every governed slot is exempt — nothing to write' }
163
- const p = join(env.root, SETTINGS_PATH)
164
- const existing = readSettings(env.root)
165
- if (existing == null && existsSync(p)) return { changed: false, summary: `${SETTINGS_PATH} is unparseable — repair it by hand first, a merge would destroy it` }
171
+ const existing = readSettings(d)
172
+ if (existing == null && c.exists(SETTINGS_PATH)) return { changed: false, summary: `${SETTINGS_PATH} is unparseable — repair it by hand first, a merge would destroy it` }
166
173
  const s: Settings = existing ?? {}
167
174
  const changes: string[] = []
168
175
  const deny = s.permissions?.deny ?? []
@@ -171,7 +178,7 @@ export const oneWayInPolicy: Rule = {
171
178
  s.permissions = { ...s.permissions, deny: [...deny, ...added] }
172
179
  changes.push(`${added.length} raw runner(s) denied`)
173
180
  }
174
- const wantGuard = guardCommandFor(env.root)
181
+ const wantGuard = await guardCommandFor(d)
175
182
  if (!hasGuardHook(s)) {
176
183
  const pre = s.hooks?.['PreToolUse'] ?? []
177
184
  s.hooks = { ...s.hooks, PreToolUse: [...pre, { matcher: 'Bash', hooks: [{ type: 'command', command: wantGuard }] }] }
@@ -183,16 +190,17 @@ export const oneWayInPolicy: Rule = {
183
190
  for (const m of s.hooks?.['PreToolUse'] ?? [])
184
191
  for (const h of m.hooks ?? []) {
185
192
  if (h.command?.includes('one-way-in-guard') !== true) continue
186
- const missing = guardScriptMissing(env.root, h.command)
187
- if (missing == null || isUninstalledDep(env.root, missing) || h.command === wantGuard) continue
188
- if (guardScriptMissing(env.root, wantGuard) != null) continue
193
+ const missing = await guardScriptMissing(d, h.command)
194
+ if (missing == null || isUninstalledDep(d, missing) || h.command === wantGuard) continue
195
+ if ((await guardScriptMissing(d, wantGuard)) != null) continue
189
196
  changes.push(`PreToolUse guard repointed from ${missing}, which does not exist`)
190
197
  h.command = wantGuard
191
198
  }
192
199
  }
193
200
  if (changes.length === 0) return { changed: false, summary: `${SETTINGS_PATH} already closes every raw runner` }
194
- mkdirSync(dirname(p), { recursive: true })
195
- atomicWrite(p, JSON.stringify(s, null, 2) + '\n')
201
+ c.write(SETTINGS_PATH, JSON.stringify(s, null, 2) + '\n')
196
202
  return { changed: true, summary: `${SETTINGS_PATH}: ${changes.join(', ')}` }
197
203
  },
198
- }
204
+ })
205
+
206
+ export default oneWayInPolicy
@@ -17,34 +17,48 @@
17
17
  // sentence is content. The rewrite lives behind an explicit `shipkit prose
18
18
  // --fix` that a human ran and can read the diff of.
19
19
  import { classSummary, proseLines, runProseCheck } from '../prose/run-prose.ts'
20
- import type { Rule } from './rule.ts'
20
+ import { check } from '../check.ts'
21
21
 
22
22
  const OWNER = 'identity/writing-voice.md'
23
23
 
24
- export const proseSte: Rule = {
24
+ export const proseSte = check({
25
25
  id: 'SK014',
26
26
  blocking: true,
27
27
  title: 'STE prose',
28
+ description: 'house prose passes the mechanical STE rules',
28
29
  owner: OWNER,
29
- inputs: ['.'],
30
- lint: (t, env) => {
31
- const cfg = env.config.prose
30
+ // keyed on what it reads: each include glob's listing and every file in
31
+ // scope, so an edit outside the declared prose never reruns it
32
+ run: (c) => {
33
+ const cfg = c.config.prose
32
34
  if (cfg == null || (cfg.include ?? []).length === 0) {
33
- t.ok(`no prose scope declared — add \`prose: { include: [...] }\` to repo.config.ts to gate house prose on STE`)
35
+ c.ok(`no prose scope declared — add \`prose: { include: [...] }\` to repo.config.ts to gate house prose on STE`)
36
+ return
37
+ }
38
+ const r = runProseCheck({ c, config: cfg })
39
+ // a scope that resolves to zero files reads exactly like a clean repo,
40
+ // and it is the opposite: the repo asked for the gate and the gate
41
+ // inspects nothing. A green here is coverage nobody has.
42
+ if (r.files === 0) {
43
+ c.warn(
44
+ `prose scope matches no file: ${(cfg.include ?? []).join(', ')} — declared and inspecting nothing`,
45
+ 'fix the globs in repo.config.ts prose.include (or its exempt list), or drop the key to opt out',
46
+ )
34
47
  return
35
48
  }
36
- const r = runProseCheck({ root: env.root, config: cfg })
37
49
  const blocking = r.byTier.fix + r.byTier.hard
38
- for (const l of proseLines(r, { cap: 12, tiers: ['fix', 'hard'] })) t.note(l)
50
+ for (const l of proseLines(r, { cap: 12, tiers: ['fix', 'hard'] })) c.note(l)
39
51
  if (blocking > 0)
40
- t.fail(
52
+ c.fail(
41
53
  `${blocking} STE violation(s) across ${r.byFile.length}/${r.files} file(s) — ${classSummary(r)}`,
42
54
  r.fixable > 0 ? `shipkit prose --fix rewrites ${r.fixable} of them` : undefined,
43
55
  )
44
56
  if (cfg.baseline != null && r.byTier.soft > cfg.baseline)
45
- t.fail(`${r.byTier.soft} soft hits (sentence length, passive voice), above the declared baseline of ${cfg.baseline} — the ratchet only goes down`)
57
+ c.fail(`${r.byTier.soft} soft hits (sentence length, passive voice), above the declared baseline of ${cfg.baseline} — the ratchet only goes down`)
46
58
  else if (r.byTier.soft > 0)
47
- t.warn(`${r.byTier.soft} soft hit(s): sentence length and passive voice need a human read${cfg.baseline != null ? ` (baseline ${cfg.baseline})` : ''}`)
48
- if (!t.failed && blocking === 0) t.ok(`${r.files} file(s) STE-clean on the mechanical rules`)
59
+ c.warn(`${r.byTier.soft} soft hit(s): sentence length and passive voice need a human read${cfg.baseline != null ? ` (baseline ${cfg.baseline})` : ''}`)
60
+ if (!c.failed && blocking === 0) c.ok(`${r.files} file(s) STE-clean on the mechanical rules`)
49
61
  }
50
- }
62
+ })
63
+
64
+ export default proseSte
@@ -3,27 +3,30 @@
3
3
  // sites that write history refuse on the disk fact either way (core/nesting.ts),
4
4
  // so this rule is about the declaration telling the truth to the next reader.
5
5
  import { nestedIn } from '../core/nesting.ts'
6
- import type { Rule } from './rule.ts'
6
+ import { check } from '../check.ts'
7
7
 
8
- export const nestedRepo: Rule = {
8
+ export const nestedRepo = check({
9
9
  id: 'SK015',
10
10
  blocking: true,
11
11
  title: 'nested repo',
12
- owner: 'build/shipkit.md',
12
+ description: 'a config inside a larger work tree declares `nested: true`, and only there',
13
+ owner: 'build/shipkit/config-keys.md',
13
14
  never: 'reads where the git work tree root sits, which no content inside the repo describes',
14
- lint: (t, env) => {
15
- const top = nestedIn(env.root)
16
- const declared = env.config.nested === true
15
+ run: (c) => {
16
+ const top = nestedIn(c.root)
17
+ const declared = c.config.nested === true
17
18
  if (top != null && !declared) {
18
- t.fail(`repo.config.ts sits inside the git repo at ${top} and does not declare it`)
19
- t.lines.push({ kind: 'fix-fail', text: 'add `nested: true` to repo.config.ts: shipkit then owns this folder only, and never writes the parent history' })
19
+ c.fail(`repo.config.ts sits inside the git repo at ${top} and does not declare it`)
20
+ c.lines.push({ kind: 'fix-fail', text: 'add `nested: true` to repo.config.ts: shipkit then owns this folder only, and never writes the parent history' })
20
21
  return
21
22
  }
22
23
  if (top == null && declared) {
23
- t.fail('repo.config.ts declares `nested: true`, but this folder is its own git work tree root (or sits outside git)')
24
- t.lines.push({ kind: 'fix-fail', text: 'delete `nested: true` from repo.config.ts' })
24
+ c.fail('repo.config.ts declares `nested: true`, but this folder is its own git work tree root (or sits outside git)')
25
+ c.lines.push({ kind: 'fix-fail', text: 'delete `nested: true` from repo.config.ts' })
25
26
  return
26
27
  }
27
- t.ok(top == null ? 'not nested, and not declared nested' : `nested in ${top}, declared`)
28
+ c.ok(top == null ? 'not nested, and not declared nested' : `nested in ${top}, declared`)
28
29
  },
29
- }
30
+ })
31
+
32
+ export default nestedRepo
@@ -0,0 +1,182 @@
1
+ // SK016 — the shapes the check file replaced, and the machine that
2
+ // rewrites them (build/shipkit/writing-a-check.md owns the shape itself).
3
+ //
4
+ // The window: every shape below is READ through 0.40, named once per run, and
5
+ // refused at the first release after `shipkit fleet` shows no repo below 0.40.
6
+ // So a repo on an old declaration keeps working, learns what to write instead
7
+ // on every gate, and is carried across by one command, save what only a
8
+ // reader can port: a check script's body, a rule, a task whose body is code.
9
+ //
10
+ // It WARNs rather than FAILs for the whole window, because the repo is not
11
+ // broken: it is behind. The day the shapes are refused, `defineRepo` and
12
+ // discovery say so at load, which is a louder place than a lint row.
13
+ //
14
+ // It reads and writes through `c` alone. The config half of the rewrite loads
15
+ // repo.config.ts as a module, which no read can stand for, so it runs in its
16
+ // own process (core/config-rewrite.ts) through `c.exec`.
17
+ import { join } from 'path'
18
+ import { checkFileRunBy, dropConfigKey, legacyShapes, scriptPort, taskCheckSource, checkFileName } from '../core/legacy-shapes.ts'
19
+ import type { ConfigPlan, ConfigRewrite } from '../core/config-rewrite.ts'
20
+ import { check, type CheckCtx, type FixResult, type TaskDef } from '../check.ts'
21
+
22
+ /** the config half of the rewrite, run as its own process */
23
+ const CONFIG_REWRITE = join(import.meta.dir, '../core/config-rewrite.ts')
24
+
25
+ /** what applies the config half of a plan and says what it did */
26
+ export type ConfigRewriter = (plan: ConfigPlan) => Promise<ConfigRewrite>
27
+
28
+ /** the fixer's rewriter: core/config-rewrite.ts in its own process, since
29
+ * loading repo.config.ts as a module is not a read the context records */
30
+ const inOwnProcess = (c: CheckCtx): ConfigRewriter => async (plan) => {
31
+ const r = await c.exec([process.execPath, CONFIG_REWRITE, c.root, JSON.stringify(plan)], { cwd: c.root, timeoutMs: 60_000 })
32
+ try {
33
+ return JSON.parse(r.out.trim().split('\n').at(-1) ?? '') as ConfigRewrite
34
+ } catch {
35
+ return { did: [], hand: [`the repo.config.ts rewrite did not answer (exit ${r.code}${r.timedOut ? ', timed out' : ''}: ${r.out.trim().slice(0, 160)}) — apply ${JSON.stringify(plan)} by hand`] }
36
+ }
37
+ }
38
+
39
+ const OWNER = 'build/shipkit/writing-a-check.md'
40
+
41
+ export const legacyCheckShapes = check({
42
+ id: 'SK016',
43
+ title: 'legacy check shapes',
44
+ description: 'every check declaration uses the current check file shape',
45
+ owner: OWNER,
46
+ // about THIS repo's own declarations, so it is a repo fact. It only ever
47
+ // warns while the window is open, which is the severity, not the axis
48
+ blocking: true,
49
+ // keyed on what it reads: the listing of check files, their sources, and
50
+ // the config. A gitignored check file is no declaration of this repo
51
+ run: (c) => {
52
+ const s = legacyShapes(c, c.config)
53
+ if (s.count === 0 && s.placeholders.length === 0) {
54
+ c.ok('no legacy declaration: every check in this repo is a check file')
55
+ return
56
+ }
57
+ // the one thing the migration cannot write: a sentence only the
58
+ // author knows. The check loads and runs meanwhile, so it is a warning
59
+ // that names each file, never a refusal
60
+ if (s.placeholders.length > 0)
61
+ c.warn(
62
+ `${s.placeholders.length} check file(s) still open with the TODO description the migration wrote: ${s.placeholders.join(', ')}`,
63
+ 'replace each TODO with one line stating what must be true',
64
+ )
65
+ if (s.count === 0) return
66
+ // one WARN per shape, never a bare line: the gate promises every line
67
+ // it prints carries a wire prefix, and this check runs in every gate
68
+ if (s.checkFiles.length > 0) {
69
+ const head = s.checkFiles.slice(0, 3).map((f) => f.rel).join(', ')
70
+ c.warn(`${s.checkFiles.length} legacy check script(s) read by the static reader: ${head}${s.checkFiles.length > 3 ? `, and ${s.checkFiles.length - 3} more` : ''}`)
71
+ const off = s.checkFiles.filter((f) => !f.read.gate).length
72
+ if (off > 0) c.warn(`${off} of them carry \`export const gate = false\`, which is \`when: 'health'\``)
73
+ }
74
+ if (s.checkDefaults) c.warn(`repo.config.ts checkDefaults lets a file forget \`blocking\` — the value belongs in each file`)
75
+ if (s.tasks.length > 0) {
76
+ const data = s.tasks.filter((x) => x.data).length
77
+ c.warn(`${s.tasks.length} \`tasks: []\` entr(ies), ${data} of them pure data — each one is a *.check.ts file with \`build: true\``)
78
+ }
79
+ if (s.rules.length > 0) c.warn(`${s.rules.length} \`rules: []\` entr(ies): ${s.rules.join(', ')} — each one is a *.check.ts file whose \`run\` is its \`lint\``)
80
+ if (s.ruleExempt.length > 0) c.warn(`ruleExempt names ${s.ruleExempt.join(', ')} — the spelling is \`when: { id: 'off:<why>' }\``)
81
+ c.warn(
82
+ `${s.count} declaration(s) in a shape the check file replaced — read through 0.40, refused after`,
83
+ 'shipkit check --fix SK016 rewrites what is mechanical and names the rest',
84
+ )
85
+ },
86
+ fix: async (c) => await fixLegacyShapes(c),
87
+ })
88
+
89
+ export default legacyCheckShapes
90
+
91
+ /** the rewrite. Everything it cannot do without guessing is NAMED in the
92
+ * summary instead of attempted, so a repo is never left half migrated.
93
+ * `rewrite` applies the config half, in its own process unless a caller
94
+ * that may load the config in process hands `rewriteConfig` itself */
95
+ export async function fixLegacyShapes(c: CheckCtx, rewrite: ConfigRewriter = inOwnProcess(c)): Promise<FixResult> {
96
+ const { root } = c
97
+ const config = c.config
98
+ const s = legacyShapes(c, config)
99
+ const todo = (rel: string): string => `${rel}: replace the TODO with one line stating what must be true`
100
+ if (s.count === 0)
101
+ return { changed: false, summary: s.placeholders.length === 0 ? 'nothing legacy left to rewrite' : `nothing mechanical left. ${s.placeholders.length} by hand: ${s.placeholders.map(todo).join(' | ')}` }
102
+
103
+ const did: string[] = []
104
+ const hand: string[] = s.placeholders.map(todo)
105
+ const source = (rel: string): string => c.read(rel) ?? ''
106
+
107
+ // THE PLAN, before any write. A check script is never rewritten: its body
108
+ // prints and exits in a process of its own, and the one body a check file
109
+ // has is `run: (c) => …` (SK020 refuses a check file that spawns itself).
110
+ // Moving prints onto `c` is a hand pass, so each script is listed with
111
+ // where it goes, and the static reader keeps running it meanwhile. The
112
+ // tasks move all together or not at all: the `tasks` key is dropped
113
+ // whole, and a task that runs a script waits for that script's port
114
+ const tasks = config.tasks ?? []
115
+ const checkRels = new Set(s.checkFiles.map((f) => f.rel))
116
+ const runsOf = new Map<TaskDef, { rel: string; rest: string[] }>()
117
+ for (const t of tasks) {
118
+ const r = t.logic == null ? checkFileRunBy(t, root) : null
119
+ if (r != null && checkRels.has(r.rel)) runsOf.set(t, r)
120
+ }
121
+ const fileIds = new Set(s.checkFiles.map((f) => f.read.name))
122
+ const configText = source('repo.config.ts')
123
+ const taskBlock: string[] = []
124
+ if (tasks.length > 0 && dropConfigKey(configText, 'tasks') == null) taskBlock.push('the `tasks` key could not be read mechanically')
125
+ for (const t of tasks) {
126
+ const runs = runsOf.get(t)
127
+ if (t.logic != null || t.cmd == null) taskBlock.push(`task ${t.id}: its body is \`logic\`, a function — move it into ${checkFileName(t.id)} beside repo.config.ts as \`run\``)
128
+ else if (runs != null) taskBlock.push(`task ${t.id}: it runs ${runs.rel}, a check script — port that file first, then drop the task: the check file is the check`)
129
+ else if (fileIds.has(t.id)) taskBlock.push(`task ${t.id}: a check file answers to the same id, and the task does not simply run that file, so the two are different checks — rename one of them`)
130
+ else if (c.exists(checkFileName(t.id))) taskBlock.push(`task ${t.id}: ${checkFileName(t.id)} already exists beside repo.config.ts, so its check file needs another name`)
131
+ }
132
+ const tasksMove = tasks.length > 0 && taskBlock.length === 0
133
+ if (!tasksMove) {
134
+ for (const l of taskBlock) hand.push(l)
135
+ if (tasks.length > 0) hand.push(`the ${tasks.length} task(s) stay in repo.config.ts until the line(s) above are done, then they move together`)
136
+ }
137
+
138
+ // 1. the check scripts, each listed with where it goes. Its declarations
139
+ // are what the static reader read, so the port loses none of them
140
+ for (const f of s.checkFiles) hand.push(`${f.rel}: ${scriptPort(f.read, config.checkDefaults?.blocking)}`)
141
+ const filesLeft = s.checkFiles.length
142
+
143
+ // 2. the tasks that are DATA, each as its own check file beside the
144
+ // config (the plan refused a name another file already holds)
145
+ for (const t of tasksMove ? tasks : []) {
146
+ const name = checkFileName(t.id)
147
+ c.write(name, taskCheckSource(t, root))
148
+ did.push(`task ${t.id} → ${name}`)
149
+ // a config entry carries no prose, so nothing can be lifted: the file
150
+ // passes the shape and a human owes it the line
151
+ hand.push(`${name}: a task declaration carried no prose — replace the TODO with one line stating what must be true`)
152
+ }
153
+
154
+ // 3. a rule is a function too, and the file it was written in owns the
155
+ // names it closes over. The fixer says where it goes, never guesses
156
+ for (const r of config.rules ?? [])
157
+ hand.push(`rule ${r.id}: move it into ${checkFileName(r.id)} beside repo.config.ts, \`lint\` becomes \`run\` and \`code\` goes (the file's own path is its closure)`)
158
+
159
+ // 4. the exemptions that were their own key, as `when` entries (the
160
+ // managed block is the one writer), then 5. the config keys, last: a
161
+ // key is only dropped once every declaration that leaned on it has a
162
+ // home. Both load the config, so they run in their own process
163
+ const cfgPlan: ConfigPlan = { when: {}, drop: [], name: config.name }
164
+ for (const [id, why] of Object.entries(config.ruleExempt ?? {})) cfgPlan.when[id] = `off:${why}`
165
+ if (s.checkDefaults && filesLeft === 0) cfgPlan.drop.push({ key: 'checkDefaults', why: 'every file now declares its own blocking' })
166
+ else if (s.checkDefaults) hand.push(`repo.config.ts \`checkDefaults\` stays until the ${filesLeft} file(s) above declare their own \`blocking\``)
167
+ if (tasksMove) cfgPlan.drop.push({ key: 'tasks', why: 'every task is a check file now' })
168
+ if (s.ruleExempt.length > 0) cfgPlan.drop.push({ key: 'ruleExempt', why: 'the exemptions are `when` entries now', afterWhen: true })
169
+ if (Object.keys(cfgPlan.when).length > 0 || cfgPlan.drop.length > 0) {
170
+ const out = await rewrite(cfgPlan)
171
+ did.push(...out.did)
172
+ hand.push(...out.hand)
173
+ if (out.restored != null)
174
+ return { changed: did.length > 0, summary: `repo.config.ts was left as it was, its rewrite did not load back (${out.restored}). Done: ${did.join('; ') || 'nothing'}` }
175
+ }
176
+
177
+ const summary = [
178
+ did.length > 0 ? did.join('; ') : 'nothing mechanical left',
179
+ ...(hand.length > 0 ? [`${hand.length} by hand: ${hand.join(' | ')}`] : []),
180
+ ].join('. ')
181
+ return { changed: did.length > 0, summary }
182
+ }