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
@@ -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
@@ -13,4 +13,5 @@ export {
13
13
  checkExecutionPlausibility,
14
14
  listSkippedSteps,
15
15
  validateReceiptForTree,
16
+ checkLaneVouching,
16
17
  } from "./receipt-validate.mjs";
@@ -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) {
@@ -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
 
@@ -4,6 +4,13 @@
4
4
  Generated by [create-cmp](https://github.com/kvdm-co-pilot/create-cmp) with a verification
5
5
  harness. Every AI session in this repo works under this contract.
6
6
 
7
+ **Principles** (the full form, with the episode behind each, is create-cmp's
8
+ `docs/PRINCIPLES.md`): derived, never claimed · prove the instrument before you read it · the
9
+ layer you changed cannot certify itself · proof costs what the change costs and never runs
10
+ silent · never wait on nothing · a signature binds content, a decision is closed · one record,
11
+ read first. These govern every rule below; when a rule below and a principle disagree, the
12
+ principle wins and the rule is the bug.
13
+
7
14
  ## Definition of done
8
15
 
9
16
  Done means `node qa/verify.mjs` reports PASS and the receipt it writes
@@ -518,6 +525,7 @@ conventions) · [`CONTRIBUTING.md`](./CONTRIBUTING.md) (workflow, Conventional C
518
525
  | `./gradlew :composeApp:assembleRelease` | Android release build — R8 + `lintVital`, the variant the lane's `releaseBuild` step proves. Produces an **unsigned** APK; signing needs a keystore, which is yours to create and keep out of the repo. |
519
526
  | `./gradlew :composeApp:hotRunDesktop --auto` | Desktop dev-client with hot reload |
520
527
  | `./gradlew :composeApp:connectedDebugAndroidTest` | Instrumented behavior tests on the attached device (the lane's `androidChecks` step) |
528
+ | `node qa/verify.mjs --profile smoke` | The smallest end-to-end lane: every pure-Node gate, no Gradle, no device — seconds. Proves the framework *returns*, never the change (its receipt is refused as done-evidence). Run it first in any repo whose harness is new or freshly upgraded |
521
529
  | `node qa/verify.mjs --profile nightly` | Scheduled stage: everything `ci` proves with the determinism probe forced on. Proves the harness, never a change — its receipt (`stage: "nightly"`) is refused as done-evidence, exactly like `--fast`. Schedule it; never wait on it |
522
530
  | `node qa/verify.mjs --profile release` | Ship-time lane: everything `ci` proves plus the audit-cadence report (`auditCadence` — which androidMain subsystems changed since their last recorded `cmp-audit`; a nudge, never a gate) and the release-APK Maestro smoke (`releaseSmoke`) |
523
531
  | `node qa/verify.mjs --determinism` | Timezone determinism probe, alone: runs the JVM test tier twice under UTC-12 and UTC+14 and FAILs naming any test whose outcome differs — the dynamic net behind ARCH-13's static one. Opt-in inside a lane via `--profile ci --determinism`; never with `--fast`; writes no receipt on its own |
@@ -119,7 +119,6 @@ class ArchitectureConformanceTest {
119
119
  )
120
120
  }
121
121
 
122
- // SPEC: ARCH-04
123
122
  // Component-derived tags (component-system-deep-dive.md §6.4) count as tag provenance:
124
123
  // a screen built entirely from registry components (ScreenColumn/AppHeader/
125
124
  // ContentStateContainer/…) is automation-reachable through the tags THOSE components
@@ -138,6 +137,7 @@ class ArchitectureConformanceTest {
138
137
  return text.contains("testTag") || (importsComponents && screenTagArgument.containsMatchIn(text))
139
138
  }
140
139
 
140
+ // SPEC: ARCH-04
141
141
  @Test
142
142
  fun `ARCH-04 every feature composable file is automation-reachable - literal testTag or screenTag provenance`() {
143
143
  // Scoped by CONTENT (contains @Composable), not by *Screen.kt filename: real apps
@@ -134,7 +134,13 @@ function refuseIfUnresolvable() {
134
134
 
135
135
  if (args.includes("--accept-defaults")) {
136
136
  refuseIfUnresolvable();
137
- const { approved, skipped } = approveAllDefaults(ROOT);
137
+ const expressAsIdx = args.indexOf("--as");
138
+ const expressSigner = expressAsIdx >= 0 ? args[expressAsIdx + 1] : undefined;
139
+ if (!expressSigner || expressSigner.startsWith("--")) {
140
+ console.error('--accept-defaults needs a signer: node qa/approve.mjs --accept-defaults --as "Your Name <you@example.com>"');
141
+ process.exit(1);
142
+ }
143
+ const { approved, skipped } = approveAllDefaults(ROOT, expressSigner);
138
144
  for (const id of approved) {
139
145
  console.log(`✓ approved ${id} [defaults-accepted]`);
140
146
  }
@@ -246,7 +252,22 @@ if (args.length === 0) {
246
252
  refuseIfUnresolvable();
247
253
 
248
254
  const artifactId = args[0];
249
- const result = approveArtifact(ROOT, artifactId, { via: "cli" });
255
+ // The signer is REQUIRED, not optional: see approveArtifact's refusal. Parsed
256
+ // here rather than defaulted from git config on purpose — `git config user.name`
257
+ // is whatever the machine says, and an agent running on a developer's laptop
258
+ // would sign with that developer's name. An approval must be typed by whoever
259
+ // is answerable for it.
260
+ const asIndex = args.indexOf("--as");
261
+ const approvedBy = asIndex >= 0 ? args[asIndex + 1] : undefined;
262
+ if (!approvedBy || approvedBy.startsWith("--")) {
263
+ console.error(
264
+ 'approve needs a signer: node qa/approve.mjs <artifact> --as "Your Name <you@example.com>"\n' +
265
+ "An approval is a signature on a hash; a row that records no signer cannot tell a human's\n" +
266
+ "sign-off from an agent's.",
267
+ );
268
+ process.exit(1);
269
+ }
270
+ const result = approveArtifact(ROOT, artifactId, { via: "cli", approvedBy });
250
271
  if (!result.ok) {
251
272
  console.error(`error: ${result.reason}`);
252
273
  process.exit(1);
@@ -26,7 +26,12 @@
26
26
  "required": ["hash", "fileCount"],
27
27
  "properties": {
28
28
  "hash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
29
- "fileCount": { "type": "number" }
29
+ "fileCount": { "type": "number" },
30
+ "undeclared": {
31
+ "type": "array",
32
+ "items": { "type": "string" },
33
+ "description": "Top-level entries git would commit that no verified-surface entry covers — unattested by this receipt. A report, never a gate; absent when the surface covers everything."
34
+ }
30
35
  }
31
36
  },
32
37
  "steps": {
@@ -35,7 +35,15 @@ import path from "node:path";
35
35
  * principle as inputs-hash.mjs's EXCLUDED_PREFIXES: lane outputs are not
36
36
  * verdict inputs).
37
37
  */
38
- export const LANE_OUTPUT_PREFIXES = ["qa/evidence", "qa-artifacts"];
38
+ // qa/flight-recorder.jsonl is a lane output in the strictest sense: the lane
39
+ // appends one line to it AFTER the receipt is written, on every run. It is
40
+ // committed (the journal is the cost record), so after the first run it sits
41
+ // in the changed set as a modified tracked file under qa/ — and qa/** is the
42
+ // "harness itself" escape hatch. Uncounted here, every --fast run after the
43
+ // first fell open to the full suite, visible only in one parenthetical.
44
+ // Found by payment-blueprint's spine adoption (2026-09-03), where the same
45
+ // line also landed in their locked region.
46
+ export const LANE_OUTPUT_PREFIXES = ["qa/evidence", "qa-artifacts", "qa/flight-recorder.jsonl"];
39
47
 
40
48
  function isLaneOutput(p) {
41
49
  return LANE_OUTPUT_PREFIXES.some((prefix) => p === prefix || p.startsWith(`${prefix}/`));
@@ -781,6 +781,7 @@ export function resolveArtifactStatus(root, artifact, storedRecord) {
781
781
  hash: recomputed.hash,
782
782
  storedHash: storedRecord.hash ?? null,
783
783
  approvedAt: storedRecord.approvedAt ?? null,
784
+ approvedBy: storedRecord.approvedBy ?? null,
784
785
  fileCount: recomputed.fileCount,
785
786
  missing: recomputed.missing,
786
787
  resolvable,
@@ -801,6 +802,7 @@ export function resolveArtifactStatus(root, artifact, storedRecord) {
801
802
  hash: recomputed.hash,
802
803
  storedHash: null,
803
804
  approvedAt: null,
805
+ approvedBy: null,
804
806
  fileCount: recomputed.fileCount,
805
807
  missing: recomputed.missing,
806
808
  resolvable,
@@ -834,6 +836,10 @@ export function resolveArtifactStatus(root, artifact, storedRecord) {
834
836
  hash: recomputed.hash,
835
837
  storedHash: storedRecord.hash,
836
838
  approvedAt: storedRecord.approvedAt,
839
+ // WHO signed. Null on rows written before signers were recorded; the gate
840
+ // treats a signed-by-nobody approval as FAIL, because that row cannot tell
841
+ // a human's sign-off from an agent's.
842
+ approvedBy: storedRecord.approvedBy ?? null,
837
843
  fileCount: recomputed.fileCount,
838
844
  missing: recomputed.missing,
839
845
  resolvable,
@@ -920,7 +926,23 @@ export function approveArtifact(root, artifactId, options = {}) {
920
926
  const state = loadApprovals(root);
921
927
  const others = state.artifacts.filter((a) => a.artifact !== artifactId);
922
928
  const approvedAt = new Date().toISOString();
923
- const record = { artifact: artifactId, status: "approved", hash: resolved.hash, approvedAt };
929
+ if (!options.approvedBy || !String(options.approvedBy).trim()) {
930
+ return {
931
+ ok: false,
932
+ reason:
933
+ `cannot approve "${artifactId}" — no signer was given. An approval is a person's ` +
934
+ "signature on a hash; a row that records no signer cannot distinguish a human's sign-off " +
935
+ "from an agent's, and an agent that invalidates an approval can clear it by re-approving. " +
936
+ "Pass the signer: `node qa/approve.mjs <artifact> --as \"Name <email>\"`.",
937
+ };
938
+ }
939
+ const record = {
940
+ artifact: artifactId,
941
+ status: "approved",
942
+ hash: resolved.hash,
943
+ approvedAt,
944
+ approvedBy: String(options.approvedBy).trim(),
945
+ };
924
946
  if (options.mode) record.mode = options.mode;
925
947
  if (options.via) record.via = options.via;
926
948
  others.push(record);
@@ -929,10 +951,11 @@ export function approveArtifact(root, artifactId, options = {}) {
929
951
  verb: "approve",
930
952
  artifact: artifactId,
931
953
  hash: resolved.hash,
954
+ approvedBy: String(options.approvedBy).trim(),
932
955
  ...(options.via ? { via: options.via } : {}),
933
956
  ...(options.mode ? { mode: options.mode } : {}),
934
957
  });
935
- return { ok: true, artifact: artifactId, hash: resolved.hash, approvedAt, ...(options.mode ? { mode: options.mode } : {}) };
958
+ return { ok: true, artifact: artifactId, hash: resolved.hash, approvedAt, approvedBy: record.approvedBy, ...(options.mode ? { mode: options.mode } : {}) };
936
959
  }
937
960
 
938
961
  /**
@@ -945,7 +968,7 @@ export function approveArtifact(root, artifactId, options = {}) {
945
968
  * @param {string} root
946
969
  * @returns {{ok: true, approved: string[], skipped: Array<{id: string, reason: string}>}}
947
970
  */
948
- export function approveAllDefaults(root) {
971
+ export function approveAllDefaults(root, approvedBy) {
949
972
  const registry = listGovernedArtifacts(root);
950
973
  const state = loadApprovals(root);
951
974
  const byId = new Map(state.artifacts.map((a) => [a.artifact, a]));
@@ -954,7 +977,7 @@ export function approveAllDefaults(root) {
954
977
  for (const artifact of registry) {
955
978
  const live = resolveArtifactStatus(root, artifact, byId.get(artifact.id));
956
979
  if (live.status === "approved") continue; // already settled — never overwritten by the express lane
957
- const result = approveArtifact(root, artifact.id, { mode: "defaults-accepted" });
980
+ const result = approveArtifact(root, artifact.id, { mode: "defaults-accepted", approvedBy });
958
981
  if (result.ok) approved.push(artifact.id);
959
982
  else skipped.push({ id: artifact.id, reason: result.reason });
960
983
  }
@@ -1143,6 +1166,21 @@ export function evaluateApprovalsGate(root) {
1143
1166
  const statuses = getApprovalStatuses(root);
1144
1167
  const mismatched = statuses.filter((s) => s.status === "changed-since-approval");
1145
1168
  const pending = statuses.filter((s) => s.status === "unreviewed" || s.status === "reopened");
1169
+ // An "approved" row with no signer attests nothing about WHO signed, which is
1170
+ // the one fact an approval exists to record. It cannot distinguish a human's
1171
+ // sign-off from an agent's, and an agent that invalidates an approval can
1172
+ // clear it by re-approving — the gate then guards only against accident, not
1173
+ // against the population it is pointed at. Rows written before signers were
1174
+ // recorded land here; the fix is one re-approval each, and the message says so.
1175
+ const unsigned = statuses.filter((s) => s.status === "approved" && !s.approvedBy);
1176
+
1177
+ if (unsigned.length > 0) {
1178
+ const lines = ["Approval recorded without a signer — re-approve to say who signed:"];
1179
+ for (const s of unsigned) {
1180
+ lines.push(` [${s.id}] ${s.label} — approved ${shortHash(s.storedHash)} by nobody. Re-approve: node qa/approve.mjs ${s.id} --as "Your Name <you@example.com>"`);
1181
+ }
1182
+ return { verdict: "FAIL", reason: lines.join("\n"), statuses };
1183
+ }
1146
1184
 
1147
1185
  if (mismatched.length > 0) {
1148
1186
  const lines = ["Approval invalidated — a governed artifact changed after sign-off:"];
@@ -146,6 +146,17 @@ export function updateReadmeBadge(root) {
146
146
  if (receipt && receipt.mode === "fast") {
147
147
  return { changed: false, reason: "fast run — the inner loop bears no evidence, so the badge is left as it stands" };
148
148
  }
149
+ // The same rule for the two other receipts qa/receipt-check.mjs refuses as
150
+ // done-evidence: smoke (Rule 0 — proves the framework, never the change) and
151
+ // nightly (proves the harness and the tree's invariants). Both derive no
152
+ // rung, and a smoke run — scripts/framework-check.mjs runs one on every
153
+ // scaffold — was rewriting a true L1 badge to "rung unrecorded". Found on
154
+ // 2026-09-03 by deriving the affected filter on a fresh app: README.md was
155
+ // the dirty file. Receipts predating `stage` are read by profile.
156
+ const stage = receipt && (typeof receipt.stage === "string" ? receipt.stage : receipt.profile);
157
+ if (stage === "smoke" || stage === "nightly") {
158
+ return { changed: false, reason: `${stage} run — refused as done-evidence, so the badge is left as it stands` };
159
+ }
149
160
 
150
161
  const body = `${renderEvidenceBadge(receipt)}\n`;
151
162
  const next = readme.replace(
@@ -65,6 +65,27 @@ const RELEASE_EXECUTION = "releaseSmoke";
65
65
 
66
66
  const RUNG_NAMES = { L0: "scaffold", L1: "desktop", L2: "device", L3: "release" };
67
67
 
68
+ /**
69
+ * The Compose Multiplatform pack's ladder — the step names above, as one
70
+ * object a pack hands to the spine. THE LADDER IS THE PACK'S, NOT THE SPINE'S
71
+ * (2026-09-03): vendored into a Kotlin backend, these names graded its
72
+ * strongest run — detekt, Konsist, mutation, gitleaks — as L0 "scaffold" and
73
+ * made L1 unreachable by construction. A fixed-amount understatement is not
74
+ * conservative, it is wrong, and receipts are where labels get quoted. So a
75
+ * pack declares its ladder (`evidenceLadder` on createXSteps' return); a pack
76
+ * that declares none earns no rung at all, which is the honest grade for a
77
+ * ladder nobody has calibrated. Every field is a list of step names except
78
+ * `release`, one name. `names` maps rung → label.
79
+ */
80
+ export const CMP_LADDER = Object.freeze({
81
+ scaffoldCore: Object.freeze(SCAFFOLD_CORE),
82
+ l0Required: Object.freeze(L0_REQUIRED),
83
+ l1Required: Object.freeze(L1_REQUIRED),
84
+ deviceExecution: Object.freeze(DEVICE_EXECUTION),
85
+ release: RELEASE_EXECUTION,
86
+ names: Object.freeze(RUNG_NAMES),
87
+ });
88
+
68
89
  /**
69
90
  * Derive the receipt's evidence rung from the lane's step results.
70
91
  *
@@ -81,8 +102,18 @@ const RUNG_NAMES = { L0: "scaffold", L1: "desktop", L2: "device", L3: "release"
81
102
  * was not earned. `satisfiedBy` lists the PASSed steps the rung counts as
82
103
  * its evidence, in lane order.
83
104
  */
84
- export function evidenceLevel(stepResults, profile, { mode } = {}) { // eslint-disable-line no-unused-vars
105
+ export function evidenceLevel(stepResults, profile, { mode, ladder } = {}) { // eslint-disable-line no-unused-vars
85
106
  if (mode === "fast") return null; // the inner loop derives no rung — ever
107
+ // `ladder` absent → the Compose ladder (every caller before packs declared
108
+ // one). `ladder: null` → the pack declares none: no rung, by decision.
109
+ if (ladder === null) return null;
110
+ const L = ladder ?? CMP_LADDER;
111
+ const SCAFFOLD_CORE = L.scaffoldCore ?? [];
112
+ const L0_REQUIRED = L.l0Required ?? [];
113
+ const L1_REQUIRED = L.l1Required ?? [];
114
+ const DEVICE_EXECUTION = L.deviceExecution ?? [];
115
+ const RELEASE_EXECUTION = L.release ?? null;
116
+ const RUNG_NAMES = L.names ?? CMP_LADDER.names;
86
117
  const steps = Array.isArray(stepResults) ? stepResults.filter((s) => s && typeof s.name === "string") : [];
87
118
  // A failed lane has no rung — and a lane with a step that could not run
88
119
  // (ERROR) has none either: a rung is evidence, and "could not check" is not.
@@ -108,7 +139,7 @@ export function evidenceLevel(stepResults, profile, { mode } = {}) { // eslint-d
108
139
 
109
140
  // Only a PASSed releaseSmoke lifts to L3 — a SKIP (unsigned keystore,
110
141
  // no device) never does.
111
- if (passed.has(RELEASE_EXECUTION)) {
142
+ if (RELEASE_EXECUTION && passed.has(RELEASE_EXECUTION)) {
112
143
  rung = "L3";
113
144
  counted.add(RELEASE_EXECUTION);
114
145
  }