create-cmp-cli 0.10.1 → 0.11.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.
@@ -0,0 +1,324 @@
1
+ // feature-brief.mjs — feature briefs and DERIVED doneness.
2
+ //
3
+ // A feature brief is `docs/features/<name>.md`: the why of a feature — the
4
+ // decisions with their rationale ("the day boundary is a configurable
5
+ // dayStartHour, default 04:00 — not midnight, because…"), the research, the
6
+ // rejected options. Location is the governance opt-in: every doc in
7
+ // docs/features/ is a governed `feature-brief:<name>` artifact, hashed and
8
+ // signed like anything else, approved BEFORE the feature is built. (Harness
9
+ // design standards stay in docs/proposals/ — different directory, different
10
+ // meaning.) `<name>` matches the feature's spec: docs/features/meal.md pairs
11
+ // with specs/meal.spec.md.
12
+ //
13
+ // The brief carries at most ONE machine-read block, and it declares — it never
14
+ // gates:
15
+ //
16
+ // ```json cmp:feature
17
+ // { "touches": ["components", "design-system"], "screens": true }
18
+ // ```
19
+ //
20
+ // `touches` is the declared blast radius: the governed artifacts this feature
21
+ // expects to invalidate. The artifact hashes already enforce; declaring lets
22
+ // the console tell "re-approval, as planned" apart from undeclared blast.
23
+ // `screens` declares a UI surface: this feature will have its own screens, so
24
+ // the walk holds a design gate (feature-design:<name> — signed on RENDERED
25
+ // output) between the brief and the behavior contract, BEFORE any screen file
26
+ // exists. Like touches it declares, never gates: once presentation/<name>/
27
+ // screen files exist on disk, the gate derives from them regardless.
28
+ //
29
+ // DONENESS IS DERIVED, NEVER CLAIMED. This file's earlier incarnation
30
+ // (intent-checks.mjs) let the agent assert delivery over its own grep checks —
31
+ // a weaker parallel truth beside the strong one the harness already maintains:
32
+ // clause ↔ citing test ↔ lane gate ↔ receipt. That mechanism is gone. A
33
+ // feature is provably done when, mechanically:
34
+ //
35
+ // 1. its spec has live clauses, and every one is cited by a test
36
+ // (spec-coverage.mjs — the same scan the lane's specCoverage gate runs),
37
+ // 2. the latest receipt's verdict is PASS, and
38
+ // 3. the receipt's inputs.hash matches a recompute of the tree RIGHT NOW —
39
+ // evidence must attest execution of *this* code, not some earlier tree.
40
+ //
41
+ // No new lane step needed: specCoverage already fails uncovered clauses and
42
+ // the test steps already fail broken promises. What remains for humans is
43
+ // judgment, not verification: approving the brief (before code) and accepting
44
+ // the feature (after proof) — acceptFeature in approvals.mjs refuses until
45
+ // provenDone is true.
46
+
47
+ import fs from "node:fs";
48
+ import path from "node:path";
49
+
50
+ import { computeInputsHash } from "./inputs-hash.mjs";
51
+ import { CLAUSE_LINE_RE, scanCitations } from "./spec-coverage.mjs";
52
+
53
+ export const FEATURES_DIR_REL = "docs/features";
54
+
55
+ /** The declaration block's info string — ```json cmp:feature */
56
+ const FEATURE_FENCE_RE = /```json\s+cmp:feature\s*\n([\s\S]*?)\n```/;
57
+
58
+ // The EDGE-CASE AUDIT (CHANGE-FLOW-DESIGN.md §1): the adversarial pass a brief
59
+ // must survive before anyone is asked to sign the design. It exists because of a
60
+ // measured failure — on 2026-07-27 the meal-plan brief was signed, designed,
61
+ // signed again, and only THEN audited; the audit found nine gaps (three of them
62
+ // defects in already-signed clauses), which cost three signing rounds on one
63
+ // feature. The audit was never optional; it was simply unplaced, so it happened
64
+ // last. This gives it a place: BEFORE the gate, not after it.
65
+ //
66
+ // The section is a plain `## Edge cases` heading followed by list items — one
67
+ // case per line, each ending in how it was resolved (a decision, a clause, or an
68
+ // explicit "out of scope"). The gate can only count entries; it cannot judge
69
+ // them. That is deliberate and enough: what it buys is that the adversarial pass
70
+ // HAPPENS while the artifacts are still unsigned, so whatever it finds lands in
71
+ // the same signing round instead of reopening one.
72
+ const EDGE_CASES_HEADING_RE = /^##\s+Edge cases\b[^\n]*\n([\s\S]*)$/im;
73
+ const LIST_ITEM_RE = /^\s*(?:[-*]|\d+\.)\s+\S/gm;
74
+
75
+ /**
76
+ * How many edge cases a brief records — 0 when the section is absent or empty.
77
+ *
78
+ * The body is taken to the NEXT `## ` heading by splitting, not by a lookahead:
79
+ * with the /m flag `$` means end-of-LINE, so a lazy `[\s\S]*?(?=\n##\s|$)`
80
+ * terminates on the first newline and silently captures nothing. Splitting says
81
+ * what it means and cannot regress that way.
82
+ *
83
+ * @param {string} markdown
84
+ * @returns {number}
85
+ */
86
+ export function countEdgeCases(markdown) {
87
+ if (typeof markdown !== "string") return 0;
88
+ const m = markdown.match(EDGE_CASES_HEADING_RE);
89
+ if (!m) return 0;
90
+ const body = m[1].split(/\n##\s/)[0];
91
+ return (body.match(LIST_ITEM_RE) || []).length;
92
+ }
93
+
94
+ /**
95
+ * The brief with its cmp:feature declaration block removed — the basis the
96
+ * feature-brief approval hash is computed over (approvals.mjs). The human signs
97
+ * the brief's REASONING; the block is machine-read declaration whose claims the
98
+ * harness independently enforces (artifact hashes enforce `touches`; disk
99
+ * presence enforces the design gate), so editing it must never invalidate a
100
+ * signature — the same stance as `architecture`'s cmp:generated stripping.
101
+ * @param {string} markdown
102
+ * @returns {string}
103
+ */
104
+ export function stripFeatureBlock(markdown) {
105
+ if (typeof markdown !== "string") return "";
106
+ // Consume the blank space around the block and leave one paragraph break, and
107
+ // normalize the trailing edge — so adding, editing, or removing the block
108
+ // (typically the doc's last element) yields the same basis as never having
109
+ // one. The fence grammar itself stays FEATURE_FENCE_RE — one definition,
110
+ // shared with parseFeatureBlock.
111
+ const stripped = markdown.replace(new RegExp(String.raw`\s*` + FEATURE_FENCE_RE.source + String.raw`\s*`), "\n\n");
112
+ return stripped.trim() === "" ? "" : stripped.replace(/\s+$/, "\n");
113
+ }
114
+
115
+ /**
116
+ * Every feature brief — docs/features/*.md, sorted (code-unit sort: artifact
117
+ * ids derive from this list and must read identically on every machine).
118
+ * @param {string} root
119
+ * @returns {Array<{name: string, rel: string}>}
120
+ */
121
+ export function listFeatureBriefs(root) {
122
+ const dir = path.join(root, FEATURES_DIR_REL);
123
+ let names;
124
+ try {
125
+ names = fs.readdirSync(dir);
126
+ } catch {
127
+ return [];
128
+ }
129
+ return names
130
+ .filter((f) => f.endsWith(".md") && f !== "README.md")
131
+ .sort()
132
+ .map((f) => ({ name: f.slice(0, -".md".length), rel: `${FEATURES_DIR_REL}/${f}` }));
133
+ }
134
+
135
+ /**
136
+ * The brief's prose, split on `## ` headings — the SUBSTANCE the human signs.
137
+ * The console renders these on the feature card (the decisions section inline,
138
+ * the rest collapsible): an approval moment must show what is being approved,
139
+ * never just a status shell. Fenced code blocks are kept verbatim inside
140
+ * their section (the cmp:feature block included — it is part of the signed
141
+ * bytes and the reader may want to see it).
142
+ * @param {string} markdown
143
+ * @returns {Array<{heading: string, body: string}>}
144
+ */
145
+ export function briefSections(markdown) {
146
+ if (typeof markdown !== "string" || markdown.trim() === "") return [];
147
+ const out = [];
148
+ let current = null;
149
+ let inFence = false;
150
+ for (const line of markdown.split("\n")) {
151
+ if (/^```/.test(line.trim())) inFence = !inFence;
152
+ const m = !inFence && line.match(/^##\s+(.+)$/);
153
+ if (m) {
154
+ if (current) out.push({ heading: current.heading, body: current.lines.join("\n").trim() });
155
+ current = { heading: m[1].trim(), lines: [] };
156
+ } else if (current) {
157
+ current.lines.push(line);
158
+ }
159
+ }
160
+ if (current) out.push({ heading: current.heading, body: current.lines.join("\n").trim() });
161
+ return out;
162
+ }
163
+
164
+ /**
165
+ * A brief's declarations: blast radius (`touches`), UI surface (`screens`),
166
+ * and the reachability exemption (`unrouted` — FI-7's escape hatch: a screen
167
+ * intentionally not wired into the navigation graph yet). A missing block, or
168
+ * one without a field, declares nothing — legal and common. A block that IS
169
+ * present but malformed is surfaced as `error`: a doc that tried to declare
170
+ * and failed should say so, not read as "declares nothing".
171
+ * @param {string} markdown
172
+ * @returns {{touches: string[], screens: boolean, unrouted: boolean, error: (string|null)}}
173
+ */
174
+ export function parseFeatureBlock(markdown) {
175
+ const m = typeof markdown === "string" ? markdown.match(FEATURE_FENCE_RE) : null;
176
+ if (!m) return { touches: [], screens: false, unrouted: false, error: null };
177
+ let parsed;
178
+ try {
179
+ parsed = JSON.parse(m[1]);
180
+ } catch (err) {
181
+ return { touches: [], screens: false, unrouted: false, error: `cmp:feature block is not valid JSON — ${err.message}` };
182
+ }
183
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
184
+ return { touches: [], screens: false, unrouted: false, error: "cmp:feature must be a JSON object" };
185
+ }
186
+ const touches = Array.isArray(parsed.touches)
187
+ ? parsed.touches.filter((t) => typeof t === "string" && t.trim() !== "")
188
+ : [];
189
+ return { touches, screens: parsed.screens === true, unrouted: parsed.unrouted === true, error: null };
190
+ }
191
+
192
+ /**
193
+ * Parse one spec file's clauses in document order.
194
+ * @param {string} root
195
+ * @param {string} specRel e.g. "specs/meal.spec.md"
196
+ * @returns {Array<{id: string, withdrawn: boolean}>}
197
+ */
198
+ function clausesOfSpec(root, specRel) {
199
+ let text;
200
+ try {
201
+ text = fs.readFileSync(path.join(root, specRel), "utf8");
202
+ } catch {
203
+ return [];
204
+ }
205
+ const out = [];
206
+ for (const line of text.split("\n")) {
207
+ const m = line.match(CLAUSE_LINE_RE);
208
+ if (m) out.push({ id: m[2], withdrawn: Boolean(m[1]) });
209
+ }
210
+ return out;
211
+ }
212
+
213
+ /**
214
+ * The latest receipt, reduced to what doneness needs: verdict, and whether its
215
+ * inputs.hash attests the tree AS IT STANDS (same cheap recompute the Stop
216
+ * hook and pre-push gate use). Absent/unparsable receipt -> present:false —
217
+ * never treated as PASS.
218
+ * @param {string} root
219
+ * @returns {{present: boolean, verdict: (string|null), attestsTree: boolean}}
220
+ */
221
+ export function receiptAttestation(root) {
222
+ let receipt;
223
+ try {
224
+ receipt = JSON.parse(fs.readFileSync(path.join(root, "qa/evidence/latest.json"), "utf8"));
225
+ } catch {
226
+ return { present: false, verdict: null, attestsTree: false };
227
+ }
228
+ const recorded = receipt?.inputs?.hash;
229
+ let attestsTree = false;
230
+ if (typeof recorded === "string" && recorded !== "") {
231
+ try {
232
+ attestsTree = computeInputsHash(root).hash === recorded;
233
+ } catch {
234
+ attestsTree = false;
235
+ }
236
+ }
237
+ return { present: true, verdict: receipt?.verdict ?? null, attestsTree };
238
+ }
239
+
240
+ /**
241
+ * One feature's full derived state — brief, spec, coverage, receipt, verdict.
242
+ *
243
+ * `provenDone` is strict on purpose; each conjunct closes a specific hole:
244
+ * - `total > 0`: a spec with no live clauses proves nothing (the vacuous-
245
+ * approval stance, applied to doneness)
246
+ * - `covered === total`: every promise has a citing test (specCoverage's own
247
+ * definition, via the same scan)
248
+ * - `verdict === "PASS"`: the citing tests actually ran green
249
+ * - `attestsTree`: they ran green against THIS tree, not an earlier one
250
+ *
251
+ * @param {string} root
252
+ * @param {{name: string, rel: string}} brief
253
+ * @param {{citations?: Array<object>, receipt?: object}} [pre] precomputed
254
+ * shared scans (callers resolving many features pass these once)
255
+ * @returns {object}
256
+ */
257
+ export function deriveFeatureStatus(root, brief, pre = {}) {
258
+ let markdown = "";
259
+ let readable = true;
260
+ try {
261
+ markdown = fs.readFileSync(path.join(root, brief.rel), "utf8");
262
+ } catch {
263
+ readable = false;
264
+ }
265
+ const block = readable ? parseFeatureBlock(markdown) : { touches: [], screens: false, error: `${brief.rel} could not be read` };
266
+
267
+ const specRel = `specs/${brief.name}.spec.md`;
268
+ const specExists = fs.existsSync(path.join(root, specRel));
269
+ const citedIds = new Set((pre.citations ?? scanCitations(root)).map((t) => t.id));
270
+ const clauses = clausesOfSpec(root, specRel).map((c) => ({ ...c, cited: citedIds.has(c.id) }));
271
+ const live = clauses.filter((c) => !c.withdrawn);
272
+ const covered = live.filter((c) => c.cited).length;
273
+
274
+ const receipt = pre.receipt ?? receiptAttestation(root);
275
+ const provenDone = live.length > 0 && covered === live.length && receipt.verdict === "PASS" && receipt.attestsTree;
276
+
277
+ return {
278
+ name: brief.name,
279
+ rel: brief.rel,
280
+ touches: block.touches,
281
+ screens: block.screens,
282
+ blockError: block.error,
283
+ // The signed substance, for surfaces that show WHAT is being approved.
284
+ sections: readable ? briefSections(markdown) : [],
285
+ // How many edge cases the brief's adversarial pass recorded — the `audit`
286
+ // rung's only mechanical signal (see countEdgeCases).
287
+ edgeCases: readable ? countEdgeCases(markdown) : 0,
288
+ specRel,
289
+ specExists,
290
+ clauses,
291
+ covered,
292
+ total: live.length,
293
+ receipt,
294
+ provenDone,
295
+ // The one-line honest explanation of why it is / isn't done — the console
296
+ // and --status print this instead of re-deriving their own wording.
297
+ doneReason: provenDone
298
+ ? `${covered}/${live.length} clauses cited · receipt PASS · attests this tree`
299
+ : !specExists
300
+ ? `no spec yet (${specRel}) — behavior starts as clauses there`
301
+ : live.length === 0
302
+ ? `${specRel} has no live clauses — nothing is promised yet`
303
+ : covered < live.length
304
+ ? `${covered}/${live.length} clauses cited — ${live.length - covered} promise(s) have no citing test`
305
+ : !receipt.present
306
+ ? "all clauses cited, but no receipt — run node qa/verify.mjs"
307
+ : receipt.verdict !== "PASS"
308
+ ? `all clauses cited, but the latest receipt is ${receipt.verdict}`
309
+ : "all clauses cited and receipt PASS, but it attests an older tree — re-run node qa/verify.mjs",
310
+ };
311
+ }
312
+
313
+ /**
314
+ * Every feature, derived — the one call the CLI status surface and the console
315
+ * section share. Shared scans (citations, receipt) run once.
316
+ * @param {string} root
317
+ * @returns {Array<ReturnType<typeof deriveFeatureStatus>>}
318
+ */
319
+ export function deriveAllFeatures(root) {
320
+ const briefs = listFeatureBriefs(root);
321
+ if (briefs.length === 0) return [];
322
+ const pre = { citations: scanCitations(root), receipt: receiptAttestation(root) };
323
+ return briefs.map((b) => deriveFeatureStatus(root, b, pre));
324
+ }
@@ -29,11 +29,47 @@ export const VERIFIED_SURFACE = [
29
29
  "gradle.properties",
30
30
  ];
31
31
 
32
- // Paths EXCLUDED even though they fall under an included surface dir above
33
- // these are lane OUTPUTS, not inputs. Including them would make the hash
34
- // depend on the lane's own prior output (or, for qa-artifacts, on binary
35
- // scratch that is deliberately never committed).
36
- const EXCLUDED_PREFIXES = ["qa/evidence", "qa-artifacts"];
32
+ // Paths EXCLUDED even though they fall under an included surface dir above.
33
+ // qa/evidence and qa-artifacts are lane OUTPUTS including them would make
34
+ // the hash depend on the lane's own prior output. qa/comments.json is excluded
35
+ // by this file's own stated principle: comments are explicitly advisory and no
36
+ // lane step reads them, so their content cannot change the verdict — hashing
37
+ // them made resolving a review note invalidate a receipt for a tree whose
38
+ // code had not changed. qa/approvals.log.jsonl (the governance journal) is
39
+ // excluded by the same principle: it is append-only HISTORY of decisions the
40
+ // snapshot (qa/approvals.json) already carries as state — no lane step reads
41
+ // it, so recording who/why must never invalidate a receipt for a tree whose
42
+ // code did not change (the exact failure FI-8 killed for acceptance).
43
+ const EXCLUDED_PREFIXES = ["qa/evidence", "qa-artifacts", "qa/comments.json", "qa/approvals.log.jsonl"];
44
+
45
+ // qa/approvals.json is hashed by PROJECTION, not raw bytes. The approvals gate's
46
+ // verdict depends on exactly three row fields (artifact, status, hash) plus the
47
+ // top-level exemplarFeature (it selects the exemplar artifact's file set).
48
+ // Everything else on a row — approvedAt, mode, via, reopenedAt, accepted,
49
+ // acceptedAt — is ledger bookkeeping that records a decision without gating one.
50
+ // Hashing those bytes meant the human clicking Accept on a provenDone feature
51
+ // instantly invalidated the receipt whose PASS permitted the acceptance.
52
+ // Acceptance is a bookend recorded after proof; it must not destroy it.
53
+ // An unparsable ledger falls back to raw bytes — refusal over fabrication.
54
+ const APPROVALS_PROJECTED_PATH = "qa/approvals.json";
55
+
56
+ function projectApprovalsBytes(raw) {
57
+ let parsed;
58
+ try {
59
+ parsed = JSON.parse(raw.toString("utf8"));
60
+ } catch {
61
+ return raw;
62
+ }
63
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.artifacts)) return raw;
64
+ const rows = parsed.artifacts
65
+ .filter((a) => a && typeof a === "object")
66
+ .map((a) => ({ artifact: a.artifact ?? null, status: a.status ?? null, hash: a.hash ?? null }))
67
+ .sort((a, b) => (String(a.artifact) < String(b.artifact) ? -1 : String(a.artifact) > String(b.artifact) ? 1 : 0));
68
+ const projection = {};
69
+ if (typeof parsed.exemplarFeature === "string") projection.exemplarFeature = parsed.exemplarFeature;
70
+ projection.artifacts = rows;
71
+ return Buffer.from(`${JSON.stringify(projection)}\n`, "utf8");
72
+ }
37
73
 
38
74
  function isExcluded(relPath) {
39
75
  return EXCLUDED_PREFIXES.some((prefix) => relPath === prefix || relPath.startsWith(`${prefix}/`));
@@ -133,7 +169,8 @@ export function computeInputsHash(root) {
133
169
 
134
170
  const overall = createHash("sha256");
135
171
  for (const relPath of files) {
136
- const bytes = fs.readFileSync(path.join(root, relPath));
172
+ const raw = fs.readFileSync(path.join(root, relPath));
173
+ const bytes = relPath === APPROVALS_PROJECTED_PATH ? projectApprovalsBytes(raw) : raw;
137
174
  const fileSha = createHash("sha256").update(bytes).digest("hex");
138
175
  overall.update(`${relPath}\0${fileSha}\n`);
139
176
  }
@@ -0,0 +1,211 @@
1
+ // reachability.mjs — the navigation-reachability gate (task FI-7,
2
+ // docs/AUTONOMY-GAPS.md §3): a feature that passes spec coverage, conformance,
3
+ // goldens, a11y, and on-device smoke — and is STILL unreachable because no
4
+ // destination in the navigation graph points at it — is a confident false
5
+ // green. `MealTrayScreen` was accepted that way: its `MealTrayRoute` composable
6
+ // was referenced by nothing but its own tests. This gate exists to catch
7
+ // exactly that shape of drift, mechanically, so it can never happen silently
8
+ // again.
9
+ //
10
+ // Pure Node, no Gradle — same stance as component-stories.mjs and
11
+ // spec-coverage.mjs: a pragmatic source scan, not a Kotlin front end.
12
+ //
13
+ // A "feature" is any top-level directory under commonMain's presentation/,
14
+ // except `components` and `theme` (registry/design surfaces, not screens),
15
+ // that contains at least one `*Screen.kt` file (recursive). Its ENTRY
16
+ // composables are the top-level `fun <Name>(` declarations in those files
17
+ // whose name ends in `Screen` or `Route` — the navigation-entry naming
18
+ // convention this template's own exemplar follows (HomeScreen, DetailScreen).
19
+ // A feature is REACHABLE if any entry composable's name is referenced —
20
+ // word-boundary, not a call-site parse — from a commonMain `.kt` file OUTSIDE
21
+ // its own presentation/<feature>/ directory. The navigation graph
22
+ // (AppNavHost) is the expected referencer.
23
+ //
24
+ // desktopMain (PreviewRegistry.kt, ComponentStories.kt) and test sources
25
+ // deliberately do NOT count: registering a screen in the preview gallery is
26
+ // not wiring it into the app a user runs — that is exactly the false green
27
+ // this gate exists to close, so only commonMain counts as a live reference.
28
+ //
29
+ // A feature with no entry composable at all (nothing named `*Screen`/`*Route`
30
+ // declared at top level) has nothing this gate can check reachability FOR —
31
+ // it is passed through untouched rather than flagged, the same "nothing to
32
+ // check" stance component-stories.mjs takes for an empty registry.
33
+
34
+ import fs from "node:fs";
35
+ import path from "node:path";
36
+
37
+ import { parseFeatureBlock } from "./feature-brief.mjs";
38
+
39
+ // Mirrors component-stories.mjs's walkDirs: every directory named `wanted`
40
+ // anywhere under `root`, recursive.
41
+ function walkDirs(root, wanted) {
42
+ const out = [];
43
+ (function walk(dir) {
44
+ let entries;
45
+ try {
46
+ entries = fs.readdirSync(dir, { withFileTypes: true });
47
+ } catch {
48
+ return;
49
+ }
50
+ for (const e of entries) {
51
+ if (!e.isDirectory()) continue;
52
+ const p = path.join(dir, e.name);
53
+ if (e.name === wanted) out.push(p);
54
+ else walk(p);
55
+ }
56
+ })(root);
57
+ return out;
58
+ }
59
+
60
+ // Mirrors component-stories.mjs's walkKtFilesDeep: every `.kt` file under
61
+ // `dir`, recursive.
62
+ function walkKtFilesDeep(dir) {
63
+ const out = [];
64
+ let entries;
65
+ try {
66
+ entries = fs.readdirSync(dir, { withFileTypes: true });
67
+ } catch {
68
+ return out;
69
+ }
70
+ for (const e of entries) {
71
+ const p = path.join(dir, e.name);
72
+ if (e.isDirectory()) out.push(...walkKtFilesDeep(p));
73
+ else if (e.name.endsWith(".kt")) out.push(p);
74
+ }
75
+ return out;
76
+ }
77
+
78
+ // Top-level `fun Name(` — unindented (column 0), the same line-anchored
79
+ // scanning stance as spec-coverage.mjs's CLAUSE_LINE_RE: a pragmatic source
80
+ // scan, not a Kotlin front end. Deliberately does not require `@Composable`
81
+ // on the preceding line — the entry-naming convention (Screen/Route suffix)
82
+ // is the signal, not the annotation.
83
+ const TOP_LEVEL_FUN_RE = /^fun\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:<[^>]*>)?\s*\(/gm;
84
+
85
+ /**
86
+ * The entry composables one `*Screen.kt` file's text declares: top-level
87
+ * `fun`s whose name ends `Screen` or `Route`. Order-preserving, de-duplicated
88
+ * by the caller across a feature's files.
89
+ * @param {string} text
90
+ * @returns {string[]}
91
+ */
92
+ export function findEntryComposables(text) {
93
+ const names = [];
94
+ TOP_LEVEL_FUN_RE.lastIndex = 0;
95
+ let m;
96
+ while ((m = TOP_LEVEL_FUN_RE.exec(text))) {
97
+ if (/(Screen|Route)$/.test(m[1])) names.push(m[1]);
98
+ }
99
+ return names;
100
+ }
101
+
102
+ /**
103
+ * Evaluate navigation reachability for a project root.
104
+ * @param {string} root project root (contains composeApp/)
105
+ * @returns {{verdict: "PASS"|"FAIL"|"SKIP", reason?: string, details: {features: Array<{name: string, reachable: boolean, unrouted?: boolean, entryComposables: string[]}>}}}
106
+ */
107
+ export function evaluateReachability(root) {
108
+ const commonRoot = path.join(root, "composeApp", "src", "commonMain", "kotlin");
109
+ if (!fs.existsSync(commonRoot)) {
110
+ return {
111
+ verdict: "SKIP",
112
+ reason: "no commonMain/kotlin directory under composeApp/src — kotlin root unresolvable, nothing to check",
113
+ details: { features: [] },
114
+ };
115
+ }
116
+
117
+ const presentationDirs = walkDirs(commonRoot, "presentation");
118
+ if (presentationDirs.length === 0) {
119
+ return { verdict: "SKIP", reason: "no presentation directory under commonMain — nothing to check", details: { features: [] } };
120
+ }
121
+
122
+ // The whole commonMain .kt surface, read once — both the per-feature scan
123
+ // source and the reachability search space (desktopMain/test deliberately
124
+ // excluded: see the file header).
125
+ const allKtFiles = walkKtFilesDeep(commonRoot);
126
+ const textOf = new Map(allKtFiles.map((f) => [f, fs.readFileSync(f, "utf8")]));
127
+
128
+ const features = []; // { name, dir, entryComposables }
129
+ for (const presentationDir of presentationDirs) {
130
+ let entries;
131
+ try {
132
+ entries = fs.readdirSync(presentationDir, { withFileTypes: true });
133
+ } catch {
134
+ continue;
135
+ }
136
+ for (const e of entries) {
137
+ if (!e.isDirectory() || e.name === "components" || e.name === "theme") continue;
138
+ const featureDir = path.join(presentationDir, e.name);
139
+ const screenFiles = walkKtFilesDeep(featureDir).filter((f) => path.basename(f).endsWith("Screen.kt"));
140
+ if (screenFiles.length === 0) continue; // not a feature per this gate's definition
141
+
142
+ const entryComposables = [];
143
+ for (const f of screenFiles) {
144
+ for (const name of findEntryComposables(textOf.get(f) ?? "")) {
145
+ if (!entryComposables.includes(name)) entryComposables.push(name);
146
+ }
147
+ }
148
+ features.push({ name: e.name, dir: featureDir, entryComposables });
149
+ }
150
+ }
151
+
152
+ if (features.length === 0) {
153
+ return { verdict: "SKIP", reason: "no presentation/<feature> directory has a *Screen.kt file — nothing to check", details: { features: [] } };
154
+ }
155
+
156
+ const resultFeatures = [];
157
+ const unreachable = [];
158
+ for (const feature of features) {
159
+ // Nothing named Screen/Route was declared at all — this gate has nothing
160
+ // to check reachability for; pass it through rather than flag it.
161
+ if (feature.entryComposables.length === 0) {
162
+ resultFeatures.push({ name: feature.name, reachable: true, entryComposables: [] });
163
+ continue;
164
+ }
165
+
166
+ const featurePrefix = feature.dir + path.sep;
167
+ const outsideFiles = allKtFiles.filter((f) => !f.startsWith(featurePrefix));
168
+ const reachable = feature.entryComposables.some((name) => {
169
+ const re = new RegExp(`\\b${name}\\b`);
170
+ return outsideFiles.some((f) => re.test(textOf.get(f) ?? ""));
171
+ });
172
+
173
+ if (reachable) {
174
+ resultFeatures.push({ name: feature.name, reachable: true, entryComposables: feature.entryComposables });
175
+ continue;
176
+ }
177
+
178
+ // Exemption: docs/features/<name>.md's cmp:feature block declares
179
+ // { "unrouted": true } — the same declare-not-gate mechanism
180
+ // feature-brief.mjs already defines for `touches`/`screens`.
181
+ let unrouted = false;
182
+ try {
183
+ const briefMarkdown = fs.readFileSync(path.join(root, "docs", "features", `${feature.name}.md`), "utf8");
184
+ unrouted = parseFeatureBlock(briefMarkdown).unrouted === true;
185
+ } catch {
186
+ unrouted = false;
187
+ }
188
+
189
+ if (unrouted) {
190
+ resultFeatures.push({ name: feature.name, reachable: true, unrouted: true, entryComposables: feature.entryComposables });
191
+ continue;
192
+ }
193
+
194
+ resultFeatures.push({ name: feature.name, reachable: false, entryComposables: feature.entryComposables });
195
+ unreachable.push(feature);
196
+ }
197
+
198
+ if (unreachable.length === 0) {
199
+ return { verdict: "PASS", details: { features: resultFeatures } };
200
+ }
201
+
202
+ const lines = [
203
+ "Reachability broken — a feature's screen passed every other gate but nothing in the navigation graph points at it (a screen nobody can navigate to is not a delivered feature):",
204
+ ];
205
+ for (const f of unreachable) {
206
+ lines.push(
207
+ ` [${f.name}] entry composable(s) ${f.entryComposables.join(", ")} — not referenced anywhere in commonMain outside presentation/${f.name}/. Fix it one of two ways: wire a destination for it in the navigation graph (AppNavHost), or, if it is intentionally not routed yet, declare { "unrouted": true } in docs/features/${f.name}.md's cmp:feature block.`,
208
+ );
209
+ }
210
+ return { verdict: "FAIL", reason: lines.join("\n"), details: { features: resultFeatures } };
211
+ }
@@ -0,0 +1,80 @@
1
+ // spec-coverage.mjs — the clause ↔ citation scan, as a library.
2
+ //
3
+ // Extracted from qa/verify.mjs's stepSpecCoverage so there is exactly ONE
4
+ // definition of "what is a clause" and "what cites it". Two consumers:
5
+ // - the lane's specCoverage gate (orphans in either direction FAIL)
6
+ // - feature-brief.mjs's derived doneness (a feature is done when every live
7
+ // clause in ITS spec is cited and the receipt attests the tree)
8
+ // If these two scanned differently, the Features view and the lane could
9
+ // disagree about the same clause — the exact two-truths problem this file
10
+ // exists to prevent.
11
+
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+
15
+ /** `- **HOME-01** — …` (live) or `- ~~**HOME-01**~~ — …` (withdrawn). */
16
+ export const CLAUSE_LINE_RE = /^-\s+(~~)?\*\*([A-Z][A-Z0-9]*-\d{2,})\*\*/;
17
+
18
+ const TAG_LINE_RE = /^(?:\/\/|#)\s*SPEC:/;
19
+ const TAG_IDS_RE = /SPEC:\s*([A-Z0-9,\s-]+)/;
20
+ const CLAUSE_ID_RE = /^[A-Z][A-Z0-9]*-\d{2,}$/;
21
+
22
+ /** Recursive walk returning files under `dir` ending in one of `exts`. */
23
+ export function walkFiles(dir, exts) {
24
+ const out = [];
25
+ if (!fs.existsSync(dir)) return out;
26
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
27
+ const p = path.join(dir, entry.name);
28
+ if (entry.isDirectory()) out.push(...walkFiles(p, exts));
29
+ else if (exts.some((ext) => entry.name.endsWith(ext))) out.push(p);
30
+ }
31
+ return out;
32
+ }
33
+
34
+ /**
35
+ * Every clause in every specs/*.spec.md.
36
+ * @param {string} root
37
+ * @returns {Map<string, {file: string, withdrawn: boolean}>} id -> where/state
38
+ */
39
+ export function scanSpecClauses(root) {
40
+ const clauses = new Map();
41
+ const specsDir = path.join(root, "specs");
42
+ if (!fs.existsSync(specsDir)) return clauses;
43
+ for (const f of fs.readdirSync(specsDir).filter((n) => n.endsWith(".spec.md"))) {
44
+ const abs = path.join(specsDir, f);
45
+ for (const line of fs.readFileSync(abs, "utf8").split("\n")) {
46
+ const m = line.match(CLAUSE_LINE_RE);
47
+ if (!m) continue;
48
+ clauses.set(m[2], { file: path.relative(root, abs), withdrawn: Boolean(m[1]) });
49
+ }
50
+ }
51
+ return clauses;
52
+ }
53
+
54
+ /**
55
+ * Every `// SPEC: ID[, ID…]` / `# SPEC: …` citation tag under composeApp/src
56
+ * and qa/e2e.
57
+ * @param {string} root
58
+ * @returns {Array<{id: string, file: string, line: number}>}
59
+ */
60
+ export function scanCitations(root) {
61
+ const tags = [];
62
+ const searchDirs = [path.join(root, "composeApp/src"), path.join(root, "qa/e2e")];
63
+ const files = searchDirs.flatMap((d) => walkFiles(d, [".kt", ".kts", ".yaml", ".yml"]));
64
+ for (const f of files) {
65
+ fs.readFileSync(f, "utf8")
66
+ .split("\n")
67
+ .forEach((line, i) => {
68
+ const trimmed = line.trim();
69
+ if (!TAG_LINE_RE.test(trimmed)) return;
70
+ const m = trimmed.match(TAG_IDS_RE);
71
+ if (!m) return;
72
+ const ids = m[1]
73
+ .split(/[,\s]+/)
74
+ .map((s) => s.trim())
75
+ .filter((s) => CLAUSE_ID_RE.test(s));
76
+ for (const id of ids) tags.push({ id, file: path.relative(root, f), line: i + 1 });
77
+ });
78
+ }
79
+ return tags;
80
+ }