rvlib-shipkit 0.38.1 → 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 +367 -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 +209 -15
  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
package/src/prose/ste.ts CHANGED
@@ -26,6 +26,7 @@ export type ProseClass =
26
26
  | 'sentence'
27
27
  | 'passive'
28
28
  | 'paragraph'
29
+ | 'ban'
29
30
 
30
31
  export type ProseHit = {
31
32
  line: number // 1-based, true to the file
@@ -36,6 +37,38 @@ export type ProseHit = {
36
37
  text: string // the offending span
37
38
  fix?: string // the replacement, when the class is mechanical
38
39
  why: string // one line, what STE wants instead
40
+ /** the dictionary entry that fired, lowercased: what a repo `allow` entry
41
+ * names and what the hub groups by. Absent on the classes that judge a
42
+ * shape (sentence length, paragraph, semicolon) rather than a word */
43
+ word?: string
44
+ }
45
+
46
+ /** a word or phrase this repo accepts: every hit on it is dropped */
47
+ export type ProseAllowWord = {
48
+ /** the word or phrase, case ignored. A dictionary entry (`should`,
49
+ * `leverage`) covers its inflected forms too */
50
+ word: string
51
+ /** one line: why this repo accepts it */
52
+ reason?: string
53
+ }
54
+
55
+ /** a word or phrase this repo refuses on top of the house dictionary */
56
+ export type ProseBanWord = {
57
+ /** the word or phrase, case ignored, matched on word boundaries */
58
+ word: string
59
+ /** what to write instead. Given, `shipkit prose --fix` writes it */
60
+ to?: string
61
+ /** one line: why this repo refuses it */
62
+ reason?: string
63
+ }
64
+
65
+ /** the per repo word lists. The house dictionary in this file stays the
66
+ * default, and these override it for one repo only */
67
+ export type ProseWords = {
68
+ /** words and phrases this repo accepts */
69
+ allow?: ProseAllowWord[]
70
+ /** words and phrases this repo refuses, each with its replacement */
71
+ ban?: ProseBanWord[]
39
72
  }
40
73
 
41
74
  export type ProseOpts = {
@@ -43,6 +76,8 @@ export type ProseOpts = {
43
76
  allow?: ProseClass[]
44
77
  /** procedural limit (20) instead of the descriptive one (25) */
45
78
  strict?: boolean
79
+ /** the repo's own allow and ban lists */
80
+ words?: ProseWords
46
81
  }
47
82
 
48
83
  const TIER: Record<ProseClass, ProseTier> = {
@@ -50,13 +85,16 @@ const TIER: Record<ProseClass, ProseTier> = {
50
85
  abbrev: 'fix',
51
86
  slop: 'fix',
52
87
  modal: 'soft',
53
- semicolon: 'hard',
88
+ // reports, never blocks: the join carries meaning a rewrite can lose
89
+ semicolon: 'soft',
54
90
  'comma-ing': 'soft',
55
91
  perfect: 'soft',
56
92
  vague: 'hard',
57
93
  sentence: 'soft',
58
94
  passive: 'soft',
59
95
  paragraph: 'soft',
96
+ // the repo named the word itself, so a hit is never a guess
97
+ ban: 'hard',
60
98
  }
61
99
 
62
100
  export const PROSE_CLASSES = Object.keys(TIER) as ProseClass[]
@@ -201,8 +239,6 @@ export function maskProse(src: string): string {
201
239
  return m
202
240
  }
203
241
 
204
- /** a table row is structure: word classes still apply inside a cell, sentence
205
- * and paragraph limits do not. Same for a heading. */
206
242
  /** `is unmaintained` is a state, not a passive voice: there is no verb `to
207
243
  * unmaintain`, so no actor exists to name. The `un-` prefix catches almost
208
244
  * the whole family; the rest are participles coined off a noun. */
@@ -215,7 +251,20 @@ const isAdjectival = (participle: string): boolean => {
215
251
  return (w.startsWith('un') && w.endsWith('ed')) || COINED.has(w) || NOT_PARTICIPLES.has(w)
216
252
  }
217
253
 
218
- const isStructureLine = (l: string): boolean => /^\s*\|/.test(l) || /^\s*#{1,6}\s/.test(l) || /^\s*[-|: ]+$/.test(l)
254
+ /** a table row is structure: word classes still apply inside a cell, the
255
+ * sentence, paragraph and semicolon rules do not. A cell is not a sentence,
256
+ * so "write two sentences" asks for the table to be broken, and its items
257
+ * are already comma separated, which leaves the semicolon as the only
258
+ * separator the writer has left. */
259
+ const isTableRow = (l: string): boolean => /^\s*\|/.test(l)
260
+
261
+ /** a list item OPENS a new paragraph: a run of items with no blank line
262
+ * between them is a list, never a wall of text, and counting the run as one
263
+ * paragraph reported a five item list as 38 sentences. Both numbered forms,
264
+ * at any indentation. */
265
+ const isListItem = (l: string): boolean => /^\s*(?:[-*+]|\d+[.)])\s/.test(l)
266
+
267
+ const isStructureLine = (l: string): boolean => isTableRow(l) || /^\s*#{1,6}\s/.test(l) || /^\s*[-|: ]+$/.test(l)
219
268
 
220
269
  // --- sentence segmentation ----------------------------------------------
221
270
 
@@ -249,8 +298,13 @@ const capitalizeLike = (sample: string, replacement: string): string =>
249
298
 
250
299
  const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
251
300
 
301
+ /** one spelling for a word or phrase, so `Prior To` and `prior to` are the
302
+ * same entry */
303
+ export const normWord = (w: string): string => w.trim().toLowerCase().replace(/\s+/g, ' ')
304
+
252
305
  export function checkProse(src: string, opts: ProseOpts = {}): ProseHit[] {
253
306
  const allow = new Set(opts.allow ?? [])
307
+ const allowWords = new Set((opts.words?.allow ?? []).map((a) => normWord(a.word)))
254
308
  const masked = maskProse(src)
255
309
  const hits: ProseHit[] = []
256
310
  // line index → offset, once
@@ -267,10 +321,19 @@ export function checkProse(src: string, opts: ProseOpts = {}): ProseHit[] {
267
321
  return lo + 1
268
322
  }
269
323
 
270
- const push = (cls: ProseClass, start: number, end: number, why: string, fix?: string) => {
324
+ const push = (cls: ProseClass, start: number, end: number, why: string, fix?: string, word?: string) => {
271
325
  if (allow.has(cls)) return
272
326
  const text = src.slice(start, end)
273
- hits.push({ line: lineOf(start), start, end, cls, tier: TIER[cls], text, fix, why })
327
+ if (allowWords.size > 0 && (allowWords.has(normWord(text)) || (word != null && allowWords.has(word)))) return
328
+ hits.push({ line: lineOf(start), start, end, cls, tier: TIER[cls], text, fix, why, ...(word == null ? {} : { word }) })
329
+ }
330
+
331
+ /** the whole masked line an offset sits on, for the classes that need the
332
+ * line's SHAPE (a table row, a list item) and not only the span */
333
+ const lineAt = (off: number): string => {
334
+ const from = masked.lastIndexOf('\n', off) + 1
335
+ const to = masked.indexOf('\n', off)
336
+ return masked.slice(from, to < 0 ? masked.length : to)
274
337
  }
275
338
 
276
339
  const scan = (re: RegExp, run: (m: RegExpExecArray) => void) => {
@@ -285,11 +348,11 @@ export function checkProse(src: string, opts: ProseOpts = {}): ProseHit[] {
285
348
  // --- fix tier
286
349
  scan(new RegExp(`\\b(${Object.keys(CONTRACTIONS).map(escapeRe).join('|')})`, 'gi'), (m) => {
287
350
  const full = CONTRACTIONS[m[0].toLowerCase()]
288
- if (full != null) push('contraction', m.index, m.index + m[0].length, 'STE keeps the full form', capitalizeLike(m[0], full))
351
+ if (full != null) push('contraction', m.index, m.index + m[0].length, 'STE keeps the full form', capitalizeLike(m[0], full), m[0].toLowerCase())
289
352
  })
290
353
  scan(new RegExp(`(?<![\\w.])(${Object.keys(ABBREV).map(escapeRe).join('|')})`, 'gi'), (m) => {
291
354
  const full = ABBREV[m[0].toLowerCase()]
292
- if (full != null) push('abbrev', m.index, m.index + m[0].length, 'write the words a non-native reader can read', capitalizeLike(m[0], full))
355
+ if (full != null) push('abbrev', m.index, m.index + m[0].length, 'write the words a non-native reader can read', capitalizeLike(m[0], full), m[0].toLowerCase())
293
356
  })
294
357
  // a verb entry matches its inflected forms too, and the ending rides onto
295
358
  // the replacement
@@ -301,12 +364,12 @@ export function checkProse(src: string, opts: ProseOpts = {}): ProseHit[] {
301
364
  const stem = e.from.endsWith('e') ? e.from.slice(0, -1) : e.from
302
365
  scan(new RegExp(`\\b(?:${escapeRe(e.from)}(es|s|ed|d)?|${escapeRe(stem)}(ing))\\b`, 'gi'), (m) => {
303
366
  const to = inflect(e.to, (m[1] ?? m[2] ?? '').toLowerCase())
304
- push('slop', m.index, m.index + m[0].length, `STE writes '${e.to}'`, capitalizeLike(m[0], to))
367
+ push('slop', m.index, m.index + m[0].length, `STE writes '${e.to}'`, capitalizeLike(m[0], to), e.from)
305
368
  })
306
369
  }
307
370
  scan(new RegExp(`\\b(${plain.map((x) => escapeRe(x.from)).join('|')})\\b`, 'gi'), (m) => {
308
371
  const e = plain.find((x) => x.from === m[0]!.toLowerCase())
309
- if (e != null) push('slop', m.index, m.index + m[0].length, `STE writes '${e.to}'`, capitalizeLike(m[0], e.to))
372
+ if (e != null) push('slop', m.index, m.index + m[0].length, `STE writes '${e.to}'`, capitalizeLike(m[0], e.to), e.from)
310
373
  })
311
374
  // a deletion gives back exactly one space, and WHICH one depends on what
312
375
  // follows: before punctuation the space in front goes with the word, or
@@ -323,16 +386,19 @@ export function checkProse(src: string, opts: ProseOpts = {}): ProseHit[] {
323
386
  if (before === ',' && after === ',') end += 1
324
387
  const punctuationFollows = /[.,:;!?)\]]/.test(after)
325
388
  const fix = lead === '' ? undefined : punctuationFollows ? '' : lead
326
- push('slop', m.index, end, 'the word carries no fact', fix)
389
+ push('slop', m.index, end, 'the word carries no fact', fix, normWord(m[2]!))
327
390
  })
328
391
  for (const p of PHRASE_SUBSTITUTIONS)
329
- scan(new RegExp(p.re.source, 'gi'), (m) => push('slop', m.index, m.index + m[0].length, p.why, capitalizeLike(m[0], p.to)))
392
+ scan(new RegExp(p.re.source, 'gi'), (m) => push('slop', m.index, m.index + m[0].length, p.why, capitalizeLike(m[0], p.to), normWord(m[0].replace(/,\s*$/, ''))))
330
393
 
331
394
  // --- hard tier
332
395
  scan(new RegExp(`\\b(${MODALS.join('|')})\\b`, 'gi'), (m) =>
333
- push('modal', m.index, m.index + m[0].length, "a requirement is 'must', a possibility is 'can', a recommendation is a fact or it is deleted"),
396
+ push('modal', m.index, m.index + m[0].length, "a requirement is 'must', a possibility is 'can', a recommendation is a fact or it is deleted", undefined, m[0].toLowerCase()),
334
397
  )
335
- scan(/;/g, (m) => push('semicolon', m.index, m.index + 1, 'write two sentences'))
398
+ scan(/;/g, (m) => {
399
+ if (isTableRow(lineAt(m.index))) return
400
+ push('semicolon', m.index, m.index + 1, 'write two sentences')
401
+ })
336
402
  scan(/,\s+(\w+ing)\b/g, (m) => {
337
403
  if (/^(nothing|something|anything|everything|during|string|thing)$/i.test(m[1]!)) return
338
404
  // an `-ing` word used as an ADJECTIVE (`, missing path`) is not the
@@ -345,17 +411,31 @@ export function checkProse(src: string, opts: ProseOpts = {}): ProseHit[] {
345
411
  // `Renaming a file, moving a responsibility, or changing any contract`
346
412
  // is a coordinated list of gerund NOUNS, not the participial clause
347
413
  // the rule bans. The tell is a second gerund behind `and` or `or`.
348
- const line = masked.slice(masked.lastIndexOf('\n', m.index) + 1, (masked.indexOf('\n', m.index) + 1 || masked.length + 1) - 1)
349
- if (/\b(and|or)\s+\w+ing\b/.test(line)) return
414
+ if (/\b(and|or)\s+\w+ing\b/.test(lineAt(m.index))) return
350
415
  push('comma-ing', m.index, m.index + m[0].length, "STE bans an '-ing' verb after a comma: start a new sentence, or write the relative clause")
351
416
  })
352
417
  scan(new RegExp(`\\b(has|have|had)\\s+(been|\\w+ed|${PARTICIPLES})\\b`, 'gi'), (m) =>
353
- push('perfect', m.index, m.index + m[0].length, 'STE uses simple tenses only'),
418
+ push('perfect', m.index, m.index + m[0].length, 'STE uses simple tenses only', undefined, normWord(m[0])),
354
419
  )
355
420
  scan(new RegExp(`\\b(${Object.keys(VAGUE).map(escapeRe).join('|')})\\b`, 'gi'), (m) => {
356
421
  const why = VAGUE[m[0].toLowerCase()]
357
- if (why != null) push('vague', m.index, m.index + m[0].length, why)
422
+ if (why != null) push('vague', m.index, m.index + m[0].length, why, undefined, m[0].toLowerCase())
358
423
  })
424
+ // the repo's own ban list: hard, because the repo named the word itself.
425
+ // A house hit on the same span gives way, so one word reports once and
426
+ // with the repo's own replacement
427
+ const banned: [number, number][] = []
428
+ for (const b of opts.words?.ban ?? []) {
429
+ const w = normWord(b.word)
430
+ if (w === '') continue
431
+ const body = w.split(' ').map(escapeRe).join('\\s+')
432
+ scan(new RegExp(`(?<![\\w'-])${body}(?![\\w'-])`, 'gi'), (m) => {
433
+ if (allow.has('ban')) return
434
+ banned.push([m.index, m.index + m[0].length])
435
+ const why = `this repo refuses '${w}'${b.to != null && b.to !== '' ? `, write '${b.to}'` : ''}${b.reason != null && b.reason !== '' ? `: ${b.reason}` : ''}`
436
+ push('ban', m.index, m.index + m[0].length, why, b.to != null && b.to !== '' ? capitalizeLike(m[0], b.to) : undefined, w)
437
+ })
438
+ }
359
439
 
360
440
  // --- soft tier, line by line
361
441
  const limit = opts.strict === true ? 20 : 25
@@ -385,21 +465,24 @@ export function checkProse(src: string, opts: ProseOpts = {}): ProseHit[] {
385
465
  let pm: RegExpExecArray | null
386
466
  while ((pm = passRe.exec(line)) != null) {
387
467
  if (isAdjectival(pm[2]!)) continue
388
- push('passive', off + pm.index, off + pm.index + pm[0].length, 'STE wants the active voice: name the actor')
389
- }
390
- const isBullet = /^\s*(?:[-*+]|\d+\.)\s/.test(line)
391
- if (!isBullet) {
392
- if (paraStart < 0) paraStart = i
468
+ push('passive', off + pm.index, off + pm.index + pm[0].length, 'STE wants the active voice: name the actor', undefined, normWord(pm[0]))
393
469
  }
470
+ const item = isListItem(line)
471
+ if (item) flushParagraph(i)
472
+ else if (paraStart < 0) paraStart = i
394
473
  for (const s of splitSentences(line)) {
395
474
  const w = wordCount(s.text)
396
- if (!isBullet && paraStart >= 0 && w > 0) paraSentences++
475
+ if (!item && paraStart >= 0 && w > 0) paraSentences++
397
476
  if (w > limit)
398
477
  push('sentence', off + s.at, off + s.at + s.text.length, `${w} words: the limit is ${limit}`)
399
478
  }
400
479
  }
401
480
  flushParagraph(lines.length)
402
481
 
482
+ if (banned.length > 0) {
483
+ const underBan = (h: ProseHit): boolean => h.cls !== 'ban' && h.word != null && banned.some(([a, b]) => h.start < b && a < h.end)
484
+ for (let i = hits.length - 1; i >= 0; i--) if (underBan(hits[i]!)) hits.splice(i, 1)
485
+ }
403
486
  return hits.sort((a, b) => a.start - b.start)
404
487
  }
405
488
 
@@ -434,3 +517,36 @@ export function fixProse(src: string, opts: ProseOpts = {}): { text: string; app
434
517
  return { text, applied }
435
518
  }
436
519
 
520
+ /** what is wrong with a repo's word lists, judged the same way by the config
521
+ * loader and by the hub's writer: an empty word, a word listed twice, or a
522
+ * word both allowed and banned, which leaves the repo's intent undecidable */
523
+ export function proseWordsProblems(w: ProseWords | undefined): string[] {
524
+ if (w == null) return []
525
+ if (typeof w !== 'object' || Array.isArray(w)) return ['prose.words is an object: { allow?: [...], ban?: [...] }']
526
+ const out: string[] = []
527
+ for (const k of Object.keys(w)) if (k !== 'allow' && k !== 'ban') out.push(`prose.words.${k} is not a key (keys: allow, ban)`)
528
+ const seen = new Map<string, 'allow' | 'ban'>()
529
+ for (const list of ['allow', 'ban'] as const) {
530
+ const entries: unknown = w[list] ?? []
531
+ if (!Array.isArray(entries)) {
532
+ out.push(`prose.words.${list} is a list`)
533
+ continue
534
+ }
535
+ for (const e of entries as { word?: unknown; to?: unknown; reason?: unknown }[]) {
536
+ if (e == null || typeof e.word !== 'string' || normWord(e.word) === '') {
537
+ out.push(`prose.words.${list} has an entry with no word`)
538
+ continue
539
+ }
540
+ const fields = list === 'ban' ? ['word', 'to', 'reason'] : ['word', 'reason']
541
+ for (const f of Object.keys(e)) if (!fields.includes(f)) out.push(`prose.words.${list} '${e.word}' has an unknown field '${f}' (fields: ${fields.join(', ')})`)
542
+ for (const f of ['to', 'reason'] as const) if (e[f] != null && typeof e[f] !== 'string') out.push(`prose.words.${list} '${e.word}' ${f} is a string`)
543
+ const n = normWord(e.word)
544
+ const was = seen.get(n)
545
+ if (was === list) out.push(`prose.words.${list} lists '${n}' twice`)
546
+ else if (was != null) out.push(`prose.words lists '${n}' in both allow and ban, pick one`)
547
+ seen.set(n, list)
548
+ }
549
+ }
550
+ return out
551
+ }
552
+
@@ -0,0 +1,81 @@
1
+ // The `.shipkit/reflections/` queue: friction a repo logged against the wiki it
2
+ // follows, waiting for a fresh agent to fold each file into the owning page
3
+ // and DELETE it. Deletion is the ack, so a file that is still here is still
4
+ // pending. Contract: meta/wiki-doctrine in the docs wiki this lib ships
5
+ // beside.
6
+ //
7
+ // Two kinds. A file asking for a wiki RULE change carries six sections; a
8
+ // plain friction NOTE carries no shape at all. Sections match on HEADING-ish
9
+ // lines only: a substring scan of the whole file called any note mentioning
10
+ // "proposed change" a malformed request, and satisfied `scope claimed` from
11
+ // ordinary prose.
12
+ import { existsSync, readdirSync, readFileSync } from 'fs'
13
+ import { join } from 'path'
14
+ import { LAYOUT } from '../core/layout.ts'
15
+ import { LEGACY_CONTRACT_PREFIX } from '../core/migrate-layout.ts'
16
+
17
+ export const REFLECTIONS_DIR = LAYOUT.reflections
18
+
19
+ /** where a repo's queue sits. A harvest across repos reads some that have not
20
+ * run the new shipkit yet, whose queue is still in the old folder. This read
21
+ * dies once `shipkit fleet` shows no repo on the old layout */
22
+ const queueDir = (root: string): string => {
23
+ const legacy = join(root, `${LEGACY_CONTRACT_PREFIX}reflections`)
24
+ return !existsSync(join(root, REFLECTIONS_DIR)) && existsSync(legacy) ? legacy : join(root, REFLECTIONS_DIR)
25
+ }
26
+
27
+ export const REQUEST_SECTIONS = [
28
+ 'what i was doing',
29
+ 'what the wiki said',
30
+ 'what actually happened',
31
+ 'owns it',
32
+ 'proposed change',
33
+ 'scope claimed',
34
+ ] as const
35
+
36
+ export type ReflectionKind = { isRequest: boolean; missing: string[] }
37
+
38
+ /** heading lines: markdown headings, or a line leading with **bold** (the
39
+ * shape as wiki-doctrine writes it) */
40
+ const headingLines = (text: string): string[] =>
41
+ text
42
+ .split('\n')
43
+ .filter((l) => /^\s*(#{1,6}\s+|\*\*|[-*]\s+\*\*)/.test(l))
44
+ .map((l) => l.toLowerCase())
45
+
46
+ export function classifyReflection(text: string): ReflectionKind {
47
+ const heads = headingLines(text)
48
+ const has = (section: string): boolean => heads.some((h) => h.includes(section))
49
+ // it announces itself by PROPOSING something, in a heading
50
+ if (!has('proposed change')) return { isRequest: false, missing: [] }
51
+ return { isRequest: true, missing: REQUEST_SECTIONS.filter((s) => !has(s)) }
52
+ }
53
+
54
+ export type Reflection = ReflectionKind & { file: string; title: string; unreadable: boolean }
55
+
56
+ /** the first heading, which is the line whoever logged it wrote to name it */
57
+ const firstHeading = (text: string): string | null =>
58
+ text.split('\n').find((l) => /^\s*#{1,6}\s+\S/.test(l))?.replace(/^\s*#{1,6}\s+/, '').trim() ?? null
59
+
60
+ /** every file still pending. README.md is the scaffold's own seed, written
61
+ * into every started repo, so counting it makes every fresh repo a
62
+ * permanent false row */
63
+ export function listReflections(root: string): Reflection[] {
64
+ let files: string[] = []
65
+ try {
66
+ files = readdirSync(queueDir(root)).filter((f) => f.endsWith('.md') && f !== 'README.md')
67
+ } catch {
68
+ return []
69
+ }
70
+ return files.sort().map((file): Reflection => {
71
+ let text: string | null = null
72
+ try {
73
+ text = readFileSync(join(queueDir(root), file), 'utf8')
74
+ } catch {
75
+ // reported as unreadable rather than dropped: a file nobody can
76
+ // read is still a file nobody harvested
77
+ }
78
+ if (text == null) return { file, title: file, isRequest: false, missing: [], unreadable: true }
79
+ return { file, title: firstHeading(text) ?? file, unreadable: false, ...classifyReflection(text) }
80
+ })
81
+ }
@@ -3,9 +3,11 @@
3
3
  // Deliberately dependency-free (no config load, no ci) so a check script can
4
4
  // import it without dragging the gate in.
5
5
  import type { RepoConfig } from '../repo-config.ts'
6
+ import { gitBin } from '../core/git-bin.ts'
7
+ import { CI_LOG_DIR, LAYOUT } from '../core/layout.ts'
6
8
 
7
9
  export function git(root: string, args: string[]): { code: number; out: string } {
8
- const p = Bun.spawnSync(['git', ...args], { cwd: root })
10
+ const p = Bun.spawnSync([gitBin(), ...args], { cwd: root })
9
11
  return { code: p.exitCode, out: `${p.stdout.toString()}${p.stderr.toString()}`.trim() }
10
12
  }
11
13
 
@@ -27,16 +29,20 @@ export function deriveRemote(root: string): { remote: string } | { error: string
27
29
  return { error: `branch '${branch === '' ? '(detached)' : branch}' has no upstream and ${remotes.length} remotes exist (${remotes.join(', ')}) — set one with git push -u <remote> <branch>` }
28
30
  }
29
31
 
30
- /** the worktree's dirty paths, minus the runner's own log dir. running the
31
- * gate creates that dir, so counting it would make every gated release refuse
32
- * in any repo that does not gitignore it. */
32
+ /** the worktree's dirty paths, minus the runner's own scratch (`.shipkit/tmp/`,
33
+ * and a `logDir` set elsewhere). running the gate writes its logs, leases and job records there, so counting them would
34
+ * make every gated release refuse in any repo that does not gitignore it.
35
+ * `-uall` lists every untracked file, so a folder git would fold into one
36
+ * row never hides real dirt beside the scratch */
33
37
  export function dirtyPaths(root: string, config: RepoConfig, pathspec?: string | string[]): string {
34
- const logRoot = (config.logDir ?? 'tmp/shipkit-ci').replace(/^\.\//, '').split('/')[0]!
38
+ const logDir = (config.logDir ?? CI_LOG_DIR).replace(/^\.\//, '').replace(/\/+$/, '')
39
+ const scratch = [LAYOUT.tmp, ...(logDir.startsWith(`${LAYOUT.tmp}/`) ? [] : [logDir.split('/')[0]!])]
40
+ const own = (p: string): boolean => scratch.some((d) => p.startsWith(`${d}/`) || p === d)
35
41
  const specs = (typeof pathspec === 'string' ? [pathspec] : (pathspec ?? [])).filter((p) => p !== '')
36
- const args = specs.length > 0 ? ['status', '--porcelain', '--', ...specs] : ['status', '--porcelain']
42
+ const args = specs.length > 0 ? ['status', '--porcelain', '-uall', '--', ...specs] : ['status', '--porcelain', '-uall']
37
43
  return git(root, args).out
38
44
  .split('\n')
39
- .filter((l) => l.trim() !== '' && !l.slice(3).trim().replace(/^"|"$/g, '').startsWith(`${logRoot}/`) && l.slice(3).trim() !== `${logRoot}/`)
45
+ .filter((l) => l.trim() !== '' && !own(l.slice(3).trim().replace(/^"|"$/g, '')))
40
46
  .join('\n')
41
47
  }
42
48
 
@@ -66,7 +72,7 @@ export type RemoteTagRefs = { refs: Map<string, string> } | { error: string }
66
72
  * because `ls-remote <pattern>` drops the peeled `^{}` line and leaves only
67
73
  * the tag OBJECT id, which differs between two tags of the same commit. */
68
74
  export function remoteTagRefs(root: string, remote: string): RemoteTagRefs {
69
- const p = Bun.spawnSync(['git', 'ls-remote', '--tags', remote], { cwd: root, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
75
+ const p = Bun.spawnSync([gitBin(), 'ls-remote', '--tags', remote], { cwd: root, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } })
70
76
  if (p.exitCode !== 0) return { error: `${p.stderr.toString()}${p.stdout.toString()}`.trim().split('\n').at(-1) ?? 'ls-remote failed' }
71
77
  const refs = new Map<string, string>()
72
78
  for (const line of p.stdout.toString().split('\n')) {
@@ -134,7 +140,7 @@ export function mergeProbe(root: string, tip: string, from: string): MergeProbe
134
140
  return { state: 'unknown', reason: `${reason} (needs git 2.38+)` }
135
141
  }
136
142
 
137
- /** local tags matching any of the release units' tag shapes that the remote
143
+ /** local tags matching any of the releasing units' tag shapes that the remote
138
144
  * does not have. A stranded tag is invisible where he looks (GitHub), so it
139
145
  * is a failure, not a note. */
140
146
  export function strandedTags(p: { root: string; globs: string[]; remote: string }): { stranded: string[]; local: string[] } | { error: string } {
@@ -33,10 +33,11 @@ import { declaredKind, hasUnreleasedNotes, promoteUnreleased } from './changelog
33
33
  import { deriveRemote, dirtyPathList, dirtyPaths, git as gitAt, mergeProbe, remoteTags, tagExistsLocally } from './git-facts.ts'
34
34
  import { unitBlockers } from './unit-gate.ts'
35
35
  import { releaseScope } from './release-scope.ts'
36
- import { formatUnitError, releaseOrder, renderTag, resolveUnits, unitById, unitPrepublishScripts, type ReleaseUnit } from './units.ts'
36
+ import { formatUnitError, releaseOrder, renderTag, resolveUnits, unitById, unitPrepublishScripts, unknownUnit, type ReleaseUnit } from './units.ts'
37
37
  import { environmentBlockers, runReleaseGate, releaseUnit } from './run-release.ts'
38
38
  import type { RepoConfig } from '../repo-config.ts'
39
39
  import { nestedIn, nestedRefusal } from '../core/nesting.ts'
40
+ import { LAYOUT } from '../core/layout.ts'
40
41
 
41
42
  export type BumpKind = 'major' | 'minor' | 'patch'
42
43
 
@@ -234,7 +235,7 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind |
234
235
  if (o.all !== true) {
235
236
  const id = o.unit ?? 'root'
236
237
  if (unitById(onDisk.units, id) == null) {
237
- print(`FAIL: no release unit '${id}' — known units: ${onDisk.units.map((x) => x.id).join(', ')}`)
238
+ print(`FAIL: ${unknownUnit(onDisk, id)}`)
238
239
  print(' fix: shipkit releases')
239
240
  return 1
240
241
  }
@@ -249,7 +250,7 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind |
249
250
 
250
251
  // surfaces the toolkit itself rewrites on gate runs, safe to auto-commit
251
252
  const machineWrittenPaths = (r: string, c: RepoConfig): Set<string> => {
252
- const s = new Set(['STATUS.md'])
253
+ const s = new Set<string>([LAYOUT.status])
253
254
  if (c.docsBase != null) {
254
255
  const base = c.docsBase.startsWith('~/') ? join(homedir(), c.docsBase.slice(2)) : c.docsBase
255
256
  const page = join(base, 'build/shipkit/local-projects.md')
@@ -372,7 +373,7 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind |
372
373
  } else {
373
374
  const id = o.unit ?? 'root'
374
375
  const u = unitById(all, id)
375
- if (u == null) return fail(`no release unit '${id}' after the merge — known units: ${all.map((x) => x.id).join(', ')}`, 'shipkit releases')
376
+ if (u == null) return fail(`${unknownUnit(resolved, id)} (after the merge)`, 'shipkit releases')
376
377
  picked = [u]
377
378
  }
378
379
 
@@ -430,7 +431,7 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind |
430
431
  // gates cost N times as much and prove the same thing. Frozen, and fatal
431
432
  // on non-blocking failures when the repo declared requireHealthy.
432
433
  // releaseUnit then skips it, because bump already proved this exact tree.
433
- if ((await runReleaseGate(root, config, print, { fix: true })) !== 0) return fail('ci gate is red on the merged result — fix it, then bump')
434
+ if ((await runReleaseGate(root, config, print, { fix: true, units: picked })) !== 0) return fail('ci gate is red on the merged result — fix it, then bump')
434
435
  // the gate's fix pass can rewrite files. writing a version on top of that
435
436
  // produces a version commit that excludes the rewrite and a release that
436
437
  // then refuses on a dirty tree, which is a half-released repo.
@@ -13,9 +13,10 @@ import { extractChangelogSection } from './changelog.ts'
13
13
  import { deriveRemote, dirtyPaths, git, remoteTagState, tagExistsLocally, type RemoteTagState } from './git-facts.ts'
14
14
  import { releaseScope } from './release-scope.ts'
15
15
  import { npmPublishArgv, unitBlockers } from './unit-gate.ts'
16
- import { formatUnitError, resolveUnits, unitById, type ReleaseUnit } from './units.ts'
16
+ import { formatUnitError, resolveUnits, unitById, unknownUnit, type ReleaseUnit } from './units.ts'
17
17
  import type { RepoConfig } from '../repo-config.ts'
18
18
  import { nestedIn, nestedRefusal } from '../core/nesting.ts'
19
+ import { declaredTestUnits, testCheckId } from '../suite/test-units.ts'
19
20
 
20
21
  // adding a FOURTH knob here is the signal that this shape wants rethinking
21
22
  // rather than another key (meta/gaps.md, release-path config keys accreting)
@@ -29,7 +30,7 @@ export type ReleaseConfig = {
29
30
  /** publish the ROOT unit to npm. Default: its package.json is not private.
30
31
  * A private package NEVER publishes, whatever this says. */
31
32
  npm?: boolean
32
- /** argv printing an npm auth token on stdout (e.g. a secret-manager read).
33
+ /** argv printing an npm auth token on stdout, as in a secret-manager read.
33
34
  * Absent = rely on ambient npm auth. Never put a token in config.
34
35
  * Repo-wide: one account publishes every unit. */
35
36
  npmTokenCommand?: string[]
@@ -43,10 +44,9 @@ export type ReleaseConfig = {
43
44
  /** repo-relative tsconfig for the ROOT unit's own typecheck (default
44
45
  * '<root>/tsconfig.json' when it exists) */
45
46
  tsconfig?: string
46
- /** task ids the release gate skips. A monorepo's repo-wide task (every
47
- * package's suite, a full matrix build) re-proves for six units what the
48
- * released unit's own gate already proved for one. Naming them here is
49
- * the repo saying which tasks are about the REPO rather than the release. */
47
+ /** check ids the release gate skips: a repo-wide task (a full matrix
48
+ * build) that is about the REPO rather than the release. Which unit test
49
+ * checks a release runs is not this key's: → build/shipkit/units.md */
50
50
  skipTasks?: string[]
51
51
  /** `shipkit bump` run from a feature branch merges it into the release
52
52
  * branch first, with a merge commit. Default true. A branch is a GATE, so
@@ -106,7 +106,7 @@ export type ReleaseOpts = {
106
106
  * while the rules ran from another downgrades a blocking failure to a warning.
107
107
  *
108
108
  * it also runs the FULL test tier, which `shipkit ci` skips while you work. */
109
- export async function runReleaseGate(root: string, config: RepoConfig, print: (l: string) => void, o: { fix?: boolean } = {}): Promise<number> {
109
+ export async function runReleaseGate(root: string, config: RepoConfig, print: (l: string) => void, o: { fix?: boolean; units?: ReleaseUnit[] } = {}): Promise<number> {
110
110
  return await runCi(root, {
111
111
  fix: o.fix ?? false,
112
112
  frozen: true,
@@ -127,18 +127,32 @@ export async function runReleaseGate(root: string, config: RepoConfig, print: (l
127
127
  // a DNS provider answers, and asking spends an API call per publish.
128
128
  // requireHealthy is the repo saying it wants them fatal, so then they run.
129
129
  onlyBlockingChecks: config.release?.requireHealthy !== true,
130
- skipTaskIds: config.release?.skipTasks ?? [],
130
+ // a sibling unit's suite proves nothing about a sub-unit's tarball: each
131
+ // unit's test check runs when THAT unit ships, or when the root does
132
+ skipTaskIds: [...(config.release?.skipTasks ?? []), ...otherUnitTests(config, o.units)],
131
133
  failOnAdvisory: config.release?.requireHealthy === true,
132
134
  print: (l) => print(` ${l}`),
133
135
  })
134
136
  }
135
137
 
138
+ /** the test checks of every sub-unit this release does not ship. No list
139
+ * given means the caller did not say, and every unit's suite runs. The root
140
+ * depends on what it contains, so a release that ships it runs them all. A
141
+ * `release: false` unit is never skipped: it ships nothing of its own, so its
142
+ * suites exist to prove something that does, and nothing says which */
143
+ export function otherUnitTests(config: RepoConfig, shipping: ReleaseUnit[] | undefined): string[] {
144
+ if (shipping == null || shipping.some((u) => u.id === 'root')) return []
145
+ const ids = new Set(shipping.map((u) => u.id))
146
+ return declaredTestUnits(config.units).filter((u) => u.releases && !ids.has(u.id)).map((u) => testCheckId(u.id))
147
+ }
148
+
136
149
  /** `shipkit release [unit]` — resolve the unit, then ship it. */
137
150
  export async function runRelease(root: string, config: RepoConfig, o: ReleaseOpts = {}): Promise<number> {
138
151
  const print = o.print ?? ((l: string) => console.log(l))
139
152
  const nested = nestedIn(root)
140
153
  if (nested != null) { print(`FAIL: ${nestedRefusal(nested)}`); return 1 }
141
- const { units, errors } = resolveUnits(root, config)
154
+ const resolution = resolveUnits(root, config)
155
+ const { units, errors } = resolution
142
156
  const id = o.unit ?? 'root'
143
157
  // only THIS unit's own resolution has to hold: a broken sibling is not a
144
158
  // reason to refuse a release of something that resolves fine
@@ -146,7 +160,7 @@ export async function runRelease(root: string, config: RepoConfig, o: ReleaseOpt
146
160
  if (mine.length > 0) { for (const e of mine) print(`FAIL: ${formatUnitError(e)}`); return 1 }
147
161
  const unit = unitById(units, id)
148
162
  if (unit == null) {
149
- print(`FAIL: no release unit '${id}' — known units: ${units.map((u) => u.id).join(', ')}`)
163
+ print(`FAIL: ${unknownUnit(resolution, id)}`)
150
164
  print(' fix: shipkit releases')
151
165
  return 1
152
166
  }
@@ -254,7 +268,7 @@ export async function releaseUnit(root: string, config: RepoConfig, unit: Releas
254
268
  if (o.skipGate === true)
255
269
  print(o.skipGateReason != null ? `ok: ci gate ${o.skipGateReason}` : 'WARN ci gate SKIPPED by --skip-gate — publishing unverified')
256
270
  else {
257
- if ((await runReleaseGate(root, config, print)) !== 0) { print('FAIL: ci gate is red — fix it, commit, then release'); return 1 }
271
+ if ((await runReleaseGate(root, config, print, { units: [unit] })) !== 0) { print('FAIL: ci gate is red — fix it, commit, then release'); return 1 }
258
272
  // frozen is a declaration, not a guarantee: any check may still write.
259
273
  // npm packs the DIRECTORY, so a rewrite here would publish a tarball
260
274
  // that exists in no commit while the tag describes another tree.
@@ -1,4 +1,4 @@
1
- // `shipkit releases` — where every release unit actually stands. Report only:
1
+ // `shipkit releases` — where every releasing unit actually stands. Report only:
2
2
  // it never gates, never writes, and exits 0 even when everything is drifted,
3
3
  // because it exists to end the "what is published and what is not" question,
4
4
  // not to add another red light.
@@ -249,6 +249,6 @@ export function renderReports(reports: UnitReport[], o: { remoteError?: string |
249
249
  // once rather than once per cell
250
250
  if (o.offline === true) lines.push('note: --offline, so npm, the remote and gh were not read (the ? cells)')
251
251
  const subs = reports.length - 1
252
- lines.push(`note: ${reports.length} release unit(s) — the repo root${subs > 0 ? ` + ${subs} declared` : ' only, nothing else declared'}`)
252
+ lines.push(`note: ${reports.length} releasing unit(s) — the repo root${subs > 0 ? ` + ${subs} declared` : ' only, nothing else declared'}`)
253
253
  return lines
254
254
  }