create-cmp-cli 0.18.0 → 0.20.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/README.md +3 -3
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/packages/harness/src/approve.mjs +30 -2
- package/packages/harness/src/lib/approvals.mjs +74 -10
- package/packages/harness/src/lib/evidence-level.mjs +3 -1
- package/packages/harness/src/lib/flight-recorder.mjs +47 -2
- package/packages/harness/src/lib/inputs-hash.mjs +71 -3
- package/packages/harness/src/lib/lane-narrator.mjs +97 -0
- package/packages/harness/src/lib/lane-runner.mjs +173 -0
- package/packages/harness/src/lib/plan.mjs +286 -20
- package/packages/harness/src/lib/receipt-validate.mjs +56 -1
- package/packages/harness/src/lib/spec-coverage.mjs +111 -3
- package/packages/harness/src/lib/step-cache.mjs +1 -1
- package/packages/harness/src/lib/step-outcomes.mjs +123 -0
- package/packages/harness/src/lib/steps-cmp.mjs +1284 -0
- package/packages/harness/src/lib/walk.mjs +67 -17
- package/packages/harness/src/receipt-check.mjs +80 -4
- package/packages/harness/src/verify.mjs +119 -1197
- package/packages/receipts/src/index.mjs +1 -0
- package/packages/receipts/src/inputs-hash.mjs +71 -3
- package/packages/receipts/src/receipt-validate.mjs +56 -1
- package/template/CLAUDE.md +52 -6
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +1 -1
- package/template/gitignore +3 -0
- package/template/qa/approve.mjs +30 -2
- package/template/qa/lib/approvals.mjs +74 -10
- package/template/qa/lib/evidence-level.mjs +3 -1
- package/template/qa/lib/flight-recorder.mjs +47 -2
- package/template/qa/lib/inputs-hash.mjs +71 -3
- package/template/qa/lib/lane-narrator.mjs +97 -0
- package/template/qa/lib/lane-runner.mjs +173 -0
- package/template/qa/lib/plan.mjs +286 -20
- package/template/qa/lib/receipt-validate.mjs +56 -1
- package/template/qa/lib/spec-coverage.mjs +111 -3
- package/template/qa/lib/step-cache.mjs +1 -1
- package/template/qa/lib/step-outcomes.mjs +123 -0
- package/template/qa/lib/steps-cmp.mjs +1284 -0
- package/template/qa/lib/walk.mjs +67 -17
- package/template/qa/receipt-check.mjs +80 -4
- package/template/qa/verify.mjs +119 -1197
- package/template/specs/README.md +26 -0
|
@@ -14,8 +14,94 @@ import path from "node:path";
|
|
|
14
14
|
|
|
15
15
|
/** `- **HOME-01** — …` (live) or `- ~~**HOME-01**~~ — …` (withdrawn). */
|
|
16
16
|
export const CLAUSE_LINE_RE = /^-\s+(~~)?\*\*([A-Z][A-Z0-9]*-\d{2,})\*\*/;
|
|
17
|
+
// An OPTIONAL tier requirement on the clause line itself:
|
|
18
|
+
//
|
|
19
|
+
// - **MOTION-13** [tier: device] — Given a cold start, When … Then …
|
|
20
|
+
//
|
|
21
|
+
// The clause declares what it takes to OBSERVE it, which is a property of the
|
|
22
|
+
// promise, not of whatever test happened to cite it. Note this attaches to the
|
|
23
|
+
// clause line, not to `[enforced: …]` — that tags docs/ARCHITECTURE.md prose and
|
|
24
|
+
// is a different grammar entirely.
|
|
25
|
+
const CLAUSE_TIER_RE = /\[tier:\s*(device|e2e)\]/i;
|
|
26
|
+
/** Which citing tiers satisfy a declared requirement. */
|
|
27
|
+
export const TIERS_SATISFYING = Object.freeze({
|
|
28
|
+
device: ["androidInstrumentedTest", "e2e"],
|
|
29
|
+
e2e: ["e2e"],
|
|
30
|
+
});
|
|
17
31
|
|
|
18
32
|
const TAG_LINE_RE = /^(?:\/\/|#)\s*SPEC:/;
|
|
33
|
+
|
|
34
|
+
// A citation is a claim that a TEST covers a clause, so it has to sit on one.
|
|
35
|
+
// Counting the tag wherever it appears makes a red specCoverage curable with a
|
|
36
|
+
// comment and zero assertions — the one escape this gate exists to close. It is
|
|
37
|
+
// not hypothetical: payment-blueprint hit a citation that had drifted onto a
|
|
38
|
+
// class declaration, where it counted for the whole file while testing nothing.
|
|
39
|
+
//
|
|
40
|
+
// So a tag counts only when a test declaration follows it within
|
|
41
|
+
// BINDING_WINDOW non-blank lines. The window is small enough that the tag must
|
|
42
|
+
// be attached to the test, and loose enough for the @DisplayName / annotation
|
|
43
|
+
// stack that idiomatically sits between them.
|
|
44
|
+
export const BINDING_WINDOW = 5;
|
|
45
|
+
|
|
46
|
+
// Kotlin @Test, a backticked test function, and the node:test / Maestro-adjacent
|
|
47
|
+
// `test(` / `it(` call forms. Deliberately syntactic: a citation's binding must
|
|
48
|
+
// be readable without compiling anything.
|
|
49
|
+
const TEST_DECL_RE = /@Test\b|\bfun\s+`[^`]+`\s*\(|\b(?:test|it)\s*\(/;
|
|
50
|
+
|
|
51
|
+
// A tag whose first meaningful line declares a TYPE is documenting that type,
|
|
52
|
+
// not claiming a test — and it must be refused structurally rather than by
|
|
53
|
+
// distance, because a short class body puts a real @Test inside the window and
|
|
54
|
+
// would otherwise launder the citation. This is exactly payment-blueprint's
|
|
55
|
+
// drift: `// SPEC: PP-07` sat on `class PaymentWorkerTest`, three properties
|
|
56
|
+
// above a genuine @Test, and vouched for the whole file.
|
|
57
|
+
const TYPE_DECL_RE = /^(?:@\w+\s+)*(?:public\s+|internal\s+|private\s+|abstract\s+|open\s+|sealed\s+|data\s+|enum\s+)*(?:class|object|interface)\b/;
|
|
58
|
+
|
|
59
|
+
// A YAML flow's own shape counts as its test: a Maestro file IS the test, so a
|
|
60
|
+
// tag in one binds to the flow rather than to a declaration inside it.
|
|
61
|
+
const FLOW_EXTS = [".yaml", ".yml"];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Does a test declaration follow `index` within BINDING_WINDOW non-blank lines,
|
|
65
|
+
* skipping block-comment bodies (a tag inside one is documentation, not a claim)?
|
|
66
|
+
* @param {string[]} lines
|
|
67
|
+
* @param {number} index line the tag sits on
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
export function citationIsBound(lines, index) {
|
|
71
|
+
let seen = 0;
|
|
72
|
+
let inBlockComment = false;
|
|
73
|
+
for (let i = index + 1; i < lines.length && seen < BINDING_WINDOW; i += 1) {
|
|
74
|
+
const line = lines[i].trim();
|
|
75
|
+
if (line === "") continue;
|
|
76
|
+
if (inBlockComment) {
|
|
77
|
+
if (line.includes("*/")) inBlockComment = false;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (line.startsWith("/*")) {
|
|
81
|
+
if (!line.includes("*/")) inBlockComment = true;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (line.startsWith("//") || line.startsWith("*")) continue;
|
|
85
|
+
seen += 1;
|
|
86
|
+
// The FIRST meaningful line decides whether this tag is on a test at all.
|
|
87
|
+
if (seen === 1 && TYPE_DECL_RE.test(line)) return false;
|
|
88
|
+
if (TEST_DECL_RE.test(line)) return true;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Is this tag inside a block comment that began earlier in the file? */
|
|
94
|
+
function insideBlockComment(lines, index) {
|
|
95
|
+
let open = false;
|
|
96
|
+
for (let i = 0; i < index; i += 1) {
|
|
97
|
+
const line = lines[i];
|
|
98
|
+
for (let c = 0; c < line.length - 1; c += 1) {
|
|
99
|
+
if (!open && line[c] === "/" && line[c + 1] === "*") open = true;
|
|
100
|
+
else if (open && line[c] === "*" && line[c + 1] === "/") open = false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return open;
|
|
104
|
+
}
|
|
19
105
|
const TAG_IDS_RE = /SPEC:\s*([A-Z0-9,\s-]+)/;
|
|
20
106
|
const CLAUSE_ID_RE = /^[A-Z][A-Z0-9]*-\d{2,}$/;
|
|
21
107
|
|
|
@@ -45,7 +131,12 @@ export function scanSpecClauses(root) {
|
|
|
45
131
|
for (const line of fs.readFileSync(abs, "utf8").split("\n")) {
|
|
46
132
|
const m = line.match(CLAUSE_LINE_RE);
|
|
47
133
|
if (!m) continue;
|
|
48
|
-
|
|
134
|
+
const tierMatch = line.match(CLAUSE_TIER_RE);
|
|
135
|
+
clauses.set(m[2], {
|
|
136
|
+
file: path.relative(root, abs),
|
|
137
|
+
withdrawn: Boolean(m[1]),
|
|
138
|
+
requiredTier: tierMatch ? tierMatch[1].toLowerCase() : null,
|
|
139
|
+
});
|
|
49
140
|
}
|
|
50
141
|
}
|
|
51
142
|
return clauses;
|
|
@@ -86,11 +177,14 @@ export function scanCitations(root) {
|
|
|
86
177
|
const tier = tierForFile(rel);
|
|
87
178
|
fs.readFileSync(f, "utf8")
|
|
88
179
|
.split("\n")
|
|
89
|
-
.forEach((line, i) => {
|
|
180
|
+
.forEach((line, i, lines) => {
|
|
90
181
|
const trimmed = line.trim();
|
|
91
182
|
if (!TAG_LINE_RE.test(trimmed)) return;
|
|
92
183
|
const m = trimmed.match(TAG_IDS_RE);
|
|
93
184
|
if (!m) return;
|
|
185
|
+
// A flow file IS its test; anything else must have a test under the tag.
|
|
186
|
+
const isFlow = FLOW_EXTS.some((ext) => rel.endsWith(ext));
|
|
187
|
+
if (!isFlow && (insideBlockComment(lines, i) || !citationIsBound(lines, i))) return;
|
|
94
188
|
const ids = m[1]
|
|
95
189
|
.split(/[,\s]+/)
|
|
96
190
|
.map((s) => s.trim())
|
|
@@ -108,6 +202,9 @@ export function scanCitations(root) {
|
|
|
108
202
|
* (commonTest/desktopTest) — behavior claims no device-tier evidence backs.
|
|
109
203
|
* `summaryLine` is the one line the lane's specCoverage step (and any other
|
|
110
204
|
* consumer) can print verbatim; null when nothing is desktop-only.
|
|
205
|
+
* `unmetTier` is the PRESCRIPTIVE half — clauses that declared `[tier: …]` and
|
|
206
|
+
* have no citation from a tier that could observe them. specCoverage FAILS on it:
|
|
207
|
+
* "instrument before you police" was the right first move, and this is the second.
|
|
111
208
|
* @param {Map<string, {file: string, withdrawn: boolean}>} clauses from scanSpecClauses
|
|
112
209
|
* @param {Array<{id: string, tier: string}>} tags from scanCitations
|
|
113
210
|
* @returns {{tiersByClause: Record<string, string[]>, desktopOnly: string[], summaryLine: string|null}}
|
|
@@ -117,6 +214,17 @@ export function clauseTierCoverage(clauses, tags) {
|
|
|
117
214
|
for (const t of tags) {
|
|
118
215
|
(tiersByClause[t.id] ??= []).includes(t.tier) || tiersByClause[t.id].push(t.tier);
|
|
119
216
|
}
|
|
217
|
+
// The gate input. A clause that DECLARED the tier it needs and has no citation
|
|
218
|
+
// from that tier is not covered — it is cited by tests structurally incapable
|
|
219
|
+
// of observing it, which is the exact hole `desktopOnly` below could only ever
|
|
220
|
+
// describe. MOTION-13 promised an animation "plays once per process start" and
|
|
221
|
+
// was cited by a desktop Compose test, a tier with no process lifecycle at all:
|
|
222
|
+
// the citation existed, the gate went green, and nothing ever observed the
|
|
223
|
+
// promise. Declared requirements are checked; undeclared clauses are unchanged.
|
|
224
|
+
const unmetTier = [...clauses.entries()]
|
|
225
|
+
.filter(([, c]) => !c.withdrawn && c.requiredTier)
|
|
226
|
+
.map(([id, c]) => ({ id, requiredTier: c.requiredTier, tiers: tiersByClause[id] ?? [], file: c.file }))
|
|
227
|
+
.filter((u) => !(TIERS_SATISFYING[u.requiredTier] ?? []).some((t) => u.tiers.includes(t)));
|
|
120
228
|
const desktopOnly = [...clauses.entries()]
|
|
121
229
|
.filter(([, c]) => !c.withdrawn)
|
|
122
230
|
.map(([id]) => id)
|
|
@@ -127,5 +235,5 @@ export function clauseTierCoverage(clauses, tags) {
|
|
|
127
235
|
const summaryLine = desktopOnly.length
|
|
128
236
|
? `${desktopOnly.length} clause${desktopOnly.length === 1 ? "" : "s"} cited only from desktop-tier tests (${desktopOnly.join(", ")})`
|
|
129
237
|
: null;
|
|
130
|
-
return { tiersByClause, desktopOnly, summaryLine };
|
|
238
|
+
return { tiersByClause, desktopOnly, unmetTier, summaryLine };
|
|
131
239
|
}
|
|
@@ -138,7 +138,7 @@ export function loadStepCache(root) {
|
|
|
138
138
|
export function lookupCachedPass(root, stepName, inputsHash) {
|
|
139
139
|
const entry = loadStepCache(root).steps[stepName];
|
|
140
140
|
if (!entry || typeof entry !== "object") return null;
|
|
141
|
-
if (entry.verdict !== "PASS") return null; // FAIL/SKIP are never reused
|
|
141
|
+
if (entry.verdict !== "PASS") return null; // FAIL/SKIP/ERROR are never reused
|
|
142
142
|
if (typeof inputsHash !== "string" || entry.inputsHash !== inputsHash) return null;
|
|
143
143
|
if (typeof entry.at !== "string") return null;
|
|
144
144
|
return entry;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// step-outcomes.mjs — a step's VERDICT, separated from its INVOCATION.
|
|
2
|
+
//
|
|
3
|
+
// A step that ran zero tests knows nothing about behaviour and must not speak
|
|
4
|
+
// as though it does. Observed 2026-09-02 (create-cmp-showcase): a concurrent
|
|
5
|
+
// adb session collided with androidChecks, Gradle exited non-zero having
|
|
6
|
+
// executed no tests, and the step reported "an on-device behavior claim is
|
|
7
|
+
// broken. Fix the behavior, not the test." The identical task passed 8 tests
|
|
8
|
+
// moments later. Believed, that sends the reader hunting a defect that does not
|
|
9
|
+
// exist; disbelieved once, it teaches them to discount every future red from
|
|
10
|
+
// the step — a gate that misattributes its own failures corrodes the gates that
|
|
11
|
+
// are right.
|
|
12
|
+
//
|
|
13
|
+
// Pure, so the wording and the rule are testable without Gradle or a device.
|
|
14
|
+
// (docs/proposals/evidence-economics.md C3, S4.)
|
|
15
|
+
//
|
|
16
|
+
// FOUR VERDICTS. PASS / FAIL / SKIP had no way to say "I could not run": a
|
|
17
|
+
// step whose infrastructure broke reported a behaviour failure. ERROR is that
|
|
18
|
+
// fourth word — zero tests executed, a deadline passed, a tool vanished, a
|
|
19
|
+
// step threw. An ERROR never accuses the change, never counts as evidence
|
|
20
|
+
// (evidence-level derives no rung over it; the plausibility check does not
|
|
21
|
+
// count it as executed), is visibly distinct from FAIL (⊘, not ✗), and is
|
|
22
|
+
// never silently retried. It still makes the lane FAIL — "could not check" is
|
|
23
|
+
// not green. This is JUnit's error-vs-failure, Bazel's FAILED_TO_BUILD /
|
|
24
|
+
// TIMEOUT vs FAILED, pytest's error vs failed — the distinction every mature
|
|
25
|
+
// runner makes and this one did not.
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The androidChecks outcome from Gradle's exit and the JUnit summary.
|
|
29
|
+
*
|
|
30
|
+
* @param {{ok: boolean, out: string}} res the Gradle invocation
|
|
31
|
+
* @param {{tests: number, failures: number, errors: number}|null} summary parsed JUnit
|
|
32
|
+
* results, or null when none were written
|
|
33
|
+
* @param {{gradlew?: string}} [opts]
|
|
34
|
+
* @returns {{verdict: "PASS"|"FAIL"|"ERROR", executed: boolean, reason?: string}}
|
|
35
|
+
*/
|
|
36
|
+
export function androidChecksOutcome(res, summary, { gradlew = "./gradlew" } = {}) {
|
|
37
|
+
const executed = Boolean(summary && summary.tests > 0);
|
|
38
|
+
if (res.ok) return { verdict: "PASS", executed };
|
|
39
|
+
const tail = String(res.out ?? "")
|
|
40
|
+
.split("\n")
|
|
41
|
+
.filter((l) => /FAILED|error:|failed/i.test(l))
|
|
42
|
+
.slice(0, 12)
|
|
43
|
+
.join("\n");
|
|
44
|
+
if (executed) {
|
|
45
|
+
return {
|
|
46
|
+
verdict: "FAIL",
|
|
47
|
+
executed,
|
|
48
|
+
reason:
|
|
49
|
+
`connectedDebugAndroidTest failed (${summary.failures + summary.errors} of ${summary.tests} tests) — ` +
|
|
50
|
+
`an on-device behavior claim is broken. Fix the behavior, not the test:\n${tail}`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
// ERROR, not FAIL: the step could not execute. A device tier that could not
|
|
54
|
+
// run is not evidence (the lane still FAILs), and going green would be the
|
|
55
|
+
// worse lie — but "your behaviour is broken" is withdrawn, and the receipt
|
|
56
|
+
// can tell a red that measured something from a red that measured nothing.
|
|
57
|
+
return {
|
|
58
|
+
verdict: "ERROR",
|
|
59
|
+
executed,
|
|
60
|
+
reason:
|
|
61
|
+
"connectedDebugAndroidTest DID NOT EXECUTE — the run reported no tests at all, so this step has observed " +
|
|
62
|
+
"nothing about your change and is not accusing it. Usual cause: another adb/Gradle session touching the same " +
|
|
63
|
+
"device (a manual `adb` command, a second lane, a running preview), or an install that never landed. " +
|
|
64
|
+
`Re-run this step alone with nothing else on the device before suspecting the code:\n ${gradlew} :composeApp:connectedDebugAndroidTest --rerun\n${tail}`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Thrown by the lane's subprocess helper when a step's deadline passes. */
|
|
69
|
+
export class StepTimeout extends Error {
|
|
70
|
+
constructor(cmd, deadlineMs) {
|
|
71
|
+
super(`deadline of ${Math.round(deadlineMs / 60000)} min passed: ${cmd}`);
|
|
72
|
+
this.name = "StepTimeout";
|
|
73
|
+
this.cmd = cmd;
|
|
74
|
+
this.deadlineMs = deadlineMs;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Did a spawnSync result hit its deadline? Node reports ETIMEDOUT on
|
|
80
|
+
* `error.code` and the kill signal on `signal`; either alone is enough — an
|
|
81
|
+
* older Node sets only one of them.
|
|
82
|
+
* @param {{error?: {code?: string}, signal?: string|null}} res
|
|
83
|
+
* @returns {boolean}
|
|
84
|
+
*/
|
|
85
|
+
export function spawnTimedOut(res) {
|
|
86
|
+
if (!res) return false;
|
|
87
|
+
if (res.error && res.error.code === "ETIMEDOUT") return true;
|
|
88
|
+
return res.signal === "SIGTERM" && (res.status === null || res.status === undefined);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A step's own deadline, from the journal's last measured duration for it:
|
|
93
|
+
* three times what it usually takes, never under five minutes (a cold Gradle
|
|
94
|
+
* daemon is slow, not wedged), never over thirty (past that it IS wedged).
|
|
95
|
+
* Unknown steps get the ceiling — a first run is never cut short.
|
|
96
|
+
* @param {number|null|undefined} expectedMs
|
|
97
|
+
* @returns {number}
|
|
98
|
+
*/
|
|
99
|
+
export function stepDeadlineMs(expectedMs, { floorMs = 5 * 60_000, ceilingMs = 30 * 60_000 } = {}) {
|
|
100
|
+
if (!(expectedMs > 0)) return ceilingMs;
|
|
101
|
+
return Math.min(ceilingMs, Math.max(floorMs, Math.round(expectedMs * 3)));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The step result for a step that could not run — a deadline, or any throw
|
|
106
|
+
* out of the step's own body (which used to crash the whole lane; now it is
|
|
107
|
+
* one ERROR row and the lane keeps going, because the other steps' verdicts
|
|
108
|
+
* are still worth having).
|
|
109
|
+
* @param {string} name the step's display name
|
|
110
|
+
* @param {unknown} err
|
|
111
|
+
* @param {number} durationMs
|
|
112
|
+
* @returns {{name: string, verdict: "ERROR", reason: string, durationMs: number, details: {executed: false, kind: string}}}
|
|
113
|
+
*/
|
|
114
|
+
export function stepErrorResult(name, err, durationMs) {
|
|
115
|
+
const timeout = err instanceof StepTimeout;
|
|
116
|
+
const reason = timeout
|
|
117
|
+
? `DID NOT COMPLETE — no result within its deadline (${Math.round(err.deadlineMs / 60000)} min). This step has observed nothing about your change and is not accusing it. ` +
|
|
118
|
+
`A wedged Gradle daemon or a device that stopped answering are the usual causes; check \`./gradlew --status\` and \`adb devices\`, then re-run the step alone.
|
|
119
|
+
${err.cmd}`
|
|
120
|
+
: `DID NOT RUN — the step threw before producing a verdict: ${err && err.message ? err.message : String(err)}. ` +
|
|
121
|
+
`Nothing here is a claim about your change.`;
|
|
122
|
+
return { name, verdict: "ERROR", reason, durationMs, details: { executed: false, kind: timeout ? "deadline" : "threw" } };
|
|
123
|
+
}
|