create-cmp-cli 0.19.0 → 0.21.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 (33) hide show
  1. package/package.json +1 -1
  2. package/packages/harness/src/approve.mjs +23 -2
  3. package/packages/harness/src/lib/affected-tests.mjs +9 -1
  4. package/packages/harness/src/lib/approvals.mjs +42 -4
  5. package/packages/harness/src/lib/evidence-badge.mjs +11 -0
  6. package/packages/harness/src/lib/evidence-level.mjs +33 -2
  7. package/packages/harness/src/lib/inputs-hash.mjs +97 -3
  8. package/packages/harness/src/lib/lane-runner.mjs +7 -0
  9. package/packages/harness/src/lib/receipt-validate.mjs +52 -0
  10. package/packages/harness/src/lib/spec-coverage.mjs +76 -1
  11. package/packages/harness/src/lib/steps-cmp.mjs +37 -0
  12. package/packages/harness/src/lib/walk.mjs +1 -1
  13. package/packages/harness/src/receipt-check.mjs +24 -1
  14. package/packages/harness/src/verify.mjs +27 -7
  15. package/packages/receipts/src/index.mjs +1 -0
  16. package/packages/receipts/src/inputs-hash.mjs +97 -3
  17. package/packages/receipts/src/receipt-validate.mjs +52 -0
  18. package/template/CLAUDE.md +8 -0
  19. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +1 -1
  20. package/template/qa/approve.mjs +23 -2
  21. package/template/qa/evidence/schema.json +6 -1
  22. package/template/qa/lib/affected-tests.mjs +9 -1
  23. package/template/qa/lib/approvals.mjs +42 -4
  24. package/template/qa/lib/evidence-badge.mjs +11 -0
  25. package/template/qa/lib/evidence-level.mjs +33 -2
  26. package/template/qa/lib/inputs-hash.mjs +97 -3
  27. package/template/qa/lib/lane-runner.mjs +7 -0
  28. package/template/qa/lib/receipt-validate.mjs +52 -0
  29. package/template/qa/lib/spec-coverage.mjs +76 -1
  30. package/template/qa/lib/steps-cmp.mjs +37 -0
  31. package/template/qa/lib/walk.mjs +1 -1
  32. package/template/qa/receipt-check.mjs +24 -1
  33. package/template/qa/verify.mjs +27 -7
@@ -17,8 +17,19 @@ import { createHash } from "node:crypto";
17
17
  import fs from "node:fs";
18
18
  import path from "node:path";
19
19
 
20
- // Directories / files INCLUDED in the verified surface (relative to project ROOT).
20
+ // Directories / files included in the verified surface (relative to project ROOT).
21
21
  // Principle: every tracked file whose content can change the lane's verdict.
22
+ //
23
+ // THIS IS A DEFAULT, NOT A LAW (evidence-economics S8, 2026-09-03). It is the
24
+ // surface of a Compose Multiplatform app, and it used to be hardcoded inside
25
+ // this module — which is the SPINE, shared by every adopter. A repo whose code
26
+ // lives in services/ or src/ that vendored this file had its verified surface
27
+ // silently shrink to whatever happened to match: no error, no failed step, a
28
+ // receipt that still validated and still looked identical, and a hash that had
29
+ // quietly stopped covering the application. A gate that attests less while
30
+ // looking the same is the worst failure this harness can have, so the surface
31
+ // is now resolved per project (see resolveVerifiedSurface) and an empty one is
32
+ // refused rather than hashed.
22
33
  export const VERIFIED_SURFACE = [
23
34
  "composeApp",
24
35
  "specs",
@@ -147,9 +158,52 @@ function walkAllFiles(dir) {
147
158
  return out;
148
159
  }
149
160
 
161
+ /** Where a project may declare its own verified surface (see resolveVerifiedSurface). */
162
+ export const SURFACE_CONFIG_REL = "qa/verified-surface.json";
163
+
164
+ /**
165
+ * The surface THIS project attests — its own declaration when it has one, the
166
+ * Compose Multiplatform default otherwise.
167
+ *
168
+ * Read from a file rather than passed as an argument on purpose: qa/verify.mjs
169
+ * (which writes inputs.hash) and qa/receipt-check.mjs (which recomputes it)
170
+ * must never disagree about what was hashed, and two call sites taking a
171
+ * parameter is two places to get it wrong. The file lives under qa/, so it is
172
+ * itself inside the surface — changing the definition invalidates receipts,
173
+ * which is correct: the tree's coverage changed.
174
+ *
175
+ * Shape: {"surface": ["services", "docs", "build-logic", ".github", "qa"]}.
176
+ * Malformed or empty content is REFUSED, never silently defaulted — a project
177
+ * that tried to declare a surface and failed must not fall back to a smaller
178
+ * one behind the operator's back.
179
+ *
180
+ * @param {string} root project root
181
+ * @returns {string[]} surface entries, relative to root
182
+ */
183
+ export function resolveVerifiedSurface(root) {
184
+ const p = path.join(root, SURFACE_CONFIG_REL);
185
+ let raw;
186
+ try {
187
+ raw = fs.readFileSync(p, "utf8");
188
+ } catch {
189
+ return VERIFIED_SURFACE; // no declaration — the CMP default, unchanged
190
+ }
191
+ let parsed;
192
+ try {
193
+ parsed = JSON.parse(raw);
194
+ } catch (err) {
195
+ throw new Error(`${SURFACE_CONFIG_REL} is not valid JSON (${err.message}) — refusing to hash a surface this project failed to declare.`);
196
+ }
197
+ const list = parsed && Array.isArray(parsed.surface) ? parsed.surface.filter((x) => typeof x === "string" && x.trim() !== "") : null;
198
+ if (!list || list.length === 0) {
199
+ throw new Error(`${SURFACE_CONFIG_REL} declares no surface — expected {"surface": ["dir", …]}. Refusing to hash nothing.`);
200
+ }
201
+ return list;
202
+ }
203
+
150
204
  // Resolve the verified surface to a flat, sorted list of paths (relative to
151
205
  // root, POSIX-style `/` separators) that currently exist on disk.
152
- function resolveSurfaceFiles(root) {
206
+ function resolveSurfaceFiles(root, VERIFIED_SURFACE) {
153
207
  const gitFiles = tryGitLsFiles(root);
154
208
 
155
209
  if (gitFiles) {
@@ -178,6 +232,33 @@ function resolveSurfaceFiles(root) {
178
232
  return collected.filter((relPath) => !isExcluded(relPath));
179
233
  }
180
234
 
235
+ /**
236
+ * Top-level entries (first path segment of every file git would commit) that
237
+ * NO surface entry covers — the files the receipt does not attest. An
238
+ * allowlist is silent about what it omits: a new top-level directory is simply
239
+ * unmatched, no error, unattested (payment-blueprint, 2026-09-03). This names
240
+ * the omission so the receipt can carry it and a reader can decide whether
241
+ * it belongs in qa/verified-surface.json. Sorted; [] when git is unavailable
242
+ * (the walk fallback has no notion of "what git sees") or everything is
243
+ * covered. Lane outputs (EXCLUDED_PREFIXES) are not "undeclared" — they are
244
+ * excluded by decision.
245
+ * @param {string} root
246
+ * @param {string[]} [surface] defaults to resolveVerifiedSurface(root)
247
+ * @returns {string[]}
248
+ */
249
+ export function undeclaredTopLevel(root, surface = resolveVerifiedSurface(root)) {
250
+ const gitFiles = tryGitLsFiles(root);
251
+ if (!gitFiles) return [];
252
+ const covered = (relPath) => surface.some((entry) => relPath === entry || relPath.startsWith(`${entry}/`)) || isExcluded(relPath);
253
+ const out = new Set();
254
+ for (const raw of gitFiles) {
255
+ const relPath = raw.split(path.sep).join("/");
256
+ if (covered(relPath)) continue;
257
+ out.add(relPath.includes("/") ? relPath.slice(0, relPath.indexOf("/")) : relPath);
258
+ }
259
+ return [...out].sort();
260
+ }
261
+
181
262
  /**
182
263
  * Compute the sha256 hash of the verified surface for the project rooted at `root`.
183
264
  * Deterministic: same tree (same file paths + same file bytes) → same hash.
@@ -189,7 +270,20 @@ export function computeInputsHash(root) {
189
270
  // on iteration order, and ICU collation varies with the machine's locale
190
271
  // (e.g. a da_DK machine orders "aa" after "z"; en orders case-insensitively
191
272
  // where code units do not) — the same tree must hash identically everywhere.
192
- const files = [...new Set(resolveSurfaceFiles(root))].sort();
273
+ const surface = resolveVerifiedSurface(root);
274
+ const files = [...new Set(resolveSurfaceFiles(root, surface))].sort();
275
+
276
+ // A surface that matches NOTHING is a misconfiguration, not a valid hash.
277
+ // Hashing zero files yields a stable, confident-looking digest that attests
278
+ // the empty set — the silent shrink this whole change exists to prevent, in
279
+ // its most extreme form. Refuse, and name what was looked for.
280
+ if (files.length === 0) {
281
+ throw new Error(
282
+ `the verified surface matched no files under ${root} — nothing would be attested. ` +
283
+ `Surface: ${surface.join(", ")}. ` +
284
+ `A project whose code lives elsewhere declares its own in ${SURFACE_CONFIG_REL}: {"surface": ["services", "qa", …]}.`,
285
+ );
286
+ }
193
287
 
194
288
  const overall = createHash("sha256");
195
289
  for (const relPath of files) {
@@ -145,6 +145,13 @@ export function runLane(ctx) {
145
145
  } catch (err) {
146
146
  result = stepErrorResult(name, err, Date.now() - stepStarted);
147
147
  }
148
+ // Layer tag: a pack may mark a step function with the layer of the
149
+ // stack it proves (`fn.layer = "backend"`). The runner stamps it onto
150
+ // the row so the receipt carries it and the console can group by it —
151
+ // a step that set its own `layer` in the result keeps its word.
152
+ if (result && typeof result === "object" && typeof step.layer === "string" && step.layer && typeof result.layer !== "string") {
153
+ result.layer = step.layer;
154
+ }
148
155
  results.push(result);
149
156
  if (print) {
150
157
  print(
@@ -45,6 +45,51 @@ export function readReceipt(root, relPath = RECEIPT_REL_PATH) {
45
45
  * FAIL verdict), so callers don't pay for a hash they don't need.
46
46
  * @returns {{valid: boolean, reason: string, profile: (string|undefined), recomputed?: {hash: string, fileCount: number}}}
47
47
  */
48
+ /**
49
+ * Does this receipt's own row-level evidence support its PASS?
50
+ *
51
+ * The receipt is necessarily excluded from the inputs hash it carries — a file
52
+ * cannot hash itself — so steps[] is the only thing between this gate and a text
53
+ * editor, and the top-level verdict is the most editable field on it.
54
+ *
55
+ * Two failures this catches, both observed downstream (payment-blueprint F2/F3):
56
+ * a receipt whose verdict was hand-edited from FAIL to PASS while its rows still
57
+ * said otherwise, and a lane made green by DELETING harness.lock.json, which
58
+ * downgraded harnessIntegrity from FAIL to SKIP and took the lane's verdict with
59
+ * it — a lane vouching for a tree with nothing vouching for the lane.
60
+ *
61
+ * @param {{verdict?: string, steps?: Array<{name?: string, verdict?: string}>}} receipt
62
+ * @returns {{ok: boolean, detail: string}}
63
+ */
64
+ export function checkLaneVouching(receipt) {
65
+ const steps = Array.isArray(receipt?.steps) ? receipt.steps : null;
66
+ if (!steps || steps.length === 0) {
67
+ return { ok: false, detail: "receipt lists no verify-lane steps — a PASS over nothing attests nothing" };
68
+ }
69
+ const failed = steps.filter((s) => s && (s.verdict === "FAIL" || s.verdict === "ERROR"));
70
+ if (failed.length > 0) {
71
+ const names = failed.map((s) => `${s.name ?? "?"} (${s.verdict})`).join(", ");
72
+ return {
73
+ ok: false,
74
+ detail: `the receipt's verdict is PASS but ${failed.length} step(s) did not pass: ${names} — the row is the more specific truth`,
75
+ };
76
+ }
77
+ const integrity = steps.find((s) => s && s.name === "harnessIntegrity");
78
+ if (!integrity) {
79
+ return {
80
+ ok: false,
81
+ detail: "receipt has no harnessIntegrity row — nothing vouches that the lane's own code is the code that ran",
82
+ };
83
+ }
84
+ if (integrity.verdict !== "PASS") {
85
+ return {
86
+ ok: false,
87
+ detail: `harnessIntegrity is ${integrity.verdict}, not PASS — the lane did not vouch for itself, so its PASS over the tree cannot be trusted`,
88
+ };
89
+ }
90
+ return { ok: true, detail: "lane vouched for itself (harnessIntegrity PASS, no failing rows)" };
91
+ }
92
+
48
93
  export function evaluateReceipt(receipt, recompute) {
49
94
  const profile = receipt.profile;
50
95
 
@@ -83,6 +128,13 @@ export function evaluateReceipt(receipt, recompute) {
83
128
  };
84
129
  }
85
130
 
131
+ // Did the lane vouch for ITSELF? See checkLaneVouching — the top-level verdict
132
+ // is the most editable field on a file the hash cannot cover.
133
+ const vouching = checkLaneVouching(receipt);
134
+ if (!vouching.ok) {
135
+ return { valid: false, reason: `${vouching.detail} (attesting profile: ${profile ?? "unknown"})`, profile, recomputed };
136
+ }
137
+
86
138
  return { valid: true, reason: `receipt is valid — PASS, attesting profile: ${profile ?? "unknown"}`, profile, recomputed };
87
139
  }
88
140
 
@@ -30,6 +30,78 @@ export const TIERS_SATISFYING = Object.freeze({
30
30
  });
31
31
 
32
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
+ }
33
105
  const TAG_IDS_RE = /SPEC:\s*([A-Z0-9,\s-]+)/;
34
106
  const CLAUSE_ID_RE = /^[A-Z][A-Z0-9]*-\d{2,}$/;
35
107
 
@@ -105,11 +177,14 @@ export function scanCitations(root) {
105
177
  const tier = tierForFile(rel);
106
178
  fs.readFileSync(f, "utf8")
107
179
  .split("\n")
108
- .forEach((line, i) => {
180
+ .forEach((line, i, lines) => {
109
181
  const trimmed = line.trim();
110
182
  if (!TAG_LINE_RE.test(trimmed)) return;
111
183
  const m = trimmed.match(TAG_IDS_RE);
112
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;
113
188
  const ids = m[1]
114
189
  .split(/[,\s]+/)
115
190
  .map((s) => s.trim())
@@ -32,6 +32,8 @@ import { DETERMINISM_TIMEZONES, compareOutcomes, parseJUnitOutcomes } from "./de
32
32
  import { evaluateAuditCadence } from "./audit-cadence.mjs";
33
33
  import { androidChecksOutcome } from "./step-outcomes.mjs";
34
34
  import { checkHarnessIntegrity, describeIntegrity, LOCK_PATH } from "./harness-lock.mjs";
35
+ import { stepDisplayName } from "./lane-runner.mjs";
36
+ import { CMP_LADDER } from "./evidence-level.mjs";
35
37
 
36
38
  /**
37
39
  * @param {object} ctx
@@ -1177,6 +1179,15 @@ const stepsForProfile = {
1177
1179
  // scaffold: what `create-cmp --verify` proves at stamp time — specCoverage,
1178
1180
  // the full JVM tier (unit + conformance + golden + UI tests) plus the Android build.
1179
1181
  scaffold: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepArchDocMemo, stepSchemaHistory, stepBuild, stepUnitTests],
1182
+ // smoke (docs/GATE-RULES.md Rule 0, docs/PRINCIPLES.md #2): the smallest
1183
+ // end-to-end lane — every pure-Node step through the REAL runner, marker,
1184
+ // receipt and journal, and NO Gradle, no device, no network. Its job is to
1185
+ // prove the framework RETURNS, fast, in both directions, before any real
1186
+ // work is pointed at it. scripts/framework-check.mjs drives it: PASS on a
1187
+ // fresh scaffold, then FAIL BY NAME on one planted spec edit, each bounded
1188
+ // in seconds. Its receipt is refused as done-evidence (qa/receipt-check.mjs)
1189
+ // exactly like --fast: it proves the instrument, never the change.
1190
+ smoke: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepArchDocMemo, stepSchemaHistory],
1180
1191
  local: [
1181
1192
  // First, always: every verdict below is only worth what the lane issuing
1182
1193
  // it is worth.
@@ -1244,6 +1255,28 @@ stepsForProfile.release = [...stepsForProfile.ci, stepAuditCadence, stepReleaseS
1244
1255
  // what differs is what is forced, and what the receipt is allowed to mean.
1245
1256
  stepsForProfile.nightly = [...stepsForProfile.ci];
1246
1257
 
1258
+ // Which layer of the stack each step proves — stamped onto the receipt row by
1259
+ // the runner (lane-runner.mjs) so the Evidence pane can group by it and a
1260
+ // multi-pack lane (a Compose app over a Kotlin backend) reads as one lane
1261
+ // with per-layer tallies. Three layers for this pack: `spine` — the harness
1262
+ // proving itself and the governed record (integrity, spec coverage,
1263
+ // approvals, the architecture doc, the schema history, the audit cadence);
1264
+ // `compose` — the JVM tier of the app (build, tests, conformance, goldens,
1265
+ // a11y, release compile); `device` — anything that needs an emulator or a
1266
+ // physical device. Layer names are free-form strings on the wire; these are
1267
+ // this pack's. Derived by NAME after the lists are built so a step listed in
1268
+ // two profiles is tagged once, and a step nobody listed is never tagged.
1269
+ const SPINE_STEP_NAMES = new Set(["harnessIntegrity", "specCoverage", "approvals", "componentStories", "reachability", "archDoc", "schemaHistory", "auditCadence", "determinism"]);
1270
+ function layerForStep(name) {
1271
+ if (DEVICE_STEPS.includes(name)) return "device";
1272
+ if (SPINE_STEP_NAMES.has(name)) return "spine";
1273
+ return "compose";
1274
+ }
1275
+ for (const fn of new Set(Object.values(stepsForProfile).flat())) {
1276
+ const name = stepDisplayName(fn);
1277
+ if (name) fn.layer = layerForStep(name);
1278
+ }
1279
+
1247
1280
  const FAST_EXCLUDED_NAMES = [...DEVICE_STEPS, "releaseBuild"];
1248
1281
  const STEP_FN_BY_NAME = {
1249
1282
  e2eSmoke: stepE2eSmoke,
@@ -1266,6 +1299,10 @@ for (const name of FAST_EXCLUDED_NAMES) {
1266
1299
  FAST_EXCLUDED_NAMES,
1267
1300
  STEP_FN_BY_NAME,
1268
1301
  stepDeterminism,
1302
+ // The ladder this pack's steps can earn (evidence-level.mjs). A pack that
1303
+ // returns none earns no rung — the spine never grades a pack by another
1304
+ // pack's step names.
1305
+ evidenceLadder: CMP_LADDER,
1269
1306
  // The device lease is held to the very end of the run (see the scope
1270
1307
  // decision above); the spine releases it in the runner's finally.
1271
1308
  releaseLease: () => {
@@ -512,7 +512,7 @@ export function renderInject(data) {
512
512
  for (const a of arrivals)
513
513
  parts.push(`▲ ARRIVED, UNPLANNED — ${a.label} (${a.status}): ${a.reason ?? "no recorded reason"}. Offer: handle now, or after the current walk lands (recommended: after).`);
514
514
  parts.push(
515
- "Protocol: open every reply with the chat header line above, verbatim. Speak stages as Decide·Design·Contract·Build·Prove·Sign-off — with their plain-words gloss on first mention — and clauses as promises. Declare the chain at kickoff and advance it as you go; if the studio line above says DOWN or not running, restore it (preview tool) or surface it before proceeding. Quiet between headers (one line per stage transition). At any human gate, render the full stop card: stage, what it is in plain words, then the easiest act first (the studio console when it is up; the CLI as fallback), then what comes after. Quote the lane's cost only from the measured figure in the card — never estimate it. Never open a second walk silently.",
515
+ "Protocol: open every reply with the chat header line above, verbatim. Speak stages as Decide·Design·Contract·Build·Prove·Sign-off — with their plain-words gloss on first mention — and clauses as promises. Declare the chain at kickoff and advance it as you go; if the studio line above says DOWN or not running, restore it (preview tool) or surface it before proceeding. Quiet between headers (one line per stage transition). At any human gate, render the full stop card: stage, what it is in plain words, then the easiest act first (the studio console when it is up; the CLI as fallback), then what comes after. Quote the lane's cost only from the measured figure in the card — never estimate it. Never open a second walk silently. Principles that bind THIS turn (docs/PRINCIPLES.md): derived, never claimed — name the command behind any claim; the layer you changed cannot certify itself — run its consumers, stamp a fresh app for a template or harness change; never wait on nothing — every wait is bounded, and if you are blocked, say on what and stop.",
516
516
  );
517
517
  return parts.join("\n\n");
518
518
  }
@@ -99,7 +99,30 @@ function evaluate() {
99
99
  profile: receipt.profile,
100
100
  };
101
101
  }
102
- const result = evaluateReceipt(receipt, () => computeInputsHash(ROOT));
102
+ // smoke (GATE-RULES Rule 0) runs no Gradle: it proves the framework returns,
103
+ // never that the change is good. Refused like --fast, for the same reason.
104
+ if (receipt.stage === "smoke" || receipt.profile === "smoke") {
105
+ return {
106
+ valid: false,
107
+ reason: "the last verify run was the smoke profile (the framework check — no build, no tests; it proves the instrument, not this change); run the change-stage lane (`node qa/verify.mjs`) before finishing",
108
+ profile: receipt.profile,
109
+ };
110
+ }
111
+ // A surface this project cannot resolve is a REFUSAL with an explanation,
112
+ // never an unhandled stack trace: this runs as the Stop hook on every turn
113
+ // end, and a crash there reads as a broken harness rather than as the
114
+ // misconfiguration it is. (evidence-economics S8 follow-up: computeInputsHash
115
+ // now throws rather than returning a confident hash of the empty set.)
116
+ let result;
117
+ try {
118
+ result = evaluateReceipt(receipt, () => computeInputsHash(ROOT));
119
+ } catch (err) {
120
+ return {
121
+ valid: false,
122
+ reason: `cannot verify this receipt — ${err && err.message ? err.message : String(err)}`,
123
+ profile: receipt.profile,
124
+ };
125
+ }
103
126
  // Surface the receipt's evidence rung (the ladder — qa/lib/evidence-level.mjs)
104
127
  // alongside the verdict: the rung is the receipt's own derived field, read
105
128
  // verbatim, never recomputed here. Older receipts without it stay valid.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // The verify lane — this project's single verification gate.
3
3
  //
4
- // node qa/verify.mjs [--profile scaffold|local|ci|nightly|release] [--fast] [--json]
4
+ // node qa/verify.mjs [--profile smoke|scaffold|local|ci|nightly|release] [--fast] [--json]
5
5
  //
6
6
  // Runs every verification step this project carries, aggregates a typed
7
7
  // PASS/FAIL verdict, and writes the evidence receipt to qa/evidence/latest.json.
@@ -31,7 +31,7 @@ import fs from "node:fs";
31
31
  import path from "node:path";
32
32
  import { fileURLToPath } from "node:url";
33
33
 
34
- import { computeInputsHash } from "./lib/inputs-hash.mjs";
34
+ import { computeInputsHash, undeclaredTopLevel } from "./lib/inputs-hash.mjs";
35
35
  import { evidenceLevel } from "./lib/evidence-level.mjs";
36
36
  import { updateReadmeBadge, README_REL_PATH } from "./lib/evidence-badge.mjs";
37
37
  import { appendFlightRecord, buildFlightEntry, neverRunTiers, readFlightJournal } from "./lib/flight-recorder.mjs";
@@ -51,7 +51,7 @@ const ARTIFACTS_DIR = path.join(ROOT, "qa-artifacts");
51
51
  // killed). Same refusal-over-fabrication stance as qa/approve.mjs, which
52
52
  // refuses an unknown artifact by name rather than guessing: an unknown
53
53
  // argument here is refused by name, not swallowed into "run everything".
54
- const USAGE = `node qa/verify.mjs [--profile scaffold|local|ci|nightly|release] [--fast] [--json] [--help]
54
+ const USAGE = `node qa/verify.mjs [--profile smoke|scaffold|local|ci|nightly|release] [--fast] [--json] [--help]
55
55
 
56
56
  The verify lane — this project's single verification gate. Runs every
57
57
  verification step this project carries, aggregates a typed PASS/FAIL
@@ -59,7 +59,7 @@ verdict, and writes the evidence receipt to qa/evidence/latest.json (commit
59
59
  it with your change — see CLAUDE.md). Exit code: 0 = PASS, 1 = FAIL.
60
60
 
61
61
  Flags:
62
- --profile <scaffold|local|ci|nightly|release>
62
+ --profile <smoke|scaffold|local|ci|nightly|release>
63
63
  which step set to run (default: local)
64
64
  --fast INNER LOOP ONLY — run the resolved profile
65
65
  minus the device/release tier (releaseBuild,
@@ -101,6 +101,10 @@ Flags:
101
101
  running anything
102
102
 
103
103
  Profiles:
104
+ smoke the smallest end-to-end lane: every pure-Node gate through the real
105
+ runner, receipt and journal — no Gradle, no device. Seconds. Proves the
106
+ FRAMEWORK returns, both ways; never the change (its receipt is refused
107
+ as done-evidence). Driven by scripts/framework-check.mjs.
104
108
  scaffold spec coverage + build + unit tests (what \`create-cmp --verify\`
105
109
  proves at stamp time)
106
110
  local everything; device-dependent steps SKIP when no device is
@@ -321,7 +325,7 @@ const { stepsForProfile, DEVICE_STEPS, FAST_EXCLUDED_NAMES, STEP_FN_BY_NAME } =
321
325
 
322
326
 
323
327
  if (!stepsForProfile[profile]) {
324
- console.error(`Unknown profile "${profile}" — use scaffold | local | ci | nightly | release.`);
328
+ console.error(`Unknown profile "${profile}" — use smoke | scaffold | local | ci | nightly | release.`);
325
329
  process.exit(2);
326
330
  }
327
331
 
@@ -446,7 +450,10 @@ const strengthLabel = onDeviceSteps.length ? `on-device: ${onDeviceSteps.join("+
446
450
  // fine print; the rung is added alongside, never in place of it. null on FAIL —
447
451
  // a failed lane has no rung. null on a --fast run too: the inner loop is a
448
452
  // signal, never evidence, so a fast receipt derives NO rung at all.
449
- const level = evidenceLevel(steps, profile, { mode });
453
+ // The ladder is the PACK's: a pack that declares none earns no rung (a
454
+ // backend graded by Compose step names was L0 by construction — wrong, not
455
+ // conservative).
456
+ const level = evidenceLevel(steps, profile, { mode, ladder: pack.evidenceLadder ?? null });
450
457
 
451
458
  // Artifacts: hash whatever the run left under qa-artifacts/ (never committed).
452
459
  const artifacts = [];
@@ -490,6 +497,15 @@ function harnessForReceipt() {
490
497
  }
491
498
 
492
499
  const inputs = computeInputsHash(ROOT);
500
+ // What the surface does NOT cover, at the top level. A surface is an allowlist,
501
+ // and a new top-level directory is simply unmatched: no error, silently
502
+ // unattested (payment-blueprint's finding, 2026-09-03). This is a REPORT on the
503
+ // receipt, never a gate — the Compose default deliberately leaves docs/, the
504
+ // README and the wrapper out — so a reader can see the gap and decide.
505
+ const undeclared = undeclaredTopLevel(ROOT);
506
+ if (undeclared.length) {
507
+ console.log(` ⓘ inputs: ${undeclared.length} top-level entr${undeclared.length === 1 ? "y is" : "ies are"} outside the verified surface (unattested): ${undeclared.join(", ")}`);
508
+ }
493
509
 
494
510
  // The receipt. Deterministic key order; ONE volatile timestamp field.
495
511
  // commit.sha is the parent HEAD at run time (you cannot know the sha of the
@@ -499,7 +515,7 @@ const inputs = computeInputsHash(ROOT);
499
515
  // more than its stage allows. scaffold → scaffold, local → change (per commit),
500
516
  // ci → merge, nightly → nightly (proves the harness, never a change), release →
501
517
  // release. Receipts predating this field are read as their profile's stage.
502
- const STAGE_OF_PROFILE = { scaffold: "scaffold", local: "change", ci: "merge", nightly: "nightly", release: "release" };
518
+ const STAGE_OF_PROFILE = { smoke: "smoke", scaffold: "scaffold", local: "change", ci: "merge", nightly: "nightly", release: "release" };
503
519
  const receipt = {
504
520
  schema: "cmp-evidence/1",
505
521
  profile,
@@ -516,6 +532,10 @@ const receipt = {
516
532
  inputs: {
517
533
  hash: inputs.hash,
518
534
  fileCount: inputs.fileCount,
535
+ // Top-level entries the surface leaves unattested (see above). Absent when
536
+ // there are none, so a receipt whose surface covers everything keeps its
537
+ // exact prior shape.
538
+ ...(undeclared.length ? { undeclared } : {}),
519
539
  },
520
540
  steps,
521
541
  // WHICH LANE issued this verdict. A receipt that cannot name its own harness