create-cmp-cli 0.20.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-cmp-cli",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Create production mobile apps (Android + iOS, one Kotlin codebase) with AI — the delivery harness for Compose Multiplatform, the current generation of cross-platform (Google-backed KMP, iOS stable since May 2025). A deterministic, non-interactive generator that scaffolds a green-building app in minutes, then holds AI-driven changes to a machine-enforced verify lane with a committed evidence receipt. Every app carries a device-free UI preview loop (real screens rendered headlessly on save; changed-screen attribution and compile-error surfacing for coding agents, a live gallery for humans) plus agent-first docs (CLAUDE.md + AGENTS.md). Installs the `create-cmp` command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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}/`));
@@ -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
  }
@@ -232,6 +232,33 @@ function resolveSurfaceFiles(root, VERIFIED_SURFACE) {
232
232
  return collected.filter((relPath) => !isExcluded(relPath));
233
233
  }
234
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
+
235
262
  /**
236
263
  * Compute the sha256 hash of the verified surface for the project rooted at `root`.
237
264
  * Deterministic: same tree (same file paths + same file bytes) → same hash.
@@ -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(
@@ -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
@@ -1253,6 +1255,28 @@ stepsForProfile.release = [...stepsForProfile.ci, stepAuditCadence, stepReleaseS
1253
1255
  // what differs is what is forced, and what the receipt is allowed to mean.
1254
1256
  stepsForProfile.nightly = [...stepsForProfile.ci];
1255
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
+
1256
1280
  const FAST_EXCLUDED_NAMES = [...DEVICE_STEPS, "releaseBuild"];
1257
1281
  const STEP_FN_BY_NAME = {
1258
1282
  e2eSmoke: stepE2eSmoke,
@@ -1275,6 +1299,10 @@ for (const name of FAST_EXCLUDED_NAMES) {
1275
1299
  FAST_EXCLUDED_NAMES,
1276
1300
  STEP_FN_BY_NAME,
1277
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,
1278
1306
  // The device lease is held to the very end of the run (see the scope
1279
1307
  // decision above); the spine releases it in the runner's finally.
1280
1308
  releaseLease: () => {
@@ -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";
@@ -450,7 +450,10 @@ const strengthLabel = onDeviceSteps.length ? `on-device: ${onDeviceSteps.join("+
450
450
  // fine print; the rung is added alongside, never in place of it. null on FAIL —
451
451
  // a failed lane has no rung. null on a --fast run too: the inner loop is a
452
452
  // signal, never evidence, so a fast receipt derives NO rung at all.
453
- 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 });
454
457
 
455
458
  // Artifacts: hash whatever the run left under qa-artifacts/ (never committed).
456
459
  const artifacts = [];
@@ -494,6 +497,15 @@ function harnessForReceipt() {
494
497
  }
495
498
 
496
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
+ }
497
509
 
498
510
  // The receipt. Deterministic key order; ONE volatile timestamp field.
499
511
  // commit.sha is the parent HEAD at run time (you cannot know the sha of the
@@ -520,6 +532,10 @@ const receipt = {
520
532
  inputs: {
521
533
  hash: inputs.hash,
522
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 } : {}),
523
539
  },
524
540
  steps,
525
541
  // WHICH LANE issued this verdict. A receipt that cannot name its own harness
@@ -232,6 +232,33 @@ function resolveSurfaceFiles(root, VERIFIED_SURFACE) {
232
232
  return collected.filter((relPath) => !isExcluded(relPath));
233
233
  }
234
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
+
235
262
  /**
236
263
  * Compute the sha256 hash of the verified surface for the project rooted at `root`.
237
264
  * Deterministic: same tree (same file paths + same file bytes) → same hash.
@@ -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}/`));
@@ -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
  }
@@ -232,6 +232,33 @@ function resolveSurfaceFiles(root, VERIFIED_SURFACE) {
232
232
  return collected.filter((relPath) => !isExcluded(relPath));
233
233
  }
234
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
+
235
262
  /**
236
263
  * Compute the sha256 hash of the verified surface for the project rooted at `root`.
237
264
  * Deterministic: same tree (same file paths + same file bytes) → same hash.
@@ -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(
@@ -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
@@ -1253,6 +1255,28 @@ stepsForProfile.release = [...stepsForProfile.ci, stepAuditCadence, stepReleaseS
1253
1255
  // what differs is what is forced, and what the receipt is allowed to mean.
1254
1256
  stepsForProfile.nightly = [...stepsForProfile.ci];
1255
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
+
1256
1280
  const FAST_EXCLUDED_NAMES = [...DEVICE_STEPS, "releaseBuild"];
1257
1281
  const STEP_FN_BY_NAME = {
1258
1282
  e2eSmoke: stepE2eSmoke,
@@ -1275,6 +1299,10 @@ for (const name of FAST_EXCLUDED_NAMES) {
1275
1299
  FAST_EXCLUDED_NAMES,
1276
1300
  STEP_FN_BY_NAME,
1277
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,
1278
1306
  // The device lease is held to the very end of the run (see the scope
1279
1307
  // decision above); the spine releases it in the runner's finally.
1280
1308
  releaseLease: () => {
@@ -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";
@@ -450,7 +450,10 @@ const strengthLabel = onDeviceSteps.length ? `on-device: ${onDeviceSteps.join("+
450
450
  // fine print; the rung is added alongside, never in place of it. null on FAIL —
451
451
  // a failed lane has no rung. null on a --fast run too: the inner loop is a
452
452
  // signal, never evidence, so a fast receipt derives NO rung at all.
453
- 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 });
454
457
 
455
458
  // Artifacts: hash whatever the run left under qa-artifacts/ (never committed).
456
459
  const artifacts = [];
@@ -494,6 +497,15 @@ function harnessForReceipt() {
494
497
  }
495
498
 
496
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
+ }
497
509
 
498
510
  // The receipt. Deterministic key order; ONE volatile timestamp field.
499
511
  // commit.sha is the parent HEAD at run time (you cannot know the sha of the
@@ -520,6 +532,10 @@ const receipt = {
520
532
  inputs: {
521
533
  hash: inputs.hash,
522
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 } : {}),
523
539
  },
524
540
  steps,
525
541
  // WHICH LANE issued this verdict. A receipt that cannot name its own harness