create-cmp-cli 0.17.1 → 0.19.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/package.json +1 -1
- package/packages/harness/src/approve.mjs +7 -0
- package/packages/harness/src/lib/approvals.mjs +44 -9
- package/packages/harness/src/lib/evidence-level.mjs +3 -1
- package/packages/harness/src/lib/feature-brief.mjs +88 -16
- package/packages/harness/src/lib/flight-recorder.mjs +47 -2
- package/packages/harness/src/lib/inputs-hash.mjs +9 -0
- 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 +466 -0
- package/packages/harness/src/lib/receipt-validate.mjs +4 -1
- package/packages/harness/src/lib/spec-coverage.mjs +35 -2
- 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 +1275 -0
- package/packages/harness/src/lib/walk.mjs +262 -21
- package/packages/harness/src/plan.mjs +64 -0
- package/packages/harness/src/receipt-check.mjs +59 -1
- package/packages/harness/src/verify.mjs +115 -1197
- package/packages/harness/src/walk-status.mjs +37 -1
- package/packages/receipts/src/inputs-hash.mjs +9 -0
- package/packages/receipts/src/receipt-validate.mjs +4 -1
- package/src/commands/doctor.mjs +26 -0
- package/src/lib/project-doctor.mjs +37 -0
- package/template/CLAUDE.md +73 -9
- package/template/gitignore +10 -0
- package/template/qa/approve.mjs +7 -0
- package/template/qa/lib/approvals.mjs +44 -9
- package/template/qa/lib/evidence-level.mjs +3 -1
- package/template/qa/lib/feature-brief.mjs +88 -16
- package/template/qa/lib/flight-recorder.mjs +47 -2
- package/template/qa/lib/inputs-hash.mjs +9 -0
- package/template/qa/lib/lane-narrator.mjs +97 -0
- package/template/qa/lib/lane-runner.mjs +173 -0
- package/template/qa/lib/plan.mjs +466 -0
- package/template/qa/lib/receipt-validate.mjs +4 -1
- package/template/qa/lib/spec-coverage.mjs +35 -2
- 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 +1275 -0
- package/template/qa/lib/walk.mjs +262 -21
- package/template/qa/plan.mjs +64 -0
- package/template/qa/receipt-check.mjs +59 -1
- package/template/qa/verify.mjs +115 -1197
- package/template/qa/walk-status.mjs +37 -1
- package/template/specs/README.md +26 -0
|
@@ -14,6 +14,20 @@ 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:/;
|
|
19
33
|
const TAG_IDS_RE = /SPEC:\s*([A-Z0-9,\s-]+)/;
|
|
@@ -45,7 +59,12 @@ export function scanSpecClauses(root) {
|
|
|
45
59
|
for (const line of fs.readFileSync(abs, "utf8").split("\n")) {
|
|
46
60
|
const m = line.match(CLAUSE_LINE_RE);
|
|
47
61
|
if (!m) continue;
|
|
48
|
-
|
|
62
|
+
const tierMatch = line.match(CLAUSE_TIER_RE);
|
|
63
|
+
clauses.set(m[2], {
|
|
64
|
+
file: path.relative(root, abs),
|
|
65
|
+
withdrawn: Boolean(m[1]),
|
|
66
|
+
requiredTier: tierMatch ? tierMatch[1].toLowerCase() : null,
|
|
67
|
+
});
|
|
49
68
|
}
|
|
50
69
|
}
|
|
51
70
|
return clauses;
|
|
@@ -108,6 +127,9 @@ export function scanCitations(root) {
|
|
|
108
127
|
* (commonTest/desktopTest) — behavior claims no device-tier evidence backs.
|
|
109
128
|
* `summaryLine` is the one line the lane's specCoverage step (and any other
|
|
110
129
|
* consumer) can print verbatim; null when nothing is desktop-only.
|
|
130
|
+
* `unmetTier` is the PRESCRIPTIVE half — clauses that declared `[tier: …]` and
|
|
131
|
+
* have no citation from a tier that could observe them. specCoverage FAILS on it:
|
|
132
|
+
* "instrument before you police" was the right first move, and this is the second.
|
|
111
133
|
* @param {Map<string, {file: string, withdrawn: boolean}>} clauses from scanSpecClauses
|
|
112
134
|
* @param {Array<{id: string, tier: string}>} tags from scanCitations
|
|
113
135
|
* @returns {{tiersByClause: Record<string, string[]>, desktopOnly: string[], summaryLine: string|null}}
|
|
@@ -117,6 +139,17 @@ export function clauseTierCoverage(clauses, tags) {
|
|
|
117
139
|
for (const t of tags) {
|
|
118
140
|
(tiersByClause[t.id] ??= []).includes(t.tier) || tiersByClause[t.id].push(t.tier);
|
|
119
141
|
}
|
|
142
|
+
// The gate input. A clause that DECLARED the tier it needs and has no citation
|
|
143
|
+
// from that tier is not covered — it is cited by tests structurally incapable
|
|
144
|
+
// of observing it, which is the exact hole `desktopOnly` below could only ever
|
|
145
|
+
// describe. MOTION-13 promised an animation "plays once per process start" and
|
|
146
|
+
// was cited by a desktop Compose test, a tier with no process lifecycle at all:
|
|
147
|
+
// the citation existed, the gate went green, and nothing ever observed the
|
|
148
|
+
// promise. Declared requirements are checked; undeclared clauses are unchanged.
|
|
149
|
+
const unmetTier = [...clauses.entries()]
|
|
150
|
+
.filter(([, c]) => !c.withdrawn && c.requiredTier)
|
|
151
|
+
.map(([id, c]) => ({ id, requiredTier: c.requiredTier, tiers: tiersByClause[id] ?? [], file: c.file }))
|
|
152
|
+
.filter((u) => !(TIERS_SATISFYING[u.requiredTier] ?? []).some((t) => u.tiers.includes(t)));
|
|
120
153
|
const desktopOnly = [...clauses.entries()]
|
|
121
154
|
.filter(([, c]) => !c.withdrawn)
|
|
122
155
|
.map(([id]) => id)
|
|
@@ -127,5 +160,5 @@ export function clauseTierCoverage(clauses, tags) {
|
|
|
127
160
|
const summaryLine = desktopOnly.length
|
|
128
161
|
? `${desktopOnly.length} clause${desktopOnly.length === 1 ? "" : "s"} cited only from desktop-tier tests (${desktopOnly.join(", ")})`
|
|
129
162
|
: null;
|
|
130
|
-
return { tiersByClause, desktopOnly, summaryLine };
|
|
163
|
+
return { tiersByClause, desktopOnly, unmetTier, summaryLine };
|
|
131
164
|
}
|
|
@@ -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
|
+
}
|