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 +1 -1
- package/packages/harness/src/lib/affected-tests.mjs +9 -1
- package/packages/harness/src/lib/evidence-badge.mjs +11 -0
- package/packages/harness/src/lib/evidence-level.mjs +33 -2
- package/packages/harness/src/lib/inputs-hash.mjs +27 -0
- package/packages/harness/src/lib/lane-runner.mjs +7 -0
- package/packages/harness/src/lib/steps-cmp.mjs +28 -0
- package/packages/harness/src/verify.mjs +18 -2
- package/packages/receipts/src/inputs-hash.mjs +27 -0
- package/template/qa/evidence/schema.json +6 -1
- package/template/qa/lib/affected-tests.mjs +9 -1
- package/template/qa/lib/evidence-badge.mjs +11 -0
- package/template/qa/lib/evidence-level.mjs +33 -2
- package/template/qa/lib/inputs-hash.mjs +27 -0
- package/template/qa/lib/lane-runner.mjs +7 -0
- package/template/qa/lib/steps-cmp.mjs +28 -0
- package/template/qa/verify.mjs +18 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-cmp-cli",
|
|
3
|
-
"version": "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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: () => {
|
package/template/qa/verify.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|