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
@@ -0,0 +1,401 @@
1
+ // THE MANAGED BLOCK in `repo.config.ts`, so a switch on a page can move a
2
+ // check without a second source of truth.
3
+ //
4
+ // The config is the ONE owner of what runs in this repo. A shadow store the
5
+ // hub kept beside it would rot the first time somebody edited the file by
6
+ // hand, which is the failure mode a control room cannot afford, so the hub
7
+ // writes the config itself. It writes only the bytes between two markers, the
8
+ // pattern this toolkit already uses twice (rule SK004 inside `.gitignore`,
9
+ // `shipkit commands --out` inside a docs page).
10
+ //
11
+ // The paranoia, in order:
12
+ // 1. the file must not have moved since the caller read it (`expect`)
13
+ // 2. the block must still MATCH what the config loads, or a hand edit the
14
+ // writer cannot account for would be silently overwritten
15
+ // 3. a `when` or `tests` declared outside the block is refused, because a
16
+ // second declaration is a duplicate key rather than a merge
17
+ // 4. the value is validated the way `defineRepo` validates it, before it
18
+ // reaches the file
19
+ // 5. the result is read back through the SAME loader the cli uses, and the
20
+ // previous bytes are restored when it does not round trip
21
+ //
22
+ // No external formatter runs over the file. The block is rendered in the
23
+ // file's own indentation and quote style, and a formatter the repo scoped to
24
+ // `src/` would land a whole file diff for a one line toggle.
25
+ import { execFileSync } from 'child_process'
26
+ import { createHash } from 'crypto'
27
+ import { existsSync, readFileSync } from 'fs'
28
+ import { join } from 'path'
29
+ import { atomicWrite } from './locked-write.ts'
30
+ import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
31
+ import { parseWhen } from './when.ts'
32
+ import type { SlowSuite, TestsConfig } from '../suite/judge-suites.ts'
33
+ import { normWord, proseWordsProblems, type ProseAllowWord, type ProseBanWord, type ProseWords } from '../prose/ste.ts'
34
+
35
+ export const GATE_BEGIN = '// >>> shipkit gate — managed block, the hub writes it, edit outside the markers'
36
+ export const GATE_END = '// <<< shipkit gate'
37
+ /** the second managed block, INSIDE the `prose: {` object: the repo's own
38
+ * word lists for rule SK014. Same writer, same paranoia, its own markers */
39
+ export const WORDS_BEGIN = '// >>> shipkit prose words — managed block, the hub writes it, edit outside the markers'
40
+ export const WORDS_END = '// <<< shipkit prose words'
41
+
42
+ /** every key this block owns. A repo declaring one of them outside the
43
+ * markers keeps it, and the block refuses to write rather than duplicate it */
44
+ const OWNED = ['when', 'tests'] as const
45
+
46
+ export type BlockState = 'missing-file' | 'missing-block' | 'stale' | 'current'
47
+
48
+ export type GateBlock = {
49
+ path: string
50
+ /** sha256 of the file as it is right now — the caller hands it back on
51
+ * the write, and a mismatch means somebody else edited it meanwhile */
52
+ hash: string
53
+ state: BlockState
54
+ when: Record<string, string>
55
+ tests: TestsConfig
56
+ /** true when a key this block owns is declared outside the markers */
57
+ declaredOutside: boolean
58
+ /** the prose word lists as the config loads them */
59
+ words: ProseWords
60
+ /** the words block: `no-prose` when the config has no `prose: {` object
61
+ * to write into, and the lists then mean nothing to rule SK014 anyway */
62
+ wordsState: BlockState | 'no-prose'
63
+ /** true when `prose.words` is declared outside the words markers */
64
+ wordsOutside: boolean
65
+ }
66
+
67
+ export type GateEdit = {
68
+ /** check id → the value, or null to drop the entry */
69
+ when?: Record<string, string | null>
70
+ /** suites to declare slow, each with the case `defineRepo` demands */
71
+ slow?: SlowSuite[]
72
+ /** suite files to drop back to the quick tier */
73
+ slowRemove?: string[]
74
+ /** the prose word lists: entries to add or replace (keyed by word), and
75
+ * words to drop from either list */
76
+ words?: WordsEdit
77
+ }
78
+
79
+ export type WordsEdit = {
80
+ allow?: ProseAllowWord[]
81
+ ban?: ProseBanWord[]
82
+ /** words to drop, from whichever list holds them */
83
+ remove?: string[]
84
+ }
85
+
86
+ const sha = (s: string): string => createHash('sha256').update(s).digest('hex').slice(0, 16)
87
+
88
+ const configPath = (root: string): string => join(root, 'repo.config.ts')
89
+
90
+ /** the file's own indentation check, so the block reads like its neighbours */
91
+ function indentOf(lines: string[]): string {
92
+ for (const l of lines) {
93
+ const m = /^([ \t]+)\S/.exec(l)
94
+ if (m != null) return m[1]!
95
+ }
96
+ return ' '
97
+ }
98
+
99
+ const isBegin = (l: string): boolean => l.trim().startsWith('// >>> shipkit gate')
100
+ const isEnd = (l: string): boolean => l.trim().startsWith('// <<< shipkit gate')
101
+ const isWordsBegin = (l: string): boolean => l.trim().startsWith('// >>> shipkit prose words')
102
+ const isWordsEnd = (l: string): boolean => l.trim().startsWith('// <<< shipkit prose words')
103
+ /** the one line the words block can be written under: `prose: {` alone */
104
+ const isProseOpen = (l: string): boolean => /^\s*prose:\s*\{\s*$/.test(l)
105
+
106
+ /** a single quoted TS string literal, escapes and all */
107
+ const q = (s: string): string => `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
108
+ /** an object key: bare when it is a plain identifier, quoted otherwise */
109
+ const k = (s: string): string => (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s) ? s : q(s))
110
+
111
+ /** the bytes between the markers, markers included */
112
+ export function renderGateBlock(when: Record<string, string>, tests: TestsConfig, pad: string): string[] {
113
+ const out = [`${pad}${GATE_BEGIN}`]
114
+ const ids = Object.keys(when).sort()
115
+ if (ids.length > 0) {
116
+ out.push(`${pad}when: {`)
117
+ for (const id of ids) out.push(`${pad}${pad}${k(id)}: ${q(when[id]!)},`)
118
+ out.push(`${pad}},`)
119
+ }
120
+ const slow = tests.slow ?? []
121
+ const serial = tests.serial ?? []
122
+ if (tests.budgetMs != null || slow.length > 0 || serial.length > 0) {
123
+ out.push(`${pad}tests: {`)
124
+ if (tests.budgetMs != null) out.push(`${pad}${pad}budgetMs: ${tests.budgetMs},`)
125
+ for (const [name, entries, keys] of [['slow', slow, ['file', 'catches', 'whyNotFast']], ['serial', serial, ['file', 'why']]] as const) {
126
+ if (entries.length === 0) continue
127
+ out.push(`${pad}${pad}${name}: [`)
128
+ for (const e of entries) {
129
+ out.push(`${pad}${pad}${pad}{`)
130
+ for (const f of keys) out.push(`${pad}${pad}${pad}${pad}${f}: ${q(String((e as Record<string, unknown>)[f] ?? ''))},`)
131
+ out.push(`${pad}${pad}${pad}},`)
132
+ }
133
+ out.push(`${pad}${pad}],`)
134
+ }
135
+ out.push(`${pad}},`)
136
+ }
137
+ out.push(`${pad}${GATE_END}`)
138
+ return out
139
+ }
140
+
141
+ /** the bytes between the words markers, markers included. Entries sorted by
142
+ * word, so the block is a function of the lists and a reorder by hand reads
143
+ * as a hand edit */
144
+ export function renderWordsBlock(words: ProseWords, pad: string, unit = ' '): string[] {
145
+ const out = [`${pad}${WORDS_BEGIN}`]
146
+ const allow = [...(words.allow ?? [])].sort((a, b) => normWord(a.word).localeCompare(normWord(b.word)))
147
+ const ban = [...(words.ban ?? [])].sort((a, b) => normWord(a.word).localeCompare(normWord(b.word)))
148
+ if (allow.length > 0 || ban.length > 0) {
149
+ out.push(`${pad}words: {`)
150
+ const entry = (e: object, keys: string[]): string => {
151
+ const r = e as Record<string, unknown>
152
+ return `{ ${keys.filter((f) => typeof r[f] === 'string' && r[f] !== '').map((f) => `${f}: ${q(r[f] as string)}`).join(', ')} },`
153
+ }
154
+ const p1 = pad + unit
155
+ const p2 = p1 + unit
156
+ if (allow.length > 0) {
157
+ out.push(`${p1}allow: [`)
158
+ for (const a of allow) out.push(`${p2}${entry(a, ['word', 'reason'])}`)
159
+ out.push(`${p1}],`)
160
+ }
161
+ if (ban.length > 0) {
162
+ out.push(`${p1}ban: [`)
163
+ for (const b of ban) out.push(`${p2}${entry(b, ['word', 'to', 'reason'])}`)
164
+ out.push(`${p1}],`)
165
+ }
166
+ out.push(`${pad}},`)
167
+ }
168
+ out.push(`${pad}${WORDS_END}`)
169
+ return out
170
+ }
171
+
172
+ /** the words the loader read, in the shape the renderer writes: empty
173
+ * strings dropped, so a round trip compares content and not spelling */
174
+ const cleanWords = (w: ProseWords | undefined): ProseWords => {
175
+ const strip = <T extends object>(e: T): T => Object.fromEntries(Object.entries(e).filter(([, v]) => v != null && v !== '')) as T
176
+ const out: ProseWords = {}
177
+ if ((w?.allow ?? []).length > 0) out.allow = w!.allow!.map(strip)
178
+ if ((w?.ban ?? []).length > 0) out.ban = w!.ban!.map(strip)
179
+ return out
180
+ }
181
+
182
+ /** the indentation one level inside the line at `at` */
183
+ const innerPad = (lines: string[], at: number): string => {
184
+ const own = /^([ \t]*)/.exec(lines[at] ?? '')?.[1] ?? ''
185
+ const unit = indentOf(lines)
186
+ return own + unit
187
+ }
188
+
189
+ /** where the words block sits and what state it is in */
190
+ function locateWords(lines: string[], c: RepoConfig): { state: BlockState | 'no-prose'; b: number; e: number; open: number; pad: string; outside: boolean } {
191
+ const b = lines.findIndex(isWordsBegin)
192
+ const e = lines.findIndex(isWordsEnd)
193
+ const open = lines.findIndex(isProseOpen)
194
+ const words = cleanWords(c.prose?.words)
195
+ if (b !== -1 && e !== -1 && e > b) {
196
+ const pad = /^([ \t]*)/.exec(lines[b]!)?.[1] ?? ''
197
+ const current = lines.slice(b, e + 1).join('\n')
198
+ const expected = renderWordsBlock(words, pad, indentOf(lines)).join('\n')
199
+ return { state: current === expected ? 'current' : 'stale', b, e, open, pad, outside: false }
200
+ }
201
+ if (open === -1) return { state: 'no-prose', b: -1, e: -1, open, pad: '', outside: c.prose?.words != null }
202
+ return { state: 'missing-block', b: -1, e: -1, open, pad: innerPad(lines, open), outside: c.prose?.words != null }
203
+ }
204
+
205
+ /** is repo.config.ts edited and not yet committed? The switch leaves it
206
+ * DIRTY on purpose: a toggle is a change its owner reviews in a git client,
207
+ * never a commit an agent makes for them. So the page has to say so. */
208
+ export function configDirty(root: string): boolean {
209
+ try {
210
+ // stderr is swallowed on purpose: "not a git repository" is an
211
+ // ANSWER here, and printing it into every hub request is noise
212
+ return execFileSync('git', ['status', '--porcelain', '--', 'repo.config.ts'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() !== ''
213
+ } catch {
214
+ // no git, or no repository: dirt is unknowable rather than absent
215
+ return false
216
+ }
217
+ }
218
+
219
+ /** what the block holds today, and whether it still describes the config */
220
+ export async function readGateBlock(root: string): Promise<GateBlock> {
221
+ const path = configPath(root)
222
+ if (!existsSync(path)) return { path, hash: '', state: 'missing-file', when: {}, tests: {}, declaredOutside: false, words: {}, wordsState: 'no-prose', wordsOutside: false }
223
+ const text = readFileSync(path, 'utf8')
224
+ const lines = text.split('\n')
225
+ const b = lines.findIndex(isBegin)
226
+ const e = lines.findIndex(isEnd)
227
+ const c = await loadRepoConfig(root, { fresh: true })
228
+ const when = c.when ?? {}
229
+ const tests = c.tests ?? {}
230
+ const hash = sha(text)
231
+ const w = locateWords(lines, c)
232
+ const wordsPart = { words: cleanWords(c.prose?.words), wordsState: w.state, wordsOutside: w.outside }
233
+ if (b === -1 || e === -1 || e < b) {
234
+ // no block: the keys it owns must be unclaimed, or a write would be a
235
+ // second declaration of the same key rather than an edit of the first
236
+ const declaredOutside = OWNED.some((key) => c[key] != null)
237
+ return { path, hash, state: 'missing-block', when, tests, declaredOutside, ...wordsPart }
238
+ }
239
+ // the block DESCRIBES the config when re-rendering the loaded values
240
+ // reproduces it byte for byte. Anything else (a hand edit, a second
241
+ // declaration merging in, a formatter that reshaped it) is stale, and
242
+ // stale refuses instead of overwriting what somebody wrote
243
+ const pad = indentOf(lines.slice(b + 1, e))
244
+ const current = lines.slice(b, e + 1).join('\n')
245
+ const expected = renderGateBlock(when, tests, pad === '' ? indentOf(lines) : pad).join('\n')
246
+ return { path, hash, state: current === expected ? 'current' : 'stale', when, tests, declaredOutside: false, ...wordsPart }
247
+ }
248
+
249
+ /** does what the loader read back say what was written? BOTH keys the block
250
+ * owns, not just `when`: a `tests` entry that loads and parses differently
251
+ * (a renderer that drops a field, a loader that reshapes one) is exactly
252
+ * what reading the result back exists to catch, and comparing half of the
253
+ * block would let that half through. Key and array ORDER are the renderer's,
254
+ * so the comparison is by content. */
255
+ export function roundTripProblem(wrote: { when: Record<string, string>; tests: TestsConfig; words?: ProseWords }, loaded: RepoConfig): string | null {
256
+ const normWhen = (m: Record<string, string> | undefined): string => JSON.stringify(Object.entries(m ?? {}).sort())
257
+ const normTests = (t: TestsConfig | undefined): string =>
258
+ JSON.stringify({
259
+ budgetMs: t?.budgetMs ?? null,
260
+ slow: (t?.slow ?? []).map((s) => [s.file, s.catches, s.whyNotFast]).sort(),
261
+ serial: (t?.serial ?? []).map((s) => [s.file, s.why]).sort(),
262
+ })
263
+ if (normWhen(loaded.when) !== normWhen(wrote.when)) return `the loader read when ${JSON.stringify(loaded.when ?? {})}`
264
+ if (normTests(loaded.tests) !== normTests(wrote.tests)) return `the loader read tests ${JSON.stringify(loaded.tests ?? {})}`
265
+ if (wrote.words != null) {
266
+ const normWords = (x: ProseWords | undefined): string => {
267
+ const c = cleanWords(x)
268
+ const by = (a: { word: string }, b: { word: string }) => normWord(a.word).localeCompare(normWord(b.word))
269
+ return JSON.stringify({ allow: [...(c.allow ?? [])].sort(by), ban: [...(c.ban ?? [])].sort(by) })
270
+ }
271
+ if (normWords(loaded.prose?.words) !== normWords(wrote.words)) return `the loader read prose.words ${JSON.stringify(loaded.prose?.words ?? {})}`
272
+ }
273
+ return null
274
+ }
275
+
276
+ /** the next word lists: an entry replaces the one with the same word in
277
+ * EITHER list (moving a word from allow to ban is one edit), then `remove`
278
+ * drops words. Judged the way defineRepo judges them */
279
+ export function nextWords(before: ProseWords, edit: WordsEdit): ProseWords {
280
+ const allow = [...(before.allow ?? [])]
281
+ const ban = [...(before.ban ?? [])]
282
+ const drop = (w: string): void => {
283
+ const n = normWord(w)
284
+ for (const list of [allow, ban] as { word: string }[][]) {
285
+ const at = list.findIndex((x) => normWord(x.word) === n)
286
+ if (at !== -1) list.splice(at, 1)
287
+ }
288
+ }
289
+ const trimmed = <T extends { word: string }>(e: T): T =>
290
+ Object.fromEntries(Object.entries(e).filter(([, v]) => typeof v === 'string' && v.trim() !== '').map(([key, v]) => [key, (v as string).trim()])) as T
291
+ for (const a of edit.allow ?? []) {
292
+ if (typeof a?.word !== 'string' || normWord(a.word) === '') throw new Error(`prose.words.allow needs a word`)
293
+ drop(a.word)
294
+ allow.push(trimmed({ word: a.word, reason: a.reason }))
295
+ }
296
+ for (const b of edit.ban ?? []) {
297
+ if (typeof b?.word !== 'string' || normWord(b.word) === '') throw new Error(`prose.words.ban needs a word`)
298
+ drop(b.word)
299
+ ban.push(trimmed({ word: b.word, to: b.to, reason: b.reason }))
300
+ }
301
+ for (const w of edit.remove ?? []) drop(w)
302
+ const out: ProseWords = {}
303
+ if (allow.length > 0) out.allow = allow
304
+ if (ban.length > 0) out.ban = ban
305
+ const problems = proseWordsProblems(out)
306
+ if (problems.length > 0) throw new Error(problems[0])
307
+ return out
308
+ }
309
+
310
+ /** the ONE write path, for the switch, the slow suite form and the prose word
311
+ * lists alike. Each block is rewritten only when the edit touches it, so a
312
+ * word edit never inserts an empty gate block, and the reverse */
313
+ export async function writeGateBlock(root: string, edit: GateEdit, o: { expect?: string } = {}): Promise<GateBlock> {
314
+ const before = await readGateBlock(root)
315
+ if (before.state === 'missing-file') throw new Error(`no repo.config.ts in ${root} — run: shipkit init`)
316
+ if (o.expect != null && o.expect !== before.hash)
317
+ throw new Error(`repo.config.ts changed since you read it, reload the page and set it again, so this edit cannot overwrite one made elsewhere`)
318
+ const touchGate = edit.when != null || edit.slow != null || edit.slowRemove != null
319
+ const touchWords = edit.words != null
320
+ if (!touchGate && !touchWords) throw new Error(`nothing to write: the edit names no when, no slow suite and no prose word`)
321
+ if (touchGate && before.state === 'stale')
322
+ throw new Error(`the shipkit gate block in repo.config.ts no longer matches what the config loads, so it was hand edited or something else declares these keys — reconcile it by hand, then use the switch again`)
323
+ if (touchGate && before.state === 'missing-block' && before.declaredOutside)
324
+ throw new Error(`repo.config.ts declares \`when\` or \`tests\` outside the managed block — move that declaration between the markers first, or the switch would write a duplicate key`)
325
+ if (touchWords && before.wordsState === 'no-prose')
326
+ throw new Error(`repo.config.ts has no \`prose: {\` line to write the word lists under — declare \`prose: { include: [...] }\` first, the lists only matter to a repo rule SK014 checks`)
327
+ if (touchWords && before.wordsState === 'stale')
328
+ throw new Error(`the shipkit prose words block in repo.config.ts no longer matches what the config loads, so it was hand edited — reconcile it by hand, then edit the lists again`)
329
+ if (touchWords && before.wordsState === 'missing-block' && before.wordsOutside)
330
+ throw new Error(`repo.config.ts declares \`prose.words\` outside the managed block — move it between the markers first, or the page would write a duplicate key`)
331
+
332
+ // the next values, validated the way defineRepo validates them, BEFORE
333
+ // anything reaches the file
334
+ const when: Record<string, string> = { ...before.when }
335
+ for (const [id, raw] of Object.entries(edit.when ?? {})) {
336
+ if (raw == null) { delete when[id]; continue }
337
+ const w = parseWhen(raw)
338
+ if (typeof w === 'string') throw new Error(`when.${id} ${w}`)
339
+ when[id] = raw
340
+ }
341
+ const slow = [...(before.tests.slow ?? [])]
342
+ for (const s of edit.slow ?? []) {
343
+ for (const f of ['catches', 'whyNotFast'] as const) {
344
+ if (typeof s[f] !== 'string' || s[f].trim() === '')
345
+ throw new Error(`tests.slow ${s.file} needs ${f}: ${f === 'catches' ? 'the regression this suite catches that no fast test can' : 'why the cost cannot come down, and what you tried'}`)
346
+ }
347
+ if (typeof s.file !== 'string' || s.file.trim() === '') throw new Error(`tests.slow needs a file`)
348
+ const at = slow.findIndex((x) => x.file === s.file)
349
+ if (at === -1) slow.push({ file: s.file, catches: s.catches, whyNotFast: s.whyNotFast })
350
+ else slow[at] = { file: s.file, catches: s.catches, whyNotFast: s.whyNotFast }
351
+ }
352
+ const dropped = new Set(edit.slowRemove ?? [])
353
+ const tests: TestsConfig = { ...before.tests, slow: slow.filter((s) => !dropped.has(s.file)) }
354
+ const words = touchWords ? nextWords(before.words, edit.words!) : undefined
355
+
356
+ const text = readFileSync(before.path, 'utf8')
357
+ let out = text.split('\n')
358
+ if (touchGate) {
359
+ const lines = out
360
+ const b = lines.findIndex(isBegin)
361
+ const e = lines.findIndex(isEnd)
362
+ const pad = b === -1 ? indentOf(lines) : indentOf(lines.slice(b + 1, e)) || indentOf(lines)
363
+ const block = renderGateBlock(when, tests, pad)
364
+ if (b !== -1 && e !== -1) out = [...lines.slice(0, b), ...block, ...lines.slice(e + 1)]
365
+ else {
366
+ // the anchor is the object literal `defineRepo({` opens. Without it
367
+ // there is nothing to write into, so the block is PRINTED for a
368
+ // human to paste rather than guessed at
369
+ const anchor = lines.findIndex((l) => /defineRepo\(\s*\{\s*$/.test(l))
370
+ if (anchor === -1)
371
+ throw new Error(`repo.config.ts has no \`defineRepo({\` line to write into — paste this block inside the config object by hand:\n${block.join('\n')}`)
372
+ out = [...lines.slice(0, anchor + 1), ...block, ...lines.slice(anchor + 1)]
373
+ }
374
+ }
375
+ if (words != null) {
376
+ const lines = out
377
+ const b = lines.findIndex(isWordsBegin)
378
+ const e = lines.findIndex(isWordsEnd)
379
+ const unit = indentOf(lines)
380
+ if (b !== -1 && e > b) {
381
+ const pad = /^([ \t]*)/.exec(lines[b]!)?.[1] ?? ''
382
+ out = [...lines.slice(0, b), ...renderWordsBlock(words, pad, unit), ...lines.slice(e + 1)]
383
+ } else {
384
+ const open = lines.findIndex(isProseOpen)
385
+ out = [...lines.slice(0, open + 1), ...renderWordsBlock(words, innerPad(lines, open), unit), ...lines.slice(open + 1)]
386
+ }
387
+ }
388
+ atomicWrite(before.path, out.join('\n'))
389
+
390
+ // read it back through the loader the cli uses. A block that does not
391
+ // round trip is a broken config, so the previous bytes go back
392
+ try {
393
+ const after = await loadRepoConfig(root, { fresh: true })
394
+ const problem = roundTripProblem({ when, tests, words }, after)
395
+ if (problem != null) throw new Error(`the block did not round trip: ${problem}`)
396
+ } catch (err) {
397
+ atomicWrite(before.path, text)
398
+ throw new Error(`repo.config.ts was left as it was: ${(err as Error).message}`)
399
+ }
400
+ return await readGateBlock(root)
401
+ }
@@ -0,0 +1,135 @@
1
+ // WHERE A CHECK RUNS, in ONE declaration, keyed by check id.
2
+ //
3
+ // The question "does this run, and where" used to be answered in three
4
+ // unrelated places with three vocabularies: `ruleExempt` for a rule, `optIn`
5
+ // for a task, `export const gate` inside a check file. Nothing could read the
6
+ // three together, so no surface could show them on one row and no surface
7
+ // could write one.
8
+ //
9
+ // Four answers, and they are the words on screen:
10
+ // always every gate and every health pass
11
+ // release out of the quick gate, in at `--full` and on the release path
12
+ // health the health pass (`shipkit check`) only, never a gate
13
+ // off:<why> not run at all, and the reason is mandatory
14
+ //
15
+ // The third word says what it MEANS, never which command runs it: a value
16
+ // nobody can read is a switch nobody moves.
17
+ import type { TestTier, CheckRow } from '../ledger/gate-checks.ts'
18
+ import type { RepoConfig } from '../repo-config.ts'
19
+
20
+ export type WhenAt = 'always' | 'release' | 'health' | 'off'
21
+
22
+ /** which declaration is answering: the `when` key the switch writes, one of
23
+ * the check file's own `when`, one of the three spellings the key replaces,
24
+ * or the default nobody wrote */
25
+ export type WhenSource = 'when' | 'check-file' | 'ruleExempt' | 'optIn' | 'gate-export' | 'defaults' | 'default'
26
+
27
+ export type WhenDecl = { at: WhenAt; reason: string | null; source: WhenSource }
28
+
29
+ export const WHEN_AT: readonly WhenAt[] = ['always', 'release', 'health', 'off']
30
+
31
+ /** the words a config value may use */
32
+ const WORDS: Record<string, WhenAt> = { always: 'always', release: 'release', health: 'health' }
33
+
34
+ const DEFAULT: WhenDecl = { at: 'always', reason: null, source: 'default' }
35
+
36
+ /** one config value, or the problem with it. A string back is the message
37
+ * `defineRepo` throws, so a bad value dies at config load and never mid gate */
38
+ export function parseWhen(raw: unknown): WhenDecl | string {
39
+ if (typeof raw !== 'string') return `must be a string: always, release, health, or off:<reason>`
40
+ const v = raw.trim()
41
+ if (v === 'off' || v.startsWith('off:')) {
42
+ const reason = v.slice(4).trim()
43
+ if (reason === '') return `off needs a reason ('off:<why this repo turns it off>') — a check nobody can explain is a silenced gate`
44
+ return { at: 'off', reason, source: 'when' }
45
+ }
46
+ const at = WORDS[v]
47
+ if (at == null) return `'${v}' is not a when value (always, release, health, off:<reason>)`
48
+ return { at, reason: null, source: 'when' }
49
+ }
50
+
51
+ /** the explicit `when` entry for one id, or null. A value that does not parse
52
+ * is null here: `defineRepo` already refused it, so reaching this with one
53
+ * means the config was never loaded through it */
54
+ export function declaredWhen(c: RepoConfig, id: string): WhenDecl | null {
55
+ const raw = c.when?.[id]
56
+ if (raw == null) return null
57
+ const w = parseWhen(raw)
58
+ return typeof w === 'string' ? null : w
59
+ }
60
+
61
+ /** a rule: `when` wins, then `ruleExempt`, which is off with its reason */
62
+ export function whenOfRule(c: RepoConfig, id: string): WhenDecl {
63
+ const w = declaredWhen(c, id)
64
+ if (w != null) return w
65
+ const exempt = c.ruleExempt?.[id]
66
+ if (exempt != null) return { at: 'off', reason: exempt, source: 'ruleExempt' }
67
+ return DEFAULT
68
+ }
69
+
70
+ /** a task: `when` wins, then `optIn`, which means off the default gate and
71
+ * reachable by name (`--with <id>`), which is what `health` means for a task */
72
+ export function whenOfTask(c: RepoConfig, t: { id: string; optIn?: boolean }): WhenDecl {
73
+ const w = declaredWhen(c, t.id)
74
+ if (w != null) return w
75
+ if (t.optIn === true) return { at: 'health', reason: null, source: 'optIn' }
76
+ return DEFAULT
77
+ }
78
+
79
+ /** a floor slot the repo turned off with `defaults: { <id>: false }`. That
80
+ * declaration REMOVES the task, so it wins over any `when`: a row claiming
81
+ * always over a slot that resolves to nothing is a check the gate never
82
+ * runs and nobody can see. The source is the `defaults` block itself, and
83
+ * the reason names the line, so a page can say which declaration answered */
84
+ export function whenOfDisabledSlot(id: string): WhenDecl {
85
+ return { at: 'off', reason: `defaults.${id}: false`, source: 'defaults' }
86
+ }
87
+
88
+ /** a discovered check: the repo's `when` wins, then the file's own `when`,
89
+ * so a config can move a check whose file the repo does not own */
90
+ export function whenOfCheck(c: RepoConfig, u: { id: string; def: { when?: string } }): WhenDecl {
91
+ const w = declaredWhen(c, u.id)
92
+ if (w != null) return w
93
+ // the legacy spelling still moves a check the repo does not own, and a
94
+ // repo statement wins over the file's own default
95
+ const exempt = c.ruleExempt?.[u.id]
96
+ if (exempt != null) return { at: 'off', reason: exempt, source: 'ruleExempt' }
97
+ if (u.def.when == null) return DEFAULT
98
+ const own = parseWhen(u.def.when)
99
+ // `check()` already refused anything unparseable, so reaching this with a
100
+ // string means the object never went through it
101
+ return typeof own === 'string' ? DEFAULT : { ...own, source: 'check-file' }
102
+ }
103
+
104
+ /** a discovered check: `when` wins, then the file's own `export const gate` */
105
+ export function whenOfLegacyCheck(c: RepoConfig, d: { name: string; gate: boolean }): WhenDecl {
106
+ const w = declaredWhen(c, d.name)
107
+ if (w != null) return w
108
+ if (!d.gate) return { at: 'health', reason: null, source: 'gate-export' }
109
+ return DEFAULT
110
+ }
111
+
112
+ /** does the GATE run it at this tier? `shipkit check` asks `runsInCheck` */
113
+ export function runsInGate(w: WhenDecl, tier: TestTier): boolean {
114
+ if (w.at === 'off' || w.at === 'health') return false
115
+ return w.at === 'release' ? tier === 'full' : true
116
+ }
117
+
118
+ /** does the health pass run it? everything but off: `shipkit check` is where
119
+ * a proactive probe belongs, and only off means never */
120
+ export function runsInCheck(w: WhenDecl): boolean {
121
+ return w.at !== 'off'
122
+ }
123
+
124
+ /** the ONE line a surface prints for a check that is off */
125
+ export function offLine(id: string, w: WhenDecl): string {
126
+ return `${id} off: ${w.reason ?? '(no reason)'}`
127
+ }
128
+
129
+ /** ids in `when` that name no check of this repo. `defineRepo` can only check
130
+ * the rule namespace: a task is resolved from the floor and a check is
131
+ * discovered on disk, so this is where the rest is caught, loud */
132
+ export function unknownWhenIds(rows: CheckRow[], c: RepoConfig): string[] {
133
+ const known = new Set(rows.map((r) => r.decl.id))
134
+ return Object.keys(c.when ?? {}).filter((id) => !known.has(id))
135
+ }
@@ -3,8 +3,10 @@
3
3
  // concurrent runners never interleave — the loser skips with a WARN
4
4
  // outcome, never corrupts the file.
5
5
 
6
- import { join } from 'path'
6
+ import { mkdirSync } from 'fs'
7
+ import { dirname, join } from 'path'
7
8
  import { atomicWrite, withFileLock } from './locked-write.ts'
9
+ import { LAYOUT } from './layout.ts'
8
10
 
9
11
  export type StatusInput = {
10
12
  /** repo name — headline */
@@ -34,7 +36,8 @@ const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, '')
34
36
  const RUN_SUMMARY = /^FAIL:?\s+\d+\/\d+ failed:/
35
37
 
36
38
  export function writeStatus(root: string, s: StatusInput): StatusOutcome {
37
- const target = join(root, 'STATUS.md')
39
+ const target = join(root, LAYOUT.status)
40
+ mkdirSync(dirname(target), { recursive: true })
38
41
  const r = withFileLock(target, () => {
39
42
  const clean = s.lines.map(stripAnsi)
40
43
  const fails = clean.filter((l) => l.startsWith('FAIL') && !RUN_SUMMARY.test(l)).length
@@ -18,6 +18,8 @@
18
18
  import { createHash } from 'crypto'
19
19
  import { existsSync, readFileSync } from 'fs'
20
20
  import { basename, join } from 'path'
21
+ import { gitBin } from '../core/git-bin.ts'
22
+ import { KEYWORD_FILE, MACHINE_PRIVATE } from '../core/layout.ts'
21
23
 
22
24
  export type LeakHit = { where: string; pattern: string; file?: string }
23
25
  export type LeakWaiver = LeakHit & { reason: string; source: string }
@@ -227,11 +229,11 @@ export function leakAllowProblems(entries: unknown, where: string, tracked: bool
227
229
 
228
230
  /** the machine-global list every repo's guard also reads: one private list
229
231
  * per machine, never copied into a repo (a copy rots, and a not-yet-ignored
230
- * .rv-private/ would make the list itself committable). */
232
+ * private folder would make the list itself committable). */
231
233
  export function globalKeywordFile(): string | null {
232
234
  const home = process.env.HOME ?? ''
233
235
  if (home === '') return null
234
- const p = join(home, '.rv-private', 'banned-keywords.txt')
236
+ const p = join(home, MACHINE_PRIVATE, 'banned-keywords.txt')
235
237
  return existsSync(p) ? p : null
236
238
  }
237
239
 
@@ -240,7 +242,7 @@ export function globalKeywordFile(): string | null {
240
242
  export function globalAllowFile(): string | null {
241
243
  const home = process.env.HOME ?? ''
242
244
  if (home === '') return null
243
- const p = join(home, '.rv-private', 'leak-allow.json')
245
+ const p = join(home, MACHINE_PRIVATE, 'leak-allow.json')
244
246
  return existsSync(p) ? p : null
245
247
  }
246
248
 
@@ -263,7 +265,7 @@ export function loadAllowFile(path: string, tracked: boolean): { entries: Loaded
263
265
  }
264
266
 
265
267
  export type LeakGuardOpts = {
266
- /** path to the keyword file, relative to root (default '.rv-private/banned-keywords.txt') */
268
+ /** path to the keyword file, relative to root (default .shipkit/private/banned-keywords.txt) */
267
269
  keywordFile?: string
268
270
  /** extra text to scan (a commit message, a release note) */
269
271
  text?: { label: string; content: string }[]
@@ -334,10 +336,10 @@ export function addedLinesByFile(diff: string): { file: string; content: string
334
336
  }
335
337
 
336
338
  export function runLeakGuard(root: string, o: LeakGuardOpts = {}): LeakGuardResult {
337
- const rel = o.keywordFile ?? '.rv-private/banned-keywords.txt'
339
+ const rel = o.keywordFile ?? KEYWORD_FILE
338
340
  const file = join(root, rel)
339
341
  const sources = [existsSync(file) ? file : null, globalKeywordFile()].filter((f): f is string => f != null)
340
- if (sources.length === 0) return { status: 'skipped', reason: `${rel} not found and no ~/.rv-private/banned-keywords.txt — no keyword list, no leak guard` }
342
+ if (sources.length === 0) return { status: 'skipped', reason: `${rel} not found and no machine-global banned-keywords.txt — no keyword list, no leak guard` }
341
343
  const patterns = parsePatterns(sources.map((f) => readFileSync(f, 'utf8')).join('\n'))
342
344
  const count = patterns.substrings.length + patterns.words.length + patterns.regexes.length
343
345
  if (count === 0) return { status: 'skipped', reason: `keyword file(s) have no patterns (${sources.join(', ')})` }
@@ -358,11 +360,11 @@ export function runLeakGuard(root: string, o: LeakGuardOpts = {}): LeakGuardResu
358
360
 
359
361
  const targets: ScanTarget[] = []
360
362
  if (o.staged !== false) {
361
- const diff = Bun.spawnSync(['git', 'diff', '--cached'], { cwd: root, maxBuffer: 256 * 1024 * 1024 })
363
+ const diff = Bun.spawnSync([gitBin(), 'diff', '--cached'], { cwd: root, maxBuffer: 256 * 1024 * 1024 })
362
364
  if (diff.exitCode === 0)
363
365
  for (const f of addedLinesByFile(diff.stdout.toString())) targets.push({ where: 'staged content', file: f.file, content: f.content })
364
366
  // a filename can leak as loudly as a line of code
365
- const names = Bun.spawnSync(['git', 'diff', '--cached', '--name-only'], { cwd: root })
367
+ const names = Bun.spawnSync([gitBin(), 'diff', '--cached', '--name-only'], { cwd: root })
366
368
  if (names.exitCode === 0) for (const n of names.stdout.toString().split('\n')) if (n.trim() !== '') targets.push({ where: 'staged path', file: n, content: n })
367
369
  }
368
370
  for (const t of o.text ?? []) targets.push({ where: t.label, content: t.content })
@@ -1,16 +1,17 @@
1
1
  #!/usr/bin/env bun
2
2
  // the PreToolUse guard for ONE WAY IN (core/one-way-in-policy.ts owns the
3
- // table and the wording). Its ONE job is to refuse a raw runner with the
4
- // shipkit command to type instead: `permissions.deny` already blocks these,
5
- // but a bare deny tells the model only that it was denied, and a refusal with
6
- // no replacement just sends it looking for another way in.
3
+ // table and the wording). It refuses a raw runner with the shipkit command to
4
+ // type instead: `permissions.deny` already blocks these, but a bare deny tells
5
+ // the model only that it was denied, and a refusal with no replacement just
6
+ // sends it looking for another way in. It also refuses a gate piped into
7
+ // another command, whose exit code the pipe hides.
7
8
  //
8
9
  // It runs before EVERY Bash call, so it stays a leaf: one tiny import, one
9
10
  // small json read, no clipanion, no repo config. Anything it cannot decide it
10
11
  // waves through, because a guard that fails closed on its own bugs would wedge
11
12
  // a repo with no way to tell why, and `permissions.deny` is still the floor
12
13
  // underneath it. Measured at ~10ms per call.
13
- import { deniedHeads, rawRunnerHit, refusal } from '../core/one-way-in-policy.ts'
14
+ import { deniedHeads, pipedGateIn, pipedGateRefusal, rawRunnerHit, refusal } from '../core/one-way-in-policy.ts'
14
15
 
15
16
  const input = await Bun.stdin.text()
16
17
  let command = ''
@@ -21,6 +22,13 @@ try {
21
22
  } catch {
22
23
  process.exit(0)
23
24
  }
25
+ // a piped gate is refused whatever the deny list says: it is not a raw
26
+ // runner, it is a verdict whose exit code the tail of the pipe replaces
27
+ const piped = pipedGateIn(command)
28
+ if (piped != null) {
29
+ console.error(pipedGateRefusal(piped))
30
+ process.exit(2)
31
+ }
24
32
  // the settings file is the authority on WHICH runners this repo refuses, so
25
33
  // deleting one `Bash(...)` line lifts the deny and this refusal together
26
34
  const settingsPath = `${process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd()}/.claude/settings.json`