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,158 @@
|
|
|
1
|
+
// The README's evidence badge — the evidence ladder, rendered where a human
|
|
2
|
+
// actually looks (roadmap §10 item 2: "render the rung in the console and
|
|
3
|
+
// README badge").
|
|
4
|
+
//
|
|
5
|
+
// The console already shows the rung (rail foot, Evidence section, receipt
|
|
6
|
+
// timeline). The README is the surface a human meets FIRST, and the one that
|
|
7
|
+
// travels — into a GitHub repo page, a PR, a screenshot in a deck. That makes
|
|
8
|
+
// it the surface where an overclaim does the most damage, so the badge obeys
|
|
9
|
+
// one rule above all others:
|
|
10
|
+
//
|
|
11
|
+
// **The badge is a statement about a specific commit, never about "now".**
|
|
12
|
+
//
|
|
13
|
+
// A badge that says "L2 device" says nothing about whether the code has moved
|
|
14
|
+
// since. So it never renders a bare rung: it renders the rung AND the commit
|
|
15
|
+
// it was attested against AND the date. That sentence stays true forever — a
|
|
16
|
+
// reader can see at a glance whether the sha still matches what they are
|
|
17
|
+
// looking at. Everything else follows from the same rule: no receipt says so,
|
|
18
|
+
// a FAIL says so, and a --fast run (which the ladder deliberately grants no
|
|
19
|
+
// rung) says so rather than borrowing the last good one.
|
|
20
|
+
//
|
|
21
|
+
// Written by the lane AFTER the receipt (it is an output derived from the
|
|
22
|
+
// receipt, never a gate), and committed alongside it.
|
|
23
|
+
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
|
|
27
|
+
export const README_REL_PATH = "README.md";
|
|
28
|
+
export const BADGE_SECTION_ID = "evidence";
|
|
29
|
+
|
|
30
|
+
const MARKER_RE = new RegExp(
|
|
31
|
+
`<!-- cmp:generated ${BADGE_SECTION_ID} -->\\n([\\s\\S]*?)<!-- /cmp:generated -->`
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
/** Shields.io colours, one per rung — the ladder read at a glance. */
|
|
35
|
+
const RUNG_COLOR = {
|
|
36
|
+
L0: "9E9E9E", // scaffold — grey: a green build, nothing proven about behavior
|
|
37
|
+
L1: "42A5F5", // desktop — blue
|
|
38
|
+
L2: "26A69A", // device — teal
|
|
39
|
+
L3: "43A047", // release — green: the strongest rung this harness can attest
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** shields.io escaping: `-` → `--`, `_` → `__`, space → `_`. */
|
|
43
|
+
function shieldEscape(s) {
|
|
44
|
+
return String(s).replace(/-/g, "--").replace(/_/g, "__").replace(/ /g, "_");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The badge body for a receipt — Markdown, no trailing newline handling (the
|
|
49
|
+
* caller frames it). Pure: every degraded state has its own honest rendering
|
|
50
|
+
* and NONE of them fall back to a rung.
|
|
51
|
+
*
|
|
52
|
+
* @param {object|null} receipt parsed qa/evidence/latest.json, or null
|
|
53
|
+
* @returns {string} Markdown
|
|
54
|
+
*/
|
|
55
|
+
export function renderEvidenceBadge(receipt) {
|
|
56
|
+
const link = "https://github.com/kvdm-co-pilot/create-cmp";
|
|
57
|
+
const badge = (label, message, color, title) =>
|
|
58
|
+
`[}-${shieldEscape(message)}-${color})](${link})`;
|
|
59
|
+
|
|
60
|
+
if (!receipt || typeof receipt !== "object") {
|
|
61
|
+
return `${badge("evidence", "none yet", "9E9E9E", "No evidence receipt")} — no verify receipt yet. Run \`node qa/verify.mjs\`.`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const verdict = typeof receipt.verdict === "string" ? receipt.verdict : "?";
|
|
65
|
+
const mode = receipt.mode === "fast" ? "fast" : "full";
|
|
66
|
+
const sha = typeof receipt.commit?.sha === "string" ? receipt.commit.sha.slice(0, 7) : null;
|
|
67
|
+
const dirty = Array.isArray(receipt.commit?.dirty) ? receipt.commit.dirty.length : 0;
|
|
68
|
+
const when = typeof receipt.generatedAt === "string" ? receipt.generatedAt.slice(0, 10) : null;
|
|
69
|
+
|
|
70
|
+
// Provenance is not decoration — it is what keeps the sentence true later.
|
|
71
|
+
const at = sha ? ` at \`${sha}\`` : "";
|
|
72
|
+
const on = when ? ` on ${when}` : "";
|
|
73
|
+
const uncommitted =
|
|
74
|
+
dirty > 0
|
|
75
|
+
? ` The tree had ${dirty} uncommitted file${dirty === 1 ? "" : "s"} at attestation, so this describes that run, not that commit.`
|
|
76
|
+
: "";
|
|
77
|
+
|
|
78
|
+
if (verdict !== "PASS") {
|
|
79
|
+
return `${badge("evidence", `lane ${verdict}`, "E53935", `Verify lane ${verdict}`)} — the last lane run${at}${on} did not pass. No rung is earned by a failed lane.${uncommitted}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (mode === "fast") {
|
|
83
|
+
// The inner loop is a signal, never evidence. Borrowing the previous
|
|
84
|
+
// full run's rung here is exactly the lie the ladder exists to prevent.
|
|
85
|
+
return `${badge("evidence", "fast run, no rung", "9E9E9E", "Fast run — no evidence rung")} — the last run${at}${on} was \`--fast\`: the device and release tiers were skipped, so it earns no rung.${uncommitted} Run \`node qa/verify.mjs\` for evidence.`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const level = receipt.evidenceLevel;
|
|
89
|
+
if (!level || typeof level.rung !== "string" || typeof level.name !== "string") {
|
|
90
|
+
return `${badge("evidence", `PASS, rung unrecorded`, "9E9E9E", "Lane PASS, no rung recorded")} — the lane passed${at}${on} but the receipt records no evidence rung.`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const color = RUNG_COLOR[level.rung] || "9E9E9E";
|
|
94
|
+
const satisfied = Array.isArray(level.satisfiedBy) && level.satisfiedBy.length
|
|
95
|
+
? ` Earned by: ${level.satisfiedBy.map((s) => `\`${s}\``).join(", ")}.`
|
|
96
|
+
: "";
|
|
97
|
+
return (
|
|
98
|
+
`${badge("evidence", `${level.rung} ${level.name}`, color, `Evidence ${level.rung} — ${level.name}`)}` +
|
|
99
|
+
` — the verify lane passed${at}${on} at rung **${level.rung} · ${level.name}**.` +
|
|
100
|
+
`${satisfied}${uncommitted}` +
|
|
101
|
+
` The rung describes that run; it says nothing about changes made since.`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Read the project's receipt and rewrite README.md's `cmp:generated evidence`
|
|
107
|
+
* block from it. Never creates the marker — a project that removed the block
|
|
108
|
+
* has opted out, and that is honoured silently.
|
|
109
|
+
*
|
|
110
|
+
* NOTE the asymmetry with renderEvidenceBadge above: the RENDERER is total —
|
|
111
|
+
* every receipt, including a `--fast` one, has an honest rendering. The WRITER
|
|
112
|
+
* is selective: a fast receipt is not written to the README at all. Two
|
|
113
|
+
* reasons, and the second is the load-bearing one:
|
|
114
|
+
* 1. The badge reports EVIDENCE. A fast run produces none, so it has nothing
|
|
115
|
+
* to say — and overwriting a true statement about a real full-lane run
|
|
116
|
+
* with "no rung" loses information rather than adding honesty.
|
|
117
|
+
* 2. `qa/watch.mjs` runs the fast lane on every save. A writer that fired
|
|
118
|
+
* there would rewrite README.md on every keystroke-to-save cycle, putting
|
|
119
|
+
* a permanently-dirty file in the inner loop. A recorder must not disturb
|
|
120
|
+
* what it records.
|
|
121
|
+
* The badge therefore always describes the last run that could BEAR evidence,
|
|
122
|
+
* and says so by naming that run's commit and date.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} root project root
|
|
125
|
+
* @returns {{changed: boolean, reason?: string}}
|
|
126
|
+
*/
|
|
127
|
+
export function updateReadmeBadge(root) {
|
|
128
|
+
const readmePath = path.join(root, README_REL_PATH);
|
|
129
|
+
let readme;
|
|
130
|
+
try {
|
|
131
|
+
readme = fs.readFileSync(readmePath, "utf8");
|
|
132
|
+
} catch {
|
|
133
|
+
return { changed: false, reason: `${README_REL_PATH} not found` };
|
|
134
|
+
}
|
|
135
|
+
if (!MARKER_RE.test(readme)) {
|
|
136
|
+
return { changed: false, reason: `${README_REL_PATH} has no cmp:generated ${BADGE_SECTION_ID} block` };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let receipt = null;
|
|
140
|
+
try {
|
|
141
|
+
receipt = JSON.parse(fs.readFileSync(path.join(root, "qa", "evidence", "latest.json"), "utf8"));
|
|
142
|
+
} catch {
|
|
143
|
+
receipt = null; // no receipt / unreadable → the "none yet" rendering, never a guess
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (receipt && receipt.mode === "fast") {
|
|
147
|
+
return { changed: false, reason: "fast run — the inner loop bears no evidence, so the badge is left as it stands" };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const body = `${renderEvidenceBadge(receipt)}\n`;
|
|
151
|
+
const next = readme.replace(
|
|
152
|
+
MARKER_RE,
|
|
153
|
+
() => `<!-- cmp:generated ${BADGE_SECTION_ID} -->\n${body}<!-- /cmp:generated -->`
|
|
154
|
+
);
|
|
155
|
+
if (next === readme) return { changed: false };
|
|
156
|
+
fs.writeFileSync(readmePath, next);
|
|
157
|
+
return { changed: true };
|
|
158
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// The evidence ladder — the receipt's COARSE grade, derived, never declared.
|
|
2
|
+
//
|
|
3
|
+
// Receipts already grade themselves in fine print ("PASS (desktop-only)",
|
|
4
|
+
// "PASS (on-device: e2eSmoke+androidChecks)"). This module names the rungs so
|
|
5
|
+
// every surface that shows a receipt can say the same thing in one word:
|
|
6
|
+
//
|
|
7
|
+
// L0 "scaffold" — the scaffold profile's checks passed (stamp-time green
|
|
8
|
+
// build: build + unit tests + the pure-Node gates).
|
|
9
|
+
// L1 "desktop" — full static + JVM evidence: everything L0 proves PLUS
|
|
10
|
+
// conformance, golden trees, a11y, and the release COMPILE
|
|
11
|
+
// (releaseBuild) — a green lane with no on-device step run.
|
|
12
|
+
// L2 "device" — L1 plus at least one on-device EXECUTION step PASSed
|
|
13
|
+
// (e2eSmoke, androidChecks, or the live tokenDrift tier).
|
|
14
|
+
// L3 "release" — L2 plus releaseSmoke PASSed (the release APK installed
|
|
15
|
+
// and driven on a device).
|
|
16
|
+
//
|
|
17
|
+
// HONESTY RULES — the rung must be honest to a fault, it is the vocabulary
|
|
18
|
+
// evidence is sold in:
|
|
19
|
+
// - A rung is DERIVED from which steps actually ran and PASSED. It is never
|
|
20
|
+
// declared: the `profile` argument is deliberately NOT part of the
|
|
21
|
+
// derivation — a requested profile can never buy a rung its steps did not
|
|
22
|
+
// earn (it is accepted so callers state what was asked for vs. earned).
|
|
23
|
+
// - A SKIP never upgrades. A SKIPped device step does not count toward L2;
|
|
24
|
+
// a SKIPped releaseSmoke (e.g. unsigned keystore) is NOT L3. The label
|
|
25
|
+
// can never overclaim.
|
|
26
|
+
// - A FAILED lane has no rung: the rung is only computed for a PASS
|
|
27
|
+
// verdict; the receipt of a FAIL records evidenceLevel null.
|
|
28
|
+
// - A FAST-MODE lane has no rung either — not even L0. `verify --fast` is
|
|
29
|
+
// the inner loop, a signal rather than evidence, so a fast receipt must
|
|
30
|
+
// never be silently reused as if it were a full-lane result: pass the
|
|
31
|
+
// run's mode and "fast" derives null, always.
|
|
32
|
+
// - The rung is COARSE by design. The per-step list (and the existing
|
|
33
|
+
// strength string) stays the fine print alongside it — steps that may
|
|
34
|
+
// SKIP for honest configuration absence (approvals unreviewed, no
|
|
35
|
+
// exported schemas) are visible there; only the always-run steps gate
|
|
36
|
+
// the desktop rungs, and only executed PASSes gate the device rungs.
|
|
37
|
+
|
|
38
|
+
/** The scaffold profile's step set (verify.mjs stepsForProfile.scaffold). */
|
|
39
|
+
const SCAFFOLD_CORE = [
|
|
40
|
+
"specCoverage",
|
|
41
|
+
"approvals",
|
|
42
|
+
"componentStories",
|
|
43
|
+
"reachability",
|
|
44
|
+
"archDoc",
|
|
45
|
+
"schemaHistory",
|
|
46
|
+
"build",
|
|
47
|
+
"unitTests",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/** Steps every PASS must carry to claim even L0 — they run in every profile and never SKIP. */
|
|
51
|
+
const L0_REQUIRED = ["build", "unitTests"];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The steps that distinguish full desktop evidence (L1) from the scaffold
|
|
55
|
+
* checks. None of these can SKIP — they PASS or FAIL — so "PASSed" is exactly
|
|
56
|
+
* "ran green".
|
|
57
|
+
*/
|
|
58
|
+
const L1_REQUIRED = ["releaseBuild", "conformance", "goldenTrees", "a11y"];
|
|
59
|
+
|
|
60
|
+
/** On-device EXECUTION steps — the only steps that can earn L2. */
|
|
61
|
+
const DEVICE_EXECUTION = ["e2eSmoke", "tokenDrift", "androidChecks"];
|
|
62
|
+
|
|
63
|
+
/** The one step that can lift L2 to L3. */
|
|
64
|
+
const RELEASE_EXECUTION = "releaseSmoke";
|
|
65
|
+
|
|
66
|
+
const RUNG_NAMES = { L0: "scaffold", L1: "desktop", L2: "device", L3: "release" };
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Derive the receipt's evidence rung from the lane's step results.
|
|
70
|
+
*
|
|
71
|
+
* @param {Array<{name: string, verdict: string}>} stepResults the lane's steps
|
|
72
|
+
* as recorded on the receipt (verdict PASS | FAIL | SKIP per step)
|
|
73
|
+
* @param {string} [profile] the profile that was REQUESTED — recorded context
|
|
74
|
+
* only, never part of the derivation (see honesty rules above)
|
|
75
|
+
* @param {{mode?: string}} [opts] the run's mode ("full" | "fast"). "fast"
|
|
76
|
+
* derives null unconditionally — the inner loop earns no rung (see honesty
|
|
77
|
+
* rules above). Absent/other values mean full.
|
|
78
|
+
* @returns {{rung: "L0"|"L1"|"L2"|"L3", name: string, satisfiedBy: string[]}|null}
|
|
79
|
+
* null when any step FAILed (a failed lane has no rung), when the run was
|
|
80
|
+
* fast-mode (the inner loop is never evidence), or when even the L0 floor
|
|
81
|
+
* was not earned. `satisfiedBy` lists the PASSed steps the rung counts as
|
|
82
|
+
* its evidence, in lane order.
|
|
83
|
+
*/
|
|
84
|
+
export function evidenceLevel(stepResults, profile, { mode } = {}) { // eslint-disable-line no-unused-vars
|
|
85
|
+
if (mode === "fast") return null; // the inner loop derives no rung — ever
|
|
86
|
+
const steps = Array.isArray(stepResults) ? stepResults.filter((s) => s && typeof s.name === "string") : [];
|
|
87
|
+
if (steps.some((s) => s.verdict === "FAIL")) return null; // a failed lane has no rung
|
|
88
|
+
const passed = new Set(steps.filter((s) => s.verdict === "PASS").map((s) => s.name));
|
|
89
|
+
|
|
90
|
+
if (!L0_REQUIRED.every((name) => passed.has(name))) return null; // not even a stamp-time green build
|
|
91
|
+
|
|
92
|
+
const inLaneOrder = (names) => steps.filter((s) => names.has(s.name) && passed.has(s.name)).map((s) => s.name);
|
|
93
|
+
|
|
94
|
+
let rung = "L0";
|
|
95
|
+
const counted = new Set(SCAFFOLD_CORE);
|
|
96
|
+
|
|
97
|
+
if (L1_REQUIRED.every((name) => passed.has(name))) {
|
|
98
|
+
rung = "L1";
|
|
99
|
+
for (const name of L1_REQUIRED) counted.add(name);
|
|
100
|
+
|
|
101
|
+
// Only an EXECUTED (PASSed) device step lifts to L2 — a SKIP never does.
|
|
102
|
+
const deviceRan = DEVICE_EXECUTION.some((name) => passed.has(name));
|
|
103
|
+
if (deviceRan) {
|
|
104
|
+
rung = "L2";
|
|
105
|
+
for (const name of DEVICE_EXECUTION) counted.add(name);
|
|
106
|
+
|
|
107
|
+
// Only a PASSed releaseSmoke lifts to L3 — a SKIP (unsigned keystore,
|
|
108
|
+
// no device) never does.
|
|
109
|
+
if (passed.has(RELEASE_EXECUTION)) {
|
|
110
|
+
rung = "L3";
|
|
111
|
+
counted.add(RELEASE_EXECUTION);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { rung, name: RUNG_NAMES[rung], satisfiedBy: inLaneOrder(counted) };
|
|
117
|
+
}
|
|
@@ -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
|
+
}
|