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
@@ -0,0 +1,256 @@
1
+ // the config surface describing itself: every `repo.config.ts` key, and every
2
+ // key nested under one, rendered from the JSDoc that sits above the field.
3
+ // The TYPE is the one source. A hand-written key list is a second one, and it
4
+ // drifts the day a key lands, which is how eight keys came to exist in the
5
+ // code and on no page.
6
+ //
7
+ // The reader is a tiny scanner rather than the TypeScript compiler: the shapes
8
+ // it must read are plain `export type X = { ... }` object literals, the toolkit
9
+ // already owns a module graph but not a type checker, and a config surface that
10
+ // needs a compiler to document itself is a surface nobody documents.
11
+ import { existsSync, readFileSync } from 'fs'
12
+ import { dirname, resolve } from 'path'
13
+
14
+ export type ConfigKey = {
15
+ /** dotted path from the root type, `serve.port` */
16
+ path: string
17
+ name: string
18
+ /** the type text as the source writes it */
19
+ type: string
20
+ /** false = the field carries no `?`, so a repo that declares the parent must declare this */
21
+ optional: boolean
22
+ /** the JSDoc above the field, collapsed to one line. Empty = undocumented */
23
+ doc: string
24
+ /** 1 for a top-level key */
25
+ depth: number
26
+ }
27
+
28
+ export type Member = {
29
+ name: string
30
+ optional: boolean
31
+ /** the type text, comments stripped and whitespace collapsed, for display */
32
+ type: string
33
+ /** the type text as the source wrote it, which is what the walk reads: an
34
+ * inline object keeps its line breaks, and those are its separators */
35
+ raw: string
36
+ doc: string
37
+ }
38
+
39
+ /** the type text a page prints: no nested doc comments, one line. An inline
40
+ * object separates its fields by NEWLINE in the source, so the newline
41
+ * becomes the `;` a reader expects on one line. */
42
+ export const displayType = (raw: string): string =>
43
+ raw
44
+ // one pass, whichever comment opens first owns the text
45
+ .replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '\n')
46
+ .replace(/[^\S\n]+/g, ' ')
47
+ .replace(/\s*\n+\s*/g, '; ')
48
+ .replace(/([{([,;])\s*;\s*/g, '$1 ')
49
+ .replace(/;\s*([)\]}])/g, ' $1')
50
+ .replace(/\s+/g, ' ')
51
+ .replace(/^;\s*|\s*;$/g, '')
52
+ .trim()
53
+
54
+ /** the `{ ... }` body of `type <name> = {`, braces balanced. null = no such type here */
55
+ export function typeBody(src: string, name: string): string | null {
56
+ const decl = new RegExp(String.raw`(?:^|\n)\s*(?:export\s+)?type\s+${name}\s*=\s*\{`).exec(src)
57
+ if (decl == null) return null
58
+ const open = src.indexOf('{', decl.index + decl[0].length - 1)
59
+ const end = matchBrace(src, open)
60
+ return end === -1 ? null : src.slice(open + 1, end)
61
+ }
62
+
63
+ /** index of the `}` closing the `{` at `open`, strings and comments skipped */
64
+ function matchBrace(src: string, open: number): number {
65
+ let depth = 0
66
+ for (let i = open; i < src.length; i++) {
67
+ const ch = src[i]!
68
+ if (ch === '/' && src[i + 1] === '*') { i = src.indexOf('*/', i); if (i === -1) return -1; i++; continue }
69
+ if (ch === '/' && src[i + 1] === '/') { const nl = src.indexOf('\n', i); if (nl === -1) return -1; i = nl; continue }
70
+ if (ch === "'" || ch === '"' || ch === '`') { i = skipString(src, i); continue }
71
+ if (ch === '{') depth++
72
+ else if (ch === '}' && --depth === 0) return i
73
+ }
74
+ return -1
75
+ }
76
+
77
+ function skipString(src: string, i: number): number {
78
+ const quote = src[i]!
79
+ for (let j = i + 1; j < src.length; j++) {
80
+ if (src[j] === '\\') { j++; continue }
81
+ if (src[j] === quote) return j
82
+ }
83
+ return src.length
84
+ }
85
+
86
+ const DOC_LINE = /^\s*\*\s?/
87
+
88
+ /** a JSDoc block collapsed to ONE line: the page renders one bullet per key,
89
+ * and a comment's line breaks are its own formatting, not sentences. */
90
+ export function collapseDoc(raw: string): string {
91
+ return raw
92
+ .replace(/^\/\*\*/, '')
93
+ .replace(/\*\/$/, '')
94
+ .split('\n')
95
+ .map((l) => l.replace(DOC_LINE, '').trim())
96
+ .join(' ')
97
+ .replace(/\s+/g, ' ')
98
+ .trim()
99
+ }
100
+
101
+ /** the members of one object-type body, in source order, each with the JSDoc
102
+ * that precedes it. Anything the scanner cannot read is skipped rather than
103
+ * guessed at: a wrong doc line is worse than a missing one. */
104
+ export function parseMembers(body: string): Member[] {
105
+ const out: Member[] = []
106
+ let doc = ''
107
+ let i = 0
108
+ while (i < body.length) {
109
+ const ch = body[i]!
110
+ if (ch === '/' && body[i + 1] === '*') {
111
+ const end = body.indexOf('*/', i)
112
+ const raw = body.slice(i, end === -1 ? body.length : end + 2)
113
+ doc = body[i + 2] === '*' ? collapseDoc(raw) : doc
114
+ i = end === -1 ? body.length : end + 2
115
+ continue
116
+ }
117
+ if (ch === '/' && body[i + 1] === '/') { const nl = body.indexOf('\n', i); i = nl === -1 ? body.length : nl + 1; continue }
118
+ if (/\s|;|,/.test(ch)) { i++; continue }
119
+ const m = /^(?:readonly\s+)?([A-Za-z_$][\w$]*)(\?)?\s*:/.exec(body.slice(i))
120
+ if (m == null) { i = skipToSeparator(body, i); doc = ''; continue }
121
+ const start = i + m[0].length
122
+ const end = skipToSeparator(body, start)
123
+ const raw = body.slice(start, end).trim()
124
+ out.push({ name: m[1]!, optional: m[2] != null, type: displayType(raw), raw, doc })
125
+ doc = ''
126
+ i = end
127
+ }
128
+ return out
129
+ }
130
+
131
+ /** index of the `;`, `,`, trailing `//` or newline that ends a member, at
132
+ * nesting depth 0. Generics count as nesting, or `Record<string, string>`
133
+ * ends a key halfway through its own type. */
134
+ function skipToSeparator(body: string, from: number): number {
135
+ let depth = 0
136
+ for (let i = from; i < body.length; i++) {
137
+ const ch = body[i]!
138
+ // a comment INSIDE an inline object is skipped whole: its backticks and
139
+ // apostrophes are prose, and reading them as string quotes walks the
140
+ // scanner past the member it is measuring
141
+ if (ch === '/' && body[i + 1] === '*') { const e = body.indexOf('*/', i); i = e === -1 ? body.length : e + 1; continue }
142
+ if (ch === '/' && body[i + 1] === '/') {
143
+ if (depth === 0) return i
144
+ const nl = body.indexOf('\n', i)
145
+ i = nl === -1 ? body.length : nl
146
+ continue
147
+ }
148
+ if (ch === "'" || ch === '"' || ch === '`') { i = skipString(body, i); continue }
149
+ if (ch === '{' || ch === '[' || ch === '(' || ch === '<') depth++
150
+ // `=>` is an arrow, never a closing generic
151
+ else if (ch === '}' || ch === ']' || ch === ')' || (ch === '>' && body[i - 1] !== '=')) depth--
152
+ else if (depth === 0 && (ch === ';' || ch === ',' || ch === '\n')) return i
153
+ }
154
+ return body.length
155
+ }
156
+
157
+ /** `import type { A, B } from './x.ts'` → name → absolute file. Where a type
158
+ * LIVES is what lets the walk follow `serve?: ServeConfig` into another
159
+ * module, which is where most of the nested keys are. */
160
+ export function importMap(src: string, file: string): Map<string, string> {
161
+ const out = new Map<string, string>()
162
+ const dir = dirname(file)
163
+ for (const m of src.matchAll(/import\s+(?:type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g)) {
164
+ const spec = m[2]!
165
+ if (!spec.startsWith('.')) continue
166
+ for (const raw of m[1]!.split(',')) {
167
+ const name = raw.replace(/^\s*type\s+/, '').trim().split(/\s+as\s+/)[0]
168
+ if (name != null && name !== '') out.set(name, resolve(dir, spec))
169
+ }
170
+ }
171
+ return out
172
+ }
173
+
174
+ /** the named type a member's text points at, or null when nothing can be
175
+ * followed: a function type, a union of literals, a `Record<>`, a primitive.
176
+ * Only a bare identifier (with an optional `[]`) is followed, because a
177
+ * scanner guessing its way INTO a type is how a page ends up documenting the
178
+ * wrong shape. */
179
+ export function namedTypeOf(type: string): string | null {
180
+ const t = type.replace(/\[\]$/, '').trim()
181
+ return /^[A-Z][\w$]*$/.test(t) ? t : null
182
+ }
183
+
184
+ /** the inline `{ ... }` body a member declares, or null */
185
+ export function inlineBodyOf(type: string): string | null {
186
+ const open = type.indexOf('{')
187
+ if (open === -1) return null
188
+ const end = matchBrace(type, open)
189
+ return end === -1 ? null : type.slice(open + 1, end)
190
+ }
191
+
192
+ const MAX_DEPTH = 3
193
+
194
+ /** every key of `root`, depth first, nested keys included. Each file is read
195
+ * once, and a type already on the current branch is never re-entered. */
196
+ export function configKeys(file: string, root: string, maxDepth: number = MAX_DEPTH): ConfigKey[] {
197
+ const cache = new Map<string, string>()
198
+ const read = (f: string): string => {
199
+ const hit = cache.get(f)
200
+ if (hit != null) return hit
201
+ const text = existsSync(f) ? readFileSync(f, 'utf8') : ''
202
+ cache.set(f, text)
203
+ return text
204
+ }
205
+ const out: ConfigKey[] = []
206
+ const walk = (body: string, ownerFile: string, prefix: string, depth: number, seen: string[]): void => {
207
+ for (const m of parseMembers(body)) {
208
+ const path = prefix === '' ? m.name : `${prefix}.${m.name}`
209
+ out.push({ path, name: m.name, type: m.type, optional: m.optional, doc: m.doc, depth })
210
+ if (depth >= maxDepth) continue
211
+ const inline = inlineBodyOf(m.raw)
212
+ if (inline != null) { walk(inline, ownerFile, path, depth + 1, seen); continue }
213
+ const named = namedTypeOf(m.raw)
214
+ if (named == null || seen.includes(named)) continue
215
+ const src = read(ownerFile)
216
+ const own = typeBody(src, named)
217
+ if (own != null) { walk(own, ownerFile, path, depth + 1, [...seen, named]); continue }
218
+ const from = importMap(src, ownerFile).get(named)
219
+ if (from == null) continue
220
+ const nested = typeBody(read(from), named)
221
+ if (nested != null) walk(nested, from, path, depth + 1, [...seen, named])
222
+ }
223
+ }
224
+ const rootBody = typeBody(read(file), root)
225
+ if (rootBody == null) throw new Error(`config-keys: no type ${root} in ${file}`)
226
+ walk(rootBody, file, '', 1, [root])
227
+ return out
228
+ }
229
+
230
+ /** one bullet per key, nested keys indented under their parent. The type text
231
+ * rides in the bullet because half of these keys are objects, and a name and
232
+ * a sentence do not say what to type. */
233
+ export function renderKeys(keys: ConfigKey[]): string {
234
+ const lines = keys.map((k) => {
235
+ const indent = ' '.repeat(k.depth - 1)
236
+ const sig = `\`${k.name}${k.optional ? '?' : ''}: ${k.type}\``
237
+ const required = k.optional ? '' : ' REQUIRED.'
238
+ const doc = k.doc === '' ? 'undocumented in the source' : k.doc
239
+ return `${indent}- ${sig} —${required} ${doc}`
240
+ })
241
+ return lines.join('\n')
242
+ }
243
+
244
+ /** the keys a source file declares but never documents. The page is generated,
245
+ * so a missing sentence is a defect in the TYPE, and this is what names it. */
246
+ export const undocumented = (keys: ConfigKey[]): string[] => keys.filter((k) => k.doc === '').map((k) => k.path)
247
+
248
+ export const BEGIN = '<!-- shipkit:config-keys -->'
249
+ export const END = '<!-- /shipkit:config-keys -->'
250
+
251
+ /** the second generated block on the same page: the CHECK shape. A check is not
252
+ * config, it is a file, but its fields are the other half of what a repo
253
+ * declares, and a field that lands with no doc line is a surface nobody can
254
+ * find. Generated from the type for the same reason the first block is. */
255
+ export const CHECK_BEGIN = '<!-- shipkit:check-keys -->'
256
+ export const CHECK_END = '<!-- /shipkit:check-keys -->'
@@ -0,0 +1,77 @@
1
+ // THE CONFIG HALF of the SK016 migration, in its own process. Writing the
2
+ // managed block and reading the result back both LOAD repo.config.ts as a
3
+ // module, which no check context read can stand for, so the fixer reaches it
4
+ // through `c.exec` and the check file itself never imports the loader.
5
+ //
6
+ // bun config-rewrite.ts <root> '<plan json>' → one json line on stdout
7
+ //
8
+ // The order is the migration's: the `when` entries first, then each key is
9
+ // dropped only once what leaned on it has a home, then the result is read
10
+ // back through the loader and the previous bytes go back when it does not
11
+ // survive.
12
+ import { readFileSync } from 'fs'
13
+ import { join } from 'path'
14
+ import { atomicWrite } from './locked-write.ts'
15
+ import { dropConfigKey } from './legacy-shapes.ts'
16
+ import { writeGateBlock } from './when-block.ts'
17
+ import { loadRepoConfig } from '../repo-config.ts'
18
+
19
+ export type ConfigPlan = {
20
+ /** check id → `when` value, written into the managed block */
21
+ when: Record<string, string>
22
+ /** keys to drop, in order. `afterWhen` drops only once the block took the entries */
23
+ drop: { key: string; why: string; afterWhen?: boolean }[]
24
+ /** the config's `name` before the rewrite: what the read back must still say */
25
+ name: string
26
+ }
27
+
28
+ export type ConfigRewrite = {
29
+ did: string[]
30
+ hand: string[]
31
+ /** set when the rewrite did not load back and the previous bytes went back */
32
+ restored?: string
33
+ }
34
+
35
+ export async function rewriteConfig(root: string, plan: ConfigPlan): Promise<ConfigRewrite> {
36
+ const did: string[] = []
37
+ const hand: string[] = []
38
+ let whenWritten = false
39
+ if (Object.keys(plan.when).length > 0) {
40
+ try {
41
+ await writeGateBlock(root, { when: plan.when })
42
+ whenWritten = true
43
+ did.push(`${Object.keys(plan.when).length} when entr(ies) written into the managed block`)
44
+ } catch (e) {
45
+ hand.push(`the \`when\` block refused the rewrite (${String(e).slice(0, 160)}) — write ${JSON.stringify(plan.when)} into it by hand`)
46
+ }
47
+ }
48
+ const path = join(root, 'repo.config.ts')
49
+ const before = readFileSync(path, 'utf8')
50
+ let text = before
51
+ for (const d of plan.drop) {
52
+ if (d.afterWhen === true && !whenWritten) continue
53
+ const next = dropConfigKey(text, d.key)
54
+ if (next == null) hand.push(`repo.config.ts \`${d.key}\`: ${d.why}, and the key could not be read mechanically — delete it by hand`)
55
+ else { text = next; did.push(`repo.config.ts drops \`${d.key}\``) }
56
+ }
57
+ if (text !== before) {
58
+ atomicWrite(path, text)
59
+ try {
60
+ const after = await loadRepoConfig(root, { fresh: true })
61
+ if (after.name !== plan.name) throw new Error(`the config reads as '${String(after.name)}'`)
62
+ } catch (e) {
63
+ atomicWrite(path, before)
64
+ return { did, hand, restored: String(e).slice(0, 160) }
65
+ }
66
+ }
67
+ return { did, hand }
68
+ }
69
+
70
+ if (import.meta.main) {
71
+ const [root, plan] = process.argv.slice(2)
72
+ if (root == null || plan == null) {
73
+ console.error('usage: bun config-rewrite.ts <root> <plan json>')
74
+ process.exit(2)
75
+ }
76
+ console.log(JSON.stringify(await rewriteConfig(root, JSON.parse(plan) as ConfigPlan)))
77
+ }
@@ -1,102 +1,178 @@
1
- // *.check.ts auto-discovery — THE way a repo registers its OWN checks
2
- // (built-in SK rules come from rules/rule-registry.ts): drop a
3
- // `<name>.check.ts` anywhere in the repo and `shipkit check` runs it as a
4
- // subprocess — wire
5
- // output parsed back, exit code is truth. Skipped dirs: node_modules, dist,
6
- // tmp, references (templates for other repos), and every dot-dir (.git,
7
- // caches, .rv-* contract folders).
8
-
9
- import { readdirSync, readFileSync } from 'fs'
1
+ // `*.check.ts` discovery — THE way anything gets into a gate. Drop a file
2
+ // anywhere in the repo, default-export `check({...})`, and every surface sees
3
+ // it with no config edit. The toolkit's own built-ins are the same files
4
+ // under `src/checks/`, walked by this same code, so a built-in and a repo
5
+ // check differ by `from` and by nothing else.
6
+ //
7
+ // Discovery IMPORTS each check file. That is what buys the shape a function
8
+ // body, a fixer and a typed declaration instead of four regexes, and it is why
9
+ // a check file keeps its metadata imports at the top level and its BODY's
10
+ // imports inside `run` or behind `import.meta.main`. The budget is 300ms for a
11
+ // real repo, pinned by tests/check-shape.test.ts.
12
+ //
13
+ // The suffix is shared with the legacy check SCRIPT (named `export const`
14
+ // declarations, a body that runs on import), read through 0.40 by
15
+ // core/discover-legacy-checks.ts. A script must never be imported, so the two
16
+ // are told apart by reading the source first (`isLegacyCheckSource`).
17
+ //
18
+ // Skipped dirs: node_modules, dist, tmp, references (templates for other
19
+ // repos), and every dot-dir.
20
+ import { readdirSync, readFileSync, realpathSync, statSync } from 'fs'
10
21
  import { basename, join, relative } from 'path'
22
+ import { check, type CheckDef, type CheckFrom } from '../check.ts'
23
+
24
+ import { CHECK_SUFFIX as SUFFIX, fallbackNames, isLegacyCheckSource, SKIPPED_DIRS as IGNORED_DIRS } from './check-file-names.ts'
25
+
26
+ export { fallbackNames, isLegacyCheckSource } from './check-file-names.ts'
11
27
 
12
- const IGNORED_DIRS = new Set(['node_modules', 'dist', 'tmp', 'references'])
13
- const SUFFIX = '.check.ts'
28
+ /** where the toolkit keeps its own checks. A second discovery root, never a
29
+ * registry: adding a built-in is adding a file here. */
30
+ export const BUILTIN_CHECK_DIR = join(import.meta.dir, '../checks')
31
+
32
+ /** an SK id lives in the FILE NAME, so the built-in id namespace is readable
33
+ * without importing one body. `defineRepo` needs it at config load, which
34
+ * is synchronous, and a second hand written list would drift. */
35
+ const BUILTIN_ID_RE = /^([A-Z]{2,4}\d{3})-/
14
36
 
15
37
  export type DiscoveredCheck = {
16
- /** absolute path to the check file */
38
+ /** what the file declared */
39
+ def: CheckDef
40
+ /** absolute path to the file */
17
41
  path: string
18
- /** display + cache name — file base, disambiguated by relative path on collision */
19
- name: string
20
- /** `export const blocking = true | false`, read statically. null = the file
21
- * never said, which is an error: a check that silently does not block is
22
- * the failure nobody sees. */
23
- blocking: boolean | null
24
- /** `export const inputs = ['a', 'b']`, read statically — the repo-relative
25
- * paths this check reads. Declares the CONTENT population for the ledger
26
- * key layer (ledger/git-key.ts). null = never cached, which is the safe
27
- * default and the whole point of default deny. */
28
- inputs: string[] | null
29
- /** `export const ttl = '24h'`, read statically — declares the WORLD
30
- * population: this check probes something no file on disk describes. */
31
- ttl: string | null
32
- /** `export const never = 'why'`, read statically — a DECIDED never
33
- * cacheable, which takes the check off the burn-down list. */
34
- never: string | null
35
- /** `export const gate = false`, read statically — this check is NOT part of
36
- * `shipkit ci`. `ci` answers "is my change good"; a check that answers
37
- * something else, and that can sit red for months without anything being
38
- * wrong, does not belong in the answer. `shipkit check` still runs it.
39
- * Default true: a check is in the gate unless it opts out by name. */
40
- gate: boolean
42
+ /** repo-relative path, null for a built-in: it lives outside the repo,
43
+ * and the toolchain identity already covers the toolkit's own source */
44
+ rel: string | null
45
+ /** the name every surface uses: the declared id, else the file basename,
46
+ * else the repo-relative path when two files share a basename */
47
+ id: string
48
+ title: string
49
+ /** the one sentence the file wrote about itself, always present: the
50
+ * shape refuses a check file without one */
51
+ description: string
52
+ /** where the answer is owned: a wiki page, else the file itself */
53
+ owner: string
54
+ from: CheckFrom
55
+ }
56
+
57
+ function walk(dir: string, out: string[]): void {
58
+ let entries
59
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
60
+ for (const e of entries) {
61
+ if (e.isDirectory()) {
62
+ if (!IGNORED_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name), out)
63
+ } else if (e.name.endsWith(SUFFIX)) out.push(join(dir, e.name))
64
+ }
65
+ }
66
+
67
+ /** every `*.check.ts` file under a repo root, check files and legacy
68
+ * scripts alike, by the same walk discovery takes, without importing one.
69
+ * Sorted. */
70
+ export function repoCheckFiles(root: string): string[] {
71
+ const out: string[] = []
72
+ walk(root, out)
73
+ return out.sort()
41
74
  }
42
75
 
43
- // read without importing: checks run as subprocesses, and discovery must stay
44
- // pure-fs. `export const blocking = true` (or false), nothing cleverer.
45
- // the tail is the gate's OWN formatter: oxfmt terminates every statement, and
46
- // the fix pass runs it, so a reader that refuses a semicolon refuses the shape
47
- // the gate itself just wrote.
48
- const TAIL = String.raw`(?:\s+as\s+const)?\s*;?\s*$`
49
- const BLOCKING_RE = new RegExp(String.raw`^\s*export\s+const\s+blocking\s*(?::\s*boolean\s*)?=\s*(true|false)` + TAIL, 'm')
50
- // `export const inputs = ['a', 'b']`, single or multi line. Anything cleverer
51
- // (a computed list, a spread) is deliberately unreadable here and lands as no
52
- // declaration, which means never cached: a scanner guessing at a program is
53
- // how a key comes to describe files the unit does not read.
54
- const INPUTS_RE = /^\s*export\s+const\s+inputs\s*(?::[^=]*)?=\s*\[([^\]]*)\]/m
55
- const TTL_RE = /^\s*export\s+const\s+ttl\s*(?::\s*string\s*)?=\s*['"]([^'"]+)['"]/m
56
- const NEVER_RE = /^\s*export\s+const\s+never\s*(?::\s*string\s*)?=\s*['"]([^'"]+)['"]/m
57
- // `export const gate = false` takes a check out of `shipkit ci`. Only the
58
- // literal false opts out: anything unreadable stays in the gate, because a
59
- // scanner that guesses its way to SKIPPING a check is how coverage disappears
60
- const GATE_RE = new RegExp(String.raw`^\s*export\s+const\s+gate\s*(?::\s*boolean\s*)?=\s*false` + TAIL, 'm')
61
- const STRING_ITEM_RE = /['"]([^'"]+)['"]/g
76
+ /** the same question about a file on disk. An unreadable file is a check
77
+ * file, so the import names what is wrong with it */
78
+ export function isLegacyCheckFile(path: string): boolean {
79
+ try { return isLegacyCheckSource(readFileSync(path, 'utf8')) } catch { return false }
80
+ }
62
81
 
63
- export function discoverChecks(root: string): DiscoveredCheck[] {
82
+ /** every built-in id, from the file names alone. Sync and import free, so
83
+ * `defineRepo` can read the namespace at config load. Read once per
84
+ * process: the toolkit's own files cannot appear mid run, and `defineRepo`
85
+ * asks for the list once per `when` entry. */
86
+ let builtinIds: string[] | null = null
87
+ export function builtinCheckIds(): string[] {
88
+ if (builtinIds != null) return builtinIds
64
89
  const files: string[] = []
65
- const walk = (dir: string): void => {
66
- let entries
67
- try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
68
- for (const e of entries) {
69
- if (e.isDirectory()) {
70
- if (!IGNORED_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name))
71
- } else if (e.name.endsWith(SUFFIX)) files.push(join(dir, e.name))
90
+ walk(BUILTIN_CHECK_DIR, files)
91
+ builtinIds = files.map((f) => {
92
+ const base = basename(f).slice(0, -SUFFIX.length)
93
+ return BUILTIN_ID_RE.exec(base)?.[1] ?? base
94
+ })
95
+ return builtinIds
96
+ }
97
+
98
+ /** the module, re-imported whenever the file on disk moves. An ES module is
99
+ * cached by path for the life of the process, so a repo that rewrites a
100
+ * check file mid process would keep running the old body without this. */
101
+ async function load(path: string): Promise<CheckDef> {
102
+ let stamp = ''
103
+ // no dot in the stamp: a query ending `.1234` reads as the module's
104
+ // extension, and a load plugin then never sees the file
105
+ try { stamp = String(statSync(path).mtimeMs).replace('.', '_') } catch {}
106
+ // the path rides on every message from here down, because `check()` knows
107
+ // the object and not where it was written, and an agent reading a refused
108
+ // declaration needs the file before it needs the rule
109
+ try {
110
+ // the REAL path: through a linked one, the resolver misses a file
111
+ // created after it first listed the directory, which is exactly the
112
+ // file a fix pass just wrote
113
+ let real = path
114
+ try { real = realpathSync(path) } catch {}
115
+ const mod = (await import(`${real}?v=${stamp}`)) as { default?: unknown }
116
+ const d = mod.default
117
+ if (d == null || typeof d !== 'object' || (!('run' in d) && !('cmd' in d)))
118
+ throw new Error(`a check file must \`export default check({...})\` with a \`run\` or a \`cmd\``)
119
+ // re-validated here rather than trusted: a file can build the object
120
+ // any way it likes, and only what `check()` accepts may reach a gate
121
+ return check(d as CheckDef)
122
+ } catch (e) {
123
+ throw new Error(`${path}: ${(e as Error).message.replace(/^check( \S+)?: /, '')}`)
124
+ }
125
+ }
126
+
127
+ /** every check this repo can run: its own files, plus the toolkit's. Throws
128
+ * on a registration nobody can act on (a duplicate id, a repo check squatting
129
+ * the reserved prefix, a built-in with no owner page), so a broken
130
+ * declaration dies at load rather than half way through a gate. */
131
+ export async function discoverChecks(root: string): Promise<DiscoveredCheck[]> {
132
+ const builtins: string[] = []
133
+ walk(BUILTIN_CHECK_DIR, builtins)
134
+ const found: string[] = []
135
+ walk(root, found)
136
+ // the repo that HOSTS the toolkit walks over `src/checks/` too, and a file
137
+ // discovered twice is a duplicate id rather than a second check. Real paths,
138
+ // because a consumer reaches the same files through a linked node_modules
139
+ const real = (f: string): string => { try { return realpathSync(f) } catch { return f } }
140
+ const shipped = new Set(builtins.map(real))
141
+ const all = found.filter((f) => !shipped.has(real(f))).sort()
142
+ builtins.sort()
143
+ const names = fallbackNames(root, all)
144
+ // a legacy script is left to its own reader, and never imported here
145
+ const own = all.filter((f) => !isLegacyCheckFile(f))
146
+ const base = (f: string): string => basename(f).slice(0, -SUFFIX.length)
147
+
148
+ const out: DiscoveredCheck[] = []
149
+ const ids = new Set<string>()
150
+ for (const f of [...builtins, ...own]) {
151
+ const isBuiltin = builtins.includes(f)
152
+ const def = await load(f)
153
+ const rel = isBuiltin ? null : relative(root, f).replace(/\\/g, '/')
154
+ const fallback = isBuiltin
155
+ ? (BUILTIN_ID_RE.exec(base(f))?.[1] ?? base(f))
156
+ : names.get(f)!
157
+ const id = def.id ?? fallback
158
+ if (isBuiltin) {
159
+ if (id !== fallback) throw new Error(`${f}: declares id '${id}' but its file name says '${fallback}' — a built-in id is its file name`)
160
+ if ((def.owner ?? '') === '') throw new Error(`${f}: a built-in check names the page that OWNS the invariant`)
161
+ } else if (id.startsWith('SK')) {
162
+ throw new Error(`${rel}: id '${id}' uses the reserved SK prefix — a repo check picks its own (e.g. RV${id.slice(-3)}) or plain words`)
72
163
  }
164
+ if (ids.has(id)) throw new Error(`${isBuiltin ? f : rel}: duplicate check id '${id}'`)
165
+ ids.add(id)
166
+ out.push({
167
+ def,
168
+ path: f,
169
+ rel,
170
+ id,
171
+ title: def.title ?? id,
172
+ description: def.description,
173
+ owner: def.owner ?? rel ?? f,
174
+ from: isBuiltin ? 'shipkit' : 'repo',
175
+ })
73
176
  }
74
- walk(root)
75
- files.sort()
76
- const baseOf = (f: string): string => basename(f).slice(0, -SUFFIX.length)
77
- const seen = new Map<string, number>()
78
- for (const f of files) seen.set(baseOf(f), (seen.get(baseOf(f)) ?? 0) + 1)
79
- return files.map((f) => {
80
- const base = baseOf(f)
81
- const name = (seen.get(base) ?? 0) > 1 ? relative(root, f).slice(0, -SUFFIX.length) : base
82
- let blocking: boolean | null = null
83
- let inputs: string[] | null = null
84
- let ttl: string | null = null
85
- let never: string | null = null
86
- let gate = true
87
- try {
88
- const src = readFileSync(f, 'utf8')
89
- const m = src.match(BLOCKING_RE)
90
- blocking = m == null ? null : m[1] === 'true'
91
- const im = src.match(INPUTS_RE)
92
- if (im != null) {
93
- const items = [...im[1]!.matchAll(STRING_ITEM_RE)].map((x) => x[1]!)
94
- if (items.length > 0) inputs = items
95
- }
96
- ttl = src.match(TTL_RE)?.[1] ?? null
97
- never = src.match(NEVER_RE)?.[1] ?? null
98
- gate = src.match(GATE_RE) == null
99
- } catch {}
100
- return { path: f, name, blocking, inputs, ttl, never, gate }
101
- })
177
+ return out
102
178
  }
@@ -0,0 +1,26 @@
1
+ // the legacy check SCRIPT reader, for the compatibility window: a
2
+ // `*.check.ts` with named `export const` declarations and a body that runs
3
+ // top to bottom, spawned as a subprocess, wire output parsed back, exit code
4
+ // is truth. It shares the suffix with the check file, and the source decides
5
+ // which one a file is (core/check-file-names.ts `isLegacyCheckSource`). It is
6
+ // read through the window, and SK016 names each one with the check object
7
+ // it becomes: a port by hand. Skipped dirs: the same walk as the check file's.
8
+
9
+ import { readFileSync } from 'fs'
10
+ import { fallbackNames, readLegacyCheck, type LegacyCheck } from './check-file-names.ts'
11
+ import { repoCheckFiles } from './discover-checks.ts'
12
+
13
+ export type { LegacyCheck } from './check-file-names.ts'
14
+
15
+ export function discoverLegacyChecks(root: string): LegacyCheck[] {
16
+ const all = repoCheckFiles(root)
17
+ const names = fallbackNames(root, all)
18
+ const out: LegacyCheck[] = []
19
+ for (const f of all) {
20
+ let src: string
21
+ try { src = readFileSync(f, 'utf8') } catch { continue }
22
+ const read = readLegacyCheck(f, names.get(f)!, src)
23
+ if (read != null) out.push(read)
24
+ }
25
+ return out
26
+ }
@@ -0,0 +1,13 @@
1
+ // a generated block inside a hand-written markdown page, between two html
2
+ // comment markers. Two docs pages are built this way (the command index and
3
+ // the config-key reference), and one splice keeps them honest: a renamed or
4
+ // hand-mangled host returns null, so a generator can fail loud instead of
5
+ // writing nothing.
6
+
7
+ /** replace the marked block in a host file. null = the markers are gone. */
8
+ export function spliceMarkedBlock(host: string, block: string, begin: string, end: string): string | null {
9
+ const a = host.indexOf(begin)
10
+ const b = host.indexOf(end)
11
+ if (a === -1 || b === -1 || b < a) return null
12
+ return `${host.slice(0, a + begin.length)}\n\n${block}\n\n${host.slice(b)}`
13
+ }