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.
Files changed (42) hide show
  1. package/README.md +3 -3
  2. package/llms.txt +1 -1
  3. package/package.json +1 -1
  4. package/packages/harness/src/approve.mjs +30 -2
  5. package/packages/harness/src/lib/approvals.mjs +74 -10
  6. package/packages/harness/src/lib/evidence-level.mjs +3 -1
  7. package/packages/harness/src/lib/flight-recorder.mjs +47 -2
  8. package/packages/harness/src/lib/inputs-hash.mjs +71 -3
  9. package/packages/harness/src/lib/lane-narrator.mjs +97 -0
  10. package/packages/harness/src/lib/lane-runner.mjs +173 -0
  11. package/packages/harness/src/lib/plan.mjs +286 -20
  12. package/packages/harness/src/lib/receipt-validate.mjs +56 -1
  13. package/packages/harness/src/lib/spec-coverage.mjs +111 -3
  14. package/packages/harness/src/lib/step-cache.mjs +1 -1
  15. package/packages/harness/src/lib/step-outcomes.mjs +123 -0
  16. package/packages/harness/src/lib/steps-cmp.mjs +1284 -0
  17. package/packages/harness/src/lib/walk.mjs +67 -17
  18. package/packages/harness/src/receipt-check.mjs +80 -4
  19. package/packages/harness/src/verify.mjs +119 -1197
  20. package/packages/receipts/src/index.mjs +1 -0
  21. package/packages/receipts/src/inputs-hash.mjs +71 -3
  22. package/packages/receipts/src/receipt-validate.mjs +56 -1
  23. package/template/CLAUDE.md +52 -6
  24. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +1 -1
  25. package/template/gitignore +3 -0
  26. package/template/qa/approve.mjs +30 -2
  27. package/template/qa/lib/approvals.mjs +74 -10
  28. package/template/qa/lib/evidence-level.mjs +3 -1
  29. package/template/qa/lib/flight-recorder.mjs +47 -2
  30. package/template/qa/lib/inputs-hash.mjs +71 -3
  31. package/template/qa/lib/lane-narrator.mjs +97 -0
  32. package/template/qa/lib/lane-runner.mjs +173 -0
  33. package/template/qa/lib/plan.mjs +286 -20
  34. package/template/qa/lib/receipt-validate.mjs +56 -1
  35. package/template/qa/lib/spec-coverage.mjs +111 -3
  36. package/template/qa/lib/step-cache.mjs +1 -1
  37. package/template/qa/lib/step-outcomes.mjs +123 -0
  38. package/template/qa/lib/steps-cmp.mjs +1284 -0
  39. package/template/qa/lib/walk.mjs +67 -17
  40. package/template/qa/receipt-check.mjs +80 -4
  41. package/template/qa/verify.mjs +119 -1197
  42. 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
- clauses.set(m[2], { file: path.relative(root, abs), withdrawn: Boolean(m[1]) });
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
+ }