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
@@ -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
@@ -12,6 +12,7 @@
12
12
  // so the fix travels through that one code path.
13
13
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
14
14
  import { join } from 'path'
15
+ import { detectPackageManager, isRefusal } from '../core/package-manager.ts'
15
16
 
16
17
  export type Pkg = { name?: string; version?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
17
18
 
@@ -140,3 +141,18 @@ export async function npmLatest(name: string): Promise<string | null> {
140
141
  if (!res.ok) return null
141
142
  return ((await res.json()) as Pkg).version ?? null
142
143
  }
144
+
145
+ /** the first release whose SK005 updates through the manager the repo's lock
146
+ * files name. An older one runs bun whatever the lock says */
147
+ export const PM_AWARE_SINCE = '1.0.0'
148
+
149
+ /** why `fleet --update` must not hand this repo its own update, or null.
150
+ * The fleet runs the REPO's shipkit, so what matters is what THAT binary
151
+ * does: one older than PM_AWARE_SINCE updates an npm repo with bun */
152
+ export function updateRefusal(r: FleetRepo): string | null {
153
+ const pm = detectPackageManager(r.dir)
154
+ if (isRefusal(pm)) return `cannot tell which package manager installs it: ${pm.refused}`
155
+ const own = r.deps.find((d) => d.name === 'rvlib-shipkit')?.installed
156
+ if (pm.name === 'bun' || own == null || !isBehind(own, PM_AWARE_SINCE)) return null
157
+ return `its shipkit ${own} updates with bun whatever the lock says, and it installs with ${pm.name}. Run: cd ${r.dir} && ${pm.update(r.behind.map((d) => d.name)).join(' ')}`
158
+ }