create-cmp-cli 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -2
- package/packages/harness/package.json +38 -0
- package/packages/harness/src/approve.mjs +247 -0
- package/packages/harness/src/arch-doc.mjs +69 -0
- package/packages/harness/src/comment.mjs +76 -0
- package/packages/harness/src/lib/a11y.mjs +113 -0
- package/packages/harness/src/lib/affected-tests.mjs +147 -0
- package/packages/harness/src/lib/approvals.mjs +1403 -0
- package/packages/harness/src/lib/arch-doc.mjs +451 -0
- package/packages/harness/src/lib/audit-cadence.mjs +290 -0
- package/packages/harness/src/lib/comments.mjs +252 -0
- package/packages/harness/src/lib/component-stories.mjs +183 -0
- package/packages/harness/src/lib/determinism.mjs +179 -0
- package/packages/harness/src/lib/device-lease.mjs +249 -0
- package/packages/harness/src/lib/evidence-badge.mjs +158 -0
- package/packages/harness/src/lib/evidence-level.mjs +117 -0
- package/packages/harness/src/lib/feature-brief.mjs +324 -0
- package/packages/harness/src/lib/flight-recorder.mjs +332 -0
- package/packages/harness/src/lib/harness-lock.mjs +147 -0
- package/packages/harness/src/lib/harness-region.mjs +159 -0
- package/packages/harness/src/lib/inputs-hash.mjs +194 -0
- package/packages/harness/src/lib/reachability.mjs +211 -0
- package/packages/harness/src/lib/receipt-validate.mjs +234 -0
- package/packages/harness/src/lib/render.mjs +254 -0
- package/packages/harness/src/lib/spec-coverage.mjs +131 -0
- package/packages/harness/src/lib/step-cache.mjs +221 -0
- package/packages/harness/src/lib/token-drift.mjs +94 -0
- package/packages/harness/src/lib/tree.mjs +108 -0
- package/packages/harness/src/preview-gallery.mjs +122 -0
- package/packages/harness/src/receipt-check.mjs +96 -0
- package/packages/harness/src/record-audit.mjs +83 -0
- package/packages/harness/src/refusal-demo.mjs +498 -0
- package/packages/harness/src/retrospective.mjs +51 -0
- package/packages/harness/src/scaffold-feature.mjs +723 -0
- package/packages/harness/src/setup-hooks.mjs +33 -0
- package/packages/harness/src/verify.mjs +1723 -0
- package/packages/harness/src/walkthrough.mjs +499 -0
- package/packages/harness/src/watch.mjs +622 -0
- package/packages/receipts/package.json +36 -0
- package/packages/receipts/src/index.mjs +16 -0
- package/packages/receipts/src/inputs-hash.mjs +194 -0
- package/packages/receipts/src/receipt-validate.mjs +234 -0
- package/src/commands/upgrade.mjs +115 -1
- package/src/lib/harness-upgrade.mjs +193 -5
- package/src/scaffold.mjs +60 -1
- package/template/AGENTS.md +5 -0
- package/template/CLAUDE.md +30 -0
- package/template/gitignore +8 -0
- package/template/qa/lib/harness-lock.mjs +147 -0
- package/template/qa/lib/harness-region.mjs +159 -0
- package/template/qa/lib/inputs-hash.mjs +1 -1
- package/template/qa/lib/receipt-validate.mjs +1 -1
- package/template/qa/preview-gallery.mjs +17 -2
- package/template/qa/verify.mjs +110 -2
- package/template/.gradle/8.11.1/checksums/checksums.lock +0 -0
- package/template/.gradle/8.11.1/fileChanges/last-build.bin +0 -0
- package/template/.gradle/8.11.1/fileHashes/fileHashes.lock +0 -0
- package/template/.gradle/8.11.1/gc.properties +0 -0
- package/template/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/template/.gradle/buildOutputCleanup/cache.properties +0 -2
- package/template/.gradle/vcs-1/gc.properties +0 -0
|
@@ -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
|
+
}
|