create-cmp-cli 0.12.0 → 0.14.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 (63) hide show
  1. package/bin/create-cmp.mjs +3 -0
  2. package/package.json +6 -2
  3. package/packages/harness/package.json +38 -0
  4. package/packages/harness/src/approve.mjs +247 -0
  5. package/packages/harness/src/arch-doc.mjs +69 -0
  6. package/packages/harness/src/comment.mjs +76 -0
  7. package/packages/harness/src/lib/a11y.mjs +113 -0
  8. package/packages/harness/src/lib/affected-tests.mjs +147 -0
  9. package/packages/harness/src/lib/approvals.mjs +1403 -0
  10. package/packages/harness/src/lib/arch-doc.mjs +451 -0
  11. package/packages/harness/src/lib/audit-cadence.mjs +290 -0
  12. package/packages/harness/src/lib/comments.mjs +252 -0
  13. package/packages/harness/src/lib/component-stories.mjs +183 -0
  14. package/packages/harness/src/lib/determinism.mjs +179 -0
  15. package/packages/harness/src/lib/device-lease.mjs +249 -0
  16. package/packages/harness/src/lib/evidence-badge.mjs +158 -0
  17. package/packages/harness/src/lib/evidence-level.mjs +117 -0
  18. package/packages/harness/src/lib/feature-brief.mjs +324 -0
  19. package/packages/harness/src/lib/flight-recorder.mjs +332 -0
  20. package/packages/harness/src/lib/harness-lock.mjs +147 -0
  21. package/packages/harness/src/lib/harness-region.mjs +159 -0
  22. package/packages/harness/src/lib/inputs-hash.mjs +194 -0
  23. package/packages/harness/src/lib/reachability.mjs +211 -0
  24. package/packages/harness/src/lib/receipt-validate.mjs +234 -0
  25. package/packages/harness/src/lib/render.mjs +254 -0
  26. package/packages/harness/src/lib/spec-coverage.mjs +131 -0
  27. package/packages/harness/src/lib/step-cache.mjs +221 -0
  28. package/packages/harness/src/lib/token-drift.mjs +94 -0
  29. package/packages/harness/src/lib/tree.mjs +108 -0
  30. package/packages/harness/src/preview-gallery.mjs +122 -0
  31. package/packages/harness/src/receipt-check.mjs +96 -0
  32. package/packages/harness/src/record-audit.mjs +83 -0
  33. package/packages/harness/src/refusal-demo.mjs +498 -0
  34. package/packages/harness/src/retrospective.mjs +51 -0
  35. package/packages/harness/src/scaffold-feature.mjs +723 -0
  36. package/packages/harness/src/setup-hooks.mjs +33 -0
  37. package/packages/harness/src/verify.mjs +1709 -0
  38. package/packages/harness/src/walkthrough.mjs +499 -0
  39. package/packages/harness/src/watch.mjs +622 -0
  40. package/packages/receipts/package.json +36 -0
  41. package/packages/receipts/src/index.mjs +16 -0
  42. package/packages/receipts/src/inputs-hash.mjs +194 -0
  43. package/packages/receipts/src/receipt-validate.mjs +234 -0
  44. package/src/commands/upgrade.mjs +383 -0
  45. package/src/lib/harness-upgrade.mjs +521 -0
  46. package/src/scaffold.mjs +60 -1
  47. package/template/AGENTS.md +5 -0
  48. package/template/CLAUDE.md +34 -1
  49. package/template/README.md +4 -0
  50. package/template/gitignore +8 -0
  51. package/template/qa/lib/audit-cadence.mjs +290 -0
  52. package/template/qa/lib/determinism.mjs +179 -0
  53. package/template/qa/lib/evidence-badge.mjs +158 -0
  54. package/template/qa/lib/flight-recorder.mjs +332 -0
  55. package/template/qa/lib/harness-lock.mjs +147 -0
  56. package/template/qa/lib/harness-region.mjs +159 -0
  57. package/template/qa/lib/inputs-hash.mjs +17 -2
  58. package/template/qa/lib/receipt-validate.mjs +1 -1
  59. package/template/qa/preview-gallery.mjs +17 -2
  60. package/template/qa/record-audit.mjs +83 -0
  61. package/template/qa/retrospective.mjs +51 -0
  62. package/template/qa/verify.mjs +400 -10
  63. package/template/qa/watch.mjs +2 -2
@@ -0,0 +1,451 @@
1
+ // arch-doc.mjs — the ARCHITECTURE.md walker (Wave B,
2
+ // docs/proposals/architecture-document-standard.md §4.2/§6). Regenerates the
3
+ // `<!-- cmp:generated <section> -->` marker blocks Wave A seeded in
4
+ // docs/ARCHITECTURE.md from a REAL walk of the tree — never hand-maintained,
5
+ // never fabricated. Pure functions, no deps beyond node builtins, mirroring
6
+ // the rest of qa/lib/'s style (approvals.mjs, tree.mjs).
7
+ //
8
+ // Four sections, each a pure `(root) => markdown` generator:
9
+ // - expect-actual-table — every top-level `expect` in commonMain, matched
10
+ // against its `actual` counterpart (by declared
11
+ // name, ignoring receiver) in androidMain/iosMain/
12
+ // desktopMain. An expect with no on-disk actual is
13
+ // reported honestly — never invented — with a
14
+ // specific note when the expect's own declaration
15
+ // names a known compiler-plugin marker type
16
+ // (`RoomDatabaseConstructor`: Room's KSP-generated
17
+ // actual never appears in source).
18
+ // - layer-file-inventory — every `.kt` file under presentation/domain/data/
19
+ // core/di, grouped by source set (commonMain +
20
+ // the three platform actual source sets), so a
21
+ // new component or a new platform actual shows up
22
+ // automatically instead of rotting a hand-counted
23
+ // "(12 files)" note.
24
+ // - adr-index — docs/adr/*.md (excluding the template), parsed
25
+ // for `# ADR-NNNN: Title` + `- **Status:** ...`.
26
+ // - glossary — NOT a term-extraction: specs/intent.md carries
27
+ // a `## Glossary` section the genesis intent
28
+ // conversation (conversation 0) fills with the
29
+ // app's domain nouns; this generator mechanically
30
+ // LIFTS that section's body verbatim (never
31
+ // parses prose for nouns itself — that would be
32
+ // guessing at vocabulary, the thing this file's
33
+ // header promises never to do).
34
+ //
35
+ // The marker grammar (`<!-- cmp:generated ID -->` … `<!-- /cmp:generated -->`)
36
+ // is generic: regenerateArchDoc() rewrites the body of every marker it finds
37
+ // whose id has a registered generator, leaves an unrecognized id untouched
38
+ // (and flags it — never silently drops a marker), and reports any registered
39
+ // section whose marker is missing from the doc entirely. Everything outside a
40
+ // marker is byte-for-byte untouched — the reconciliation rule this file exists
41
+ // to enforce: "marker content is the derivable core; judgment lives outside
42
+ // the markers."
43
+ //
44
+ // Consumers: qa/arch-doc.mjs (the CLI — thin shell, mirrors qa/approve.mjs's
45
+ // split), qa/verify.mjs (the `archDoc` lane step).
46
+
47
+ import fs from "node:fs";
48
+ import path from "node:path";
49
+
50
+ export const ARCH_DOC_REL_PATH = "docs/ARCHITECTURE.md";
51
+ export const ADR_DIR_REL_PATH = "docs/adr";
52
+ export const INTENT_REL_PATH = "specs/intent.md";
53
+
54
+ // The shipped platform source sets the layer/expect-actual walks cover —
55
+ // deliberately excludes commonTest/androidDebug/androidRelease/desktopTest:
56
+ // those are QA/dev-time source sets, not part of the documented layer model
57
+ // (§4/§5 of the doc describe the app's shipped shape).
58
+ const PLATFORM_SOURCE_SETS = ["androidMain", "iosMain", "desktopMain"];
59
+ const SOURCE_SET_KOTLIN_ROOT = {
60
+ commonMain: "composeApp/src/commonMain/kotlin",
61
+ androidMain: "composeApp/src/androidMain/kotlin",
62
+ iosMain: "composeApp/src/iosMain/kotlin",
63
+ desktopMain: "composeApp/src/desktopMain/kotlin",
64
+ };
65
+
66
+ // The five documented layers (§5 — `core` promoted to an official layer by
67
+ // Wave A / ARCH-10), in the order the doc's layer-model diagram lists them.
68
+ const LAYERS = ["presentation", "domain", "data", "core", "di"];
69
+
70
+ function toPosix(p) {
71
+ return p.split(path.sep).join("/");
72
+ }
73
+
74
+ /**
75
+ * Find the app's kotlin package directory under commonMain: a real fs walk
76
+ * for the first directory that itself contains a `presentation` subdirectory
77
+ * (every create-cmp scaffold has `presentation` as a direct child of the
78
+ * package dir — same technique inspector/mcp/src/lib/architecture.mjs uses
79
+ * for the console's layer map, duplicated here on purpose: this file ships
80
+ * inside a generated project with zero cross-package imports).
81
+ * @param {string} root
82
+ * @returns {{ packageDir: string, packageRel: string } | null} packageRel is
83
+ * POSIX-style, relative to composeApp/src/commonMain/kotlin.
84
+ */
85
+ export function findPackageDir(root) {
86
+ const kotlinRoot = path.join(root, SOURCE_SET_KOTLIN_ROOT.commonMain);
87
+ if (!fs.existsSync(kotlinRoot)) return null;
88
+ let found = null;
89
+ (function walk(dir) {
90
+ if (found) return;
91
+ let entries;
92
+ try {
93
+ entries = fs.readdirSync(dir, { withFileTypes: true });
94
+ } catch {
95
+ return;
96
+ }
97
+ if (entries.some((e) => e.isDirectory() && e.name === "presentation")) {
98
+ found = dir;
99
+ return;
100
+ }
101
+ for (const e of entries) {
102
+ if (found) return;
103
+ if (e.isDirectory()) walk(path.join(dir, e.name));
104
+ }
105
+ })(kotlinRoot);
106
+ if (!found) return null;
107
+ return { packageDir: found, packageRel: toPosix(path.relative(kotlinRoot, found)) };
108
+ }
109
+
110
+ /** Every `.kt` file under `dir` (recursive), as sorted POSIX-relative paths. */
111
+ function walkKotlinFiles(dir) {
112
+ const out = [];
113
+ let entries;
114
+ try {
115
+ entries = fs.readdirSync(dir, { withFileTypes: true });
116
+ } catch {
117
+ return out;
118
+ }
119
+ for (const e of entries) {
120
+ const abs = path.join(dir, e.name);
121
+ if (e.isDirectory()) out.push(...walkKotlinFiles(abs).map((f) => `${e.name}/${f}`));
122
+ else if (e.name.endsWith(".kt")) out.push(e.name);
123
+ }
124
+ return out.sort((a, b) => a.localeCompare(b));
125
+ }
126
+
127
+ // ── expect/actual ────────────────────────────────────────────────────────────
128
+
129
+ // Column-0 anchored on purpose: nested members of an `expect class { ... }`
130
+ // body (e.g. ` actual val isOnline: ...`) are indented and must NOT be
131
+ // mistaken for a second top-level expect/actual pair. Modifiers between the
132
+ // expect/actual keyword and the declaration kind (`expect suspend fun`,
133
+ // `expect enum class`, `actual data class`, …) are consumed so those
134
+ // declarations are not silently omitted from a table that promises "every
135
+ // top-level expect".
136
+ const DECL_RE =
137
+ /^(?:public\s+|internal\s+|private\s+)?(expect|actual)\s+(?:(?:suspend|abstract|open|sealed|data|enum|annotation|value|inline|external)\s+)*(class|fun|object|val|var|interface)\s+(?:([A-Za-z_][\w.]*)\.)?([A-Za-z_]\w*)/;
138
+
139
+ /**
140
+ * Scan every `.kt` file under `dir` for top-level expect/actual declarations.
141
+ * @returns {Array<{ keyword: "expect"|"actual", kind: string, receiver: (string|null), name: string, file: string, rawLine: string }>}
142
+ */
143
+ function scanDeclarations(dir) {
144
+ const out = [];
145
+ for (const relFile of walkKotlinFiles(dir)) {
146
+ const lines = fs.readFileSync(path.join(dir, relFile), "utf8").split("\n");
147
+ for (const line of lines) {
148
+ const m = line.match(DECL_RE);
149
+ if (!m) continue;
150
+ out.push({ keyword: m[1], kind: m[2], receiver: m[3] ?? null, name: m[4], file: relFile, rawLine: line.trim() });
151
+ }
152
+ }
153
+ return out;
154
+ }
155
+
156
+ function displayName({ kind, receiver, name }) {
157
+ const base = receiver ? `${receiver}.${name}` : name;
158
+ return kind === "fun" ? `${base}()` : base;
159
+ }
160
+
161
+ /**
162
+ * The expect/actual boundary table (§4): every top-level `expect` in
163
+ * commonMain, one row each, matched against its `actual` (by declared name,
164
+ * receiver ignored) in every platform source set. A platform column reads
165
+ * `_(no actual found in source)_` when nothing on disk matches — EXCEPT when
166
+ * the expect's own declaration line names `RoomDatabaseConstructor` (Room's
167
+ * KSP compiler plugin generates that actual at build time; it never exists as
168
+ * a source file), which gets the specific, honest note instead of a bare gap.
169
+ * @param {string} root
170
+ * @returns {string} markdown table (no trailing newline)
171
+ */
172
+ export function generateExpectActualTable(root) {
173
+ const pkg = findPackageDir(root);
174
+ if (!pkg) {
175
+ return "_No `presentation/` package directory found under `composeApp/src/commonMain/kotlin` — nothing to derive an expect/actual table from._";
176
+ }
177
+
178
+ const expects = scanDeclarations(pkg.packageDir).filter((d) => d.keyword === "expect");
179
+ if (expects.length === 0) {
180
+ return "_No top-level `expect` declarations found in `commonMain`._";
181
+ }
182
+
183
+ const actualsByPlatform = {};
184
+ for (const platform of PLATFORM_SOURCE_SETS) {
185
+ const platformDir = path.join(root, SOURCE_SET_KOTLIN_ROOT[platform], pkg.packageRel);
186
+ const actuals = fs.existsSync(platformDir) ? scanDeclarations(platformDir).filter((d) => d.keyword === "actual") : [];
187
+ const byName = new Map();
188
+ for (const a of actuals) if (!byName.has(a.name)) byName.set(a.name, a.file);
189
+ actualsByPlatform[platform] = byName;
190
+ }
191
+
192
+ const rows = [...expects].sort((a, b) => a.name.localeCompare(b.name));
193
+
194
+ const header = `| Declaration | commonMain (expect) | ${PLATFORM_SOURCE_SETS.map((p) => `${p} (actual)`).join(" | ")} |`;
195
+ const divider = `|${"---|".repeat(2 + PLATFORM_SOURCE_SETS.length)}`;
196
+ const lines = [header, divider];
197
+ for (const decl of rows) {
198
+ const cells = PLATFORM_SOURCE_SETS.map((platform) => {
199
+ const file = actualsByPlatform[platform].get(decl.name);
200
+ if (file) return `\`${file}\``;
201
+ if (/RoomDatabaseConstructor/.test(decl.rawLine)) return "_(Room KSP-generated — no actual in source)_";
202
+ return "_(no actual found in source)_";
203
+ });
204
+ lines.push(`| \`${displayName(decl)}\` | \`${decl.file}\` | ${cells.join(" | ")} |`);
205
+ }
206
+ return lines.join("\n");
207
+ }
208
+
209
+ // ── layer file inventory ────────────────────────────────────────────────────
210
+
211
+ /**
212
+ * The layer-model file inventory (§5): every `.kt` file under each of the
213
+ * five documented layers (`presentation`/`domain`/`data`/`core`/`di`), one
214
+ * bullet per layer, grouped by source set (commonMain plus whichever platform
215
+ * source sets actually have files there — e.g. `data/local`'s platform
216
+ * `DatabaseBuilder.*.kt` actuals). A layer with zero files anywhere is
217
+ * reported as such, never omitted.
218
+ * @param {string} root
219
+ * @returns {string} markdown bullet list (no trailing newline)
220
+ */
221
+ export function generateLayerFileInventory(root) {
222
+ const pkg = findPackageDir(root);
223
+ if (!pkg) {
224
+ return "_No `presentation/` package directory found under `composeApp/src/commonMain/kotlin` — nothing to derive a layer inventory from._";
225
+ }
226
+
227
+ const sourceSets = ["commonMain", ...PLATFORM_SOURCE_SETS];
228
+ const lines = [];
229
+ for (const layer of LAYERS) {
230
+ const groups = [];
231
+ for (const sourceSet of sourceSets) {
232
+ const layerDir = path.join(root, SOURCE_SET_KOTLIN_ROOT[sourceSet], pkg.packageRel, layer);
233
+ if (!fs.existsSync(layerDir)) continue;
234
+ const files = walkKotlinFiles(layerDir);
235
+ if (files.length === 0) continue;
236
+ groups.push(`${sourceSet}: ${files.map((f) => `\`${f}\``).join(", ")}`);
237
+ }
238
+ lines.push(groups.length > 0 ? `- \`${layer}/\` — ${groups.join("; ")}` : `- \`${layer}/\` — _(no files found)_`);
239
+ }
240
+ return lines.join("\n");
241
+ }
242
+
243
+ // ── ADR index ────────────────────────────────────────────────────────────────
244
+
245
+ const ADR_TITLE_RE = /^#\s*ADR-(\d+):\s*(.+?)\s*$/m;
246
+ const ADR_STATUS_RE = /^-\s*\*\*Status:\*\*\s*(.+?)\s*$/m;
247
+
248
+ /**
249
+ * The ADR index (§8): every `docs/adr/NNNN-*.md` file (the ADR template
250
+ * itself, `template.md`, is excluded — it is not a decision), parsed for its
251
+ * `# ADR-NNNN: Title` heading and `- **Status:** ...` line, sorted by ADR
252
+ * number. A file that doesn't match the expected heading grammar is skipped
253
+ * with an honest note rather than a fabricated title.
254
+ * @param {string} root
255
+ * @returns {string} markdown table (no trailing newline)
256
+ */
257
+ export function generateAdrIndex(root) {
258
+ const adrDir = path.join(root, ADR_DIR_REL_PATH);
259
+ if (!fs.existsSync(adrDir)) {
260
+ return `_No ${ADR_DIR_REL_PATH}/ directory found._`;
261
+ }
262
+ const files = fs
263
+ .readdirSync(adrDir, { withFileTypes: true })
264
+ .filter((e) => e.isFile() && e.name.endsWith(".md") && e.name !== "template.md" && /^\d{4}-/.test(e.name))
265
+ .map((e) => e.name)
266
+ .sort((a, b) => a.localeCompare(b));
267
+
268
+ if (files.length === 0) {
269
+ return "_No ADRs recorded yet._";
270
+ }
271
+
272
+ const rows = [];
273
+ const unparsed = [];
274
+ for (const file of files) {
275
+ const content = fs.readFileSync(path.join(adrDir, file), "utf8");
276
+ const titleMatch = content.match(ADR_TITLE_RE);
277
+ if (!titleMatch) {
278
+ unparsed.push(file);
279
+ continue;
280
+ }
281
+ const statusMatch = content.match(ADR_STATUS_RE);
282
+ rows.push({
283
+ id: Number.parseInt(titleMatch[1], 10),
284
+ idLabel: titleMatch[1],
285
+ file,
286
+ title: titleMatch[2],
287
+ status: statusMatch ? statusMatch[1] : "_(no Status line found)_",
288
+ });
289
+ }
290
+ rows.sort((a, b) => a.id - b.id);
291
+
292
+ const lines = ["| ADR | Title | Status |", "|---|---|---|"];
293
+ for (const r of rows) {
294
+ lines.push(`| [${r.idLabel}](./adr/${r.file}) | ${r.title} | ${r.status} |`);
295
+ }
296
+ for (const file of unparsed) {
297
+ lines.push(`\n_${file} does not match the \`# ADR-NNNN: Title\` heading grammar — skipped, not fabricated._`);
298
+ }
299
+ return lines.join("\n");
300
+ }
301
+
302
+ // ── glossary ─────────────────────────────────────────────────────────────────
303
+
304
+ // The exact placeholder text specs/intent.md ships every unfilled section
305
+ // with (qa/scaffold's seed) — presence in a section's body means that
306
+ // section hasn't been filled in by the genesis intent interview yet.
307
+ const INTENT_PLACEHOLDER_MARKER = "_not yet captured";
308
+
309
+ // specs/intent.md's `## Glossary` heading — a level-2 heading, matched at
310
+ // column 0 so it can't fire on a nested heading inside another section's
311
+ // body. The NEXT level-2 heading (or EOF) closes the section.
312
+ const GLOSSARY_HEADING_RE = /^##\s+Glossary\s*$/m;
313
+ const NEXT_HEADING_RE = /^##\s+\S/m;
314
+
315
+ /**
316
+ * The domain glossary (§8). specs/intent.md ships a `## Glossary` section
317
+ * that the genesis intent conversation (conversation 0) fills with the
318
+ * app's domain nouns (its own vocabulary — "their feature names", per
319
+ * GENESIS-FLOW-DESIGN.md). This generator does NOT extract terms from
320
+ * prose itself (that would be guessing); it mechanically LIFTS that
321
+ * section's body verbatim, the same "derived, not fabricated" contract
322
+ * every other section in this file keeps. Honest about every state: the
323
+ * file missing, the section missing (an older intent.md pre-dating this
324
+ * section), the section present but still carrying the unfilled
325
+ * placeholder, and the section genuinely filled in.
326
+ * @param {string} root
327
+ * @returns {string} markdown/prose (no trailing newline)
328
+ */
329
+ export function generateGlossary(root) {
330
+ const intentPath = path.join(root, INTENT_REL_PATH);
331
+ if (!fs.existsSync(intentPath)) {
332
+ return `_Domain glossary — \`${INTENT_REL_PATH}\` not found, so there is nothing to seed it from yet._`;
333
+ }
334
+ const content = fs.readFileSync(intentPath, "utf8");
335
+
336
+ const headingMatch = content.match(GLOSSARY_HEADING_RE);
337
+ if (!headingMatch) {
338
+ return (
339
+ `_Domain glossary — [\`${INTENT_REL_PATH}\`](../${INTENT_REL_PATH}) has no \`## Glossary\` ` +
340
+ "section to lift from yet — nothing derived._"
341
+ );
342
+ }
343
+
344
+ const afterHeading = content.slice(headingMatch.index + headingMatch[0].length);
345
+ const nextHeadingMatch = afterHeading.match(NEXT_HEADING_RE);
346
+ const body = (nextHeadingMatch ? afterHeading.slice(0, nextHeadingMatch.index) : afterHeading).trim();
347
+
348
+ if (body.length === 0 || body.includes(INTENT_PLACEHOLDER_MARKER)) {
349
+ return (
350
+ `_Domain glossary — seeded from the \`## Glossary\` section of [\`${INTENT_REL_PATH}\`](../${INTENT_REL_PATH}) ` +
351
+ "once the genesis intent interview fills it in; empty on a fresh scaffold._"
352
+ );
353
+ }
354
+
355
+ return (
356
+ `_Lifted verbatim from the \`## Glossary\` section of [\`${INTENT_REL_PATH}\`](../${INTENT_REL_PATH}) — edit it ` +
357
+ `there, not here; this block is regenerated from it.\n\n${body}`
358
+ );
359
+ }
360
+
361
+ // ── the marker grammar + regeneration ───────────────────────────────────────
362
+
363
+ export const SECTIONS = [
364
+ { id: "expect-actual-table", label: "Expect/actual boundary table", generate: generateExpectActualTable },
365
+ { id: "layer-file-inventory", label: "Layer file inventory", generate: generateLayerFileInventory },
366
+ { id: "adr-index", label: "ADR index", generate: generateAdrIndex },
367
+ { id: "glossary", label: "Domain glossary", generate: generateGlossary },
368
+ ];
369
+ export const SECTION_IDS = SECTIONS.map((s) => s.id);
370
+
371
+ const MARKER_BLOCK_RE = /<!-- cmp:generated ([a-zA-Z0-9_-]+) -->\n([\s\S]*?)<!-- \/cmp:generated -->/g;
372
+
373
+ /**
374
+ * Strip every `cmp:generated ID` marker block's BODY from `content`,
375
+ * replacing it with nothing but leaving the marker pair itself (id and all)
376
+ * in place — the doc's STRUCTURE (which sections exist, in what order) still
377
+ * counts toward whatever a caller does with the result, only the mechanically
378
+ * regenerated CONTENT is removed.
379
+ *
380
+ * This is the ONE definition of "generated" for this doc — reused (never
381
+ * forked) by qa/lib/approvals.mjs's `architecture` artifact hash basis
382
+ * (docs/proposals/architecture-document-standard.md §4.4): regenerating a
383
+ * section (`node qa/arch-doc.mjs`) must never invalidate that human approval,
384
+ * only an authored-prose edit may.
385
+ * @param {string} content
386
+ * @returns {string}
387
+ */
388
+ export function stripGeneratedSections(content) {
389
+ return content.replace(MARKER_BLOCK_RE, (_whole, id) => `<!-- cmp:generated ${id} -->\n<!-- /cmp:generated -->`);
390
+ }
391
+
392
+ /**
393
+ * Regenerate every `cmp:generated` marker section in `docRelPath` (default
394
+ * docs/ARCHITECTURE.md) from a real walk of the tree at `root` right now.
395
+ * Rewrites ONLY the bytes between a recognized marker pair — everything else
396
+ * in the file (prose, headings, unrecognized markers) passes through
397
+ * byte-for-byte untouched.
398
+ * @param {string} root
399
+ * @param {{ docRelPath?: string }} [options]
400
+ * @returns {{ ok: true, content: string, changed: boolean, changedSections: string[], missingSections: string[], unknownSections: string[] } | { ok: false, reason: string }}
401
+ */
402
+ export function regenerateArchDoc(root, options = {}) {
403
+ const docRelPath = options.docRelPath ?? ARCH_DOC_REL_PATH;
404
+ const docPath = path.join(root, docRelPath);
405
+ if (!fs.existsSync(docPath)) {
406
+ return { ok: false, reason: `${docRelPath} not found` };
407
+ }
408
+ const original = fs.readFileSync(docPath, "utf8");
409
+ const byId = new Map(SECTIONS.map((s) => [s.id, s]));
410
+ const found = new Set();
411
+ const changedSections = [];
412
+ const unknownSections = [];
413
+
414
+ const rewritten = original.replace(MARKER_BLOCK_RE, (whole, id, body) => {
415
+ found.add(id);
416
+ const section = byId.get(id);
417
+ if (!section) {
418
+ unknownSections.push(id);
419
+ return whole; // no generator registered for this id — never fabricate one
420
+ }
421
+ const generated = `${section.generate(root).replace(/\s+$/, "")}\n`;
422
+ if (generated !== body) changedSections.push(id);
423
+ return `<!-- cmp:generated ${id} -->\n${generated}<!-- /cmp:generated -->`;
424
+ });
425
+
426
+ const missingSections = SECTION_IDS.filter((id) => !found.has(id));
427
+
428
+ return {
429
+ ok: true,
430
+ content: rewritten,
431
+ changed: rewritten !== original,
432
+ changedSections,
433
+ missingSections,
434
+ unknownSections,
435
+ };
436
+ }
437
+
438
+ /**
439
+ * Regenerate and write `docRelPath` in place. No-op (returns `wrote: false`)
440
+ * when nothing would change.
441
+ * @param {string} root
442
+ * @param {{ docRelPath?: string }} [options]
443
+ */
444
+ export function writeArchDoc(root, options = {}) {
445
+ const result = regenerateArchDoc(root, options);
446
+ if (!result.ok) return result;
447
+ if (!result.changed) return { ...result, wrote: false };
448
+ const docPath = path.join(root, options.docRelPath ?? ARCH_DOC_REL_PATH);
449
+ fs.writeFileSync(docPath, result.content);
450
+ return { ...result, wrote: true };
451
+ }