create-cmp-cli 0.10.0 → 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.
- package/package.json +1 -1
- package/src/lib/tabs.mjs +26 -0
- package/template/.claude/skills/add-feature/SKILL.md +20 -0
- package/template/.claude/skills/add-repository/SKILL.md +6 -0
- package/template/.claude/skills/add-screen/SKILL.md +6 -0
- package/template/CLAUDE.md +81 -2
- package/template/composeApp/build.gradle.kts +25 -0
- package/template/composeApp/proguard-rules.pro +12 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +16 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +2 -5
- package/template/qa/approve.mjs +119 -11
- package/template/qa/lib/approvals.mjs +602 -21
- package/template/qa/lib/feature-brief.mjs +324 -0
- package/template/qa/lib/inputs-hash.mjs +43 -6
- package/template/qa/lib/reachability.mjs +211 -0
- package/template/qa/lib/spec-coverage.mjs +80 -0
- package/template/qa/verify.mjs +148 -48
|
@@ -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
|
-
//
|
|
34
|
-
// depend on the lane's own prior output
|
|
35
|
-
//
|
|
36
|
-
|
|
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
|
|
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
|
+
}
|