create-cmp-cli 0.20.0 → 0.22.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 (41) hide show
  1. package/package.json +1 -1
  2. package/packages/harness/package.json +1 -1
  3. package/packages/harness/src/lib/affected-tests.mjs +9 -1
  4. package/packages/harness/src/lib/device-provider.mjs +153 -0
  5. package/packages/harness/src/lib/e2e-coverage.mjs +60 -0
  6. package/packages/harness/src/lib/evidence-badge.mjs +11 -0
  7. package/packages/harness/src/lib/evidence-level.mjs +34 -2
  8. package/packages/harness/src/lib/feature-brief.mjs +16 -3
  9. package/packages/harness/src/lib/harness-lock.mjs +6 -1
  10. package/packages/harness/src/lib/inputs-hash.mjs +27 -0
  11. package/packages/harness/src/lib/lane-runner.mjs +7 -0
  12. package/packages/harness/src/lib/spec-coverage.mjs +27 -3
  13. package/packages/harness/src/lib/step-outcomes.mjs +104 -0
  14. package/packages/harness/src/lib/steps-cmp.mjs +133 -39
  15. package/packages/harness/src/receipt-check.mjs +25 -0
  16. package/packages/harness/src/scaffold-feature.mjs +55 -0
  17. package/packages/harness/src/verify.mjs +18 -2
  18. package/packages/receipts/src/inputs-hash.mjs +27 -0
  19. package/src/commands/upgrade.mjs +31 -2
  20. package/src/lib/tabs.mjs +18 -3
  21. package/src/lib/upgrade.mjs +50 -0
  22. package/template/.github/workflows/verify.yml +9 -0
  23. package/template/gitignore +9 -0
  24. package/template/qa/e2e/README.md +23 -4
  25. package/template/qa/e2e/smoke.yaml +9 -1
  26. package/template/qa/evidence/schema.json +6 -1
  27. package/template/qa/lib/affected-tests.mjs +9 -1
  28. package/template/qa/lib/device-provider.mjs +153 -0
  29. package/template/qa/lib/e2e-coverage.mjs +60 -0
  30. package/template/qa/lib/evidence-badge.mjs +11 -0
  31. package/template/qa/lib/evidence-level.mjs +34 -2
  32. package/template/qa/lib/feature-brief.mjs +16 -3
  33. package/template/qa/lib/harness-lock.mjs +6 -1
  34. package/template/qa/lib/inputs-hash.mjs +27 -0
  35. package/template/qa/lib/lane-runner.mjs +7 -0
  36. package/template/qa/lib/spec-coverage.mjs +27 -3
  37. package/template/qa/lib/step-outcomes.mjs +104 -0
  38. package/template/qa/lib/steps-cmp.mjs +133 -39
  39. package/template/qa/receipt-check.mjs +25 -0
  40. package/template/qa/scaffold-feature.mjs +55 -0
  41. package/template/qa/verify.mjs +18 -2
@@ -6,6 +6,8 @@
6
6
 
7
7
  import { parseVersions, updateTomlValues, upsertProperty, parseProperties } from "./toml.mjs";
8
8
 
9
+ import { execFileSync } from "node:child_process";
10
+
9
11
  export const BACKUP_SUFFIX = ".bak-upgrade";
10
12
 
11
13
  /** Marker comment the golden template ships in libs.versions.toml. */
@@ -201,3 +203,51 @@ export function planUpgrade({ tomlContent, gradlePropertiesContent, wrapperPrope
201
203
  fromOurTemplate: looksLikeOurTemplate(tomlContent),
202
204
  };
203
205
  }
206
+
207
+ // ── Two upgrade courtesies the showcase asked for (2026-09-03) ──────────────
208
+ /**
209
+ * Lines of YOUR file a conflict sidecar does not carry — what "take the
210
+ * sidecar" would silently drop. Third consecutive upgrade on the showcase:
211
+ * the .gitignore sidecar lacked the four signing-key ignores, and an agent
212
+ * resolving by taking the sidecar would leave the keystore one `git add -A`
213
+ * from a public repo. Blank and comment lines are not content.
214
+ * @param {string} yours
215
+ * @param {string} sidecar
216
+ * @returns {string[]}
217
+ */
218
+ export function sidecarDroppedLines(yours, sidecar) {
219
+ const content = (text) =>
220
+ String(text ?? "")
221
+ .split("\n")
222
+ .map((l) => l.trim())
223
+ .filter((l) => l && !l.startsWith("#"));
224
+ const have = new Set(content(sidecar));
225
+ return [...new Set(content(yours).filter((l) => !have.has(l)))];
226
+ }
227
+
228
+ /**
229
+ * Backups (*BACKUP_SUFFIX) left by EARLIER upgrades — gitignored, so git sees
230
+ * them as ignored-untracked. One set per upgrade accumulated and nothing
231
+ * cleaned them (the showcase carried 0.19.0's and 0.20.0's). Returns root-
232
+ * relative paths; [] when git is unavailable (then nothing is touched).
233
+ * @param {string} projectDir
234
+ * @param {{runGit?: (args: string[], cwd: string) => string|null}} [deps]
235
+ * @returns {string[]}
236
+ */
237
+ export function staleBackupPaths(projectDir, { runGit } = {}) {
238
+ const git =
239
+ runGit ??
240
+ ((args, cwd) => {
241
+ try {
242
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
243
+ } catch {
244
+ return null;
245
+ }
246
+ });
247
+ const out = git(["ls-files", "-z", "--others", "--ignored", "--exclude-standard"], projectDir);
248
+ if (out === null) return [];
249
+ return out
250
+ .split("\0")
251
+ .filter((p) => p.endsWith(BACKUP_SUFFIX))
252
+ .sort();
253
+ }
@@ -64,7 +64,16 @@ jobs:
64
64
  # verdict and an evidence receipt. If the build step fails after a
65
65
  # dependency bump, remember: Kotlin / KSP / Compose / Room / AGP move as
66
66
  # ONE set (see the comments in gradle/libs.versions.toml).
67
+ # CMP_DEVICE=none is the ONE explicit opt-out of the device tier: this
68
+ # hosted runner has no emulator, so e2eSmoke/androidChecks SKIP with that
69
+ # reason on the merge-stage receipt (visible, never silent). The lane boots
70
+ # a headless emulator itself everywhere else — locally that is the
71
+ # change-stage gate, and qa/receipt-check.mjs refuses a receipt whose
72
+ # device tier was opted out. To prove L2 here too, drop the variable and
73
+ # boot an emulator first (e.g. reactivecircus/android-emulator-runner).
67
74
  - name: Verify lane
75
+ env:
76
+ CMP_DEVICE: none
68
77
  run: node qa/verify.mjs --profile ci
69
78
 
70
79
  # The receipt this run produced, kept as a build artifact. The receipt
@@ -44,3 +44,12 @@ qa/.plan.json
44
44
  # The closed-chain trail (drive-narration N5): local because it carries raw
45
45
  # human prompts — the committed journal for lane runs stays qa/flight-recorder.jsonl.
46
46
  qa/.plan-history.jsonl
47
+
48
+ # Android signing. The keystore IS the app's identity on Android — it cannot be reissued —
49
+ # and keystore.properties holds its passwords. Never committed, never in a sidecar diff.
50
+ # (create-cmp-showcase, 2026-09-03: three consecutive upgrades' .gitignore sidecars lacked
51
+ # these four lines, leaving the key one `git add -A` from a public repo.)
52
+ keystore.properties
53
+ keystore/
54
+ *.jks
55
+ *.keystore
@@ -13,14 +13,33 @@ curl -fsSL "https://get.maestro.mobile.dev" | bash # Apache-2.0, free CLI
13
13
  ## Run
14
14
 
15
15
  ```bash
16
- # Android: emulator/device attached, debug build installed
16
+ # The lane runs EVERY flow in this directory (e2eSmoke), on the DEBUG build, and
17
+ # boots a headless emulator itself when nothing is attached:
18
+ node qa/verify.mjs
19
+
20
+ # By hand, one flow, against whatever is installed:
17
21
  ./gradlew :composeApp:installDebug
18
22
  maestro test qa/e2e/smoke.yaml
19
-
20
- # The verify lane runs this automatically when maestro + a device are present:
21
- node qa/verify.mjs
22
23
  ```
23
24
 
25
+ - **Every top-level `*.yaml` here runs**, in one Maestro session; the receipt's e2eSmoke row
26
+ lists each flow's result (`details.results`). A flow in a subfolder does not run and does
27
+ not count as coverage — the executed list and the coverage scan read the same list.
28
+ - **The device**: an attached device is used as-is. With none attached the lane boots an
29
+ emulator headless — `CMP_AVD`, else the doctor's `cmp_pixel`, else the only AVD — waits
30
+ (bounded, 4 min) and shuts it down when the lane exits (`CMP_KEEP_DEVICE=1` keeps it up).
31
+ A device that cannot be provisioned is an ERROR row and the lane FAILs. `CMP_DEVICE=none`
32
+ is the one explicit opt-out; a receipt carrying it is refused as done-evidence.
33
+ - **Per feature**: `qa/scaffold-feature.mjs` stamps `qa/e2e/<feature>.yaml` — a passing
34
+ skeleton (launch + shell) naming the screen id and the clauses to prove. It cites nothing
35
+ until you make it the journey. Mark clauses only a device can observe `[tier: e2e]` in
36
+ the spec; specCoverage then fails by name until a flow cites them.
37
+ - **The gate**: `e2eCoverage` (pure Node, every profile). A feature with a screen and a spec
38
+ must have at least one live clause cited from a flow here — so a stamped skeleton FAILs the
39
+ lane by name until it is the journey. A screen with no spec is a placeholder (reported);
40
+ a screen declared `{ "unrouted": true }` in its brief is exempt. A `screens: true` brief is
41
+ likewise not done until one of its clauses is proven by a flow.
42
+
24
43
  ## Conventions
25
44
 
26
45
  - **Selectors by testTag** (`id:` — TestTagAutomation surfaces tags as resource-ids on
@@ -1,4 +1,4 @@
1
- # E2E smoke — Maestro flow. SPEC: SHELL-01, SHELL-02.
1
+ # E2E smoke — Maestro flow. SPEC: SHELL-01, SHELL-02, HOME-02.
2
2
  #
3
3
  # Proves the real app boots on a device/emulator and the bottom-nav shell works.
4
4
  # Selectors go by testTag (surfaced as resource-ids on Android via TestTagAutomation),
@@ -30,6 +30,14 @@ appId: __PACKAGE__
30
30
  - assertVisible:
31
31
  id: "app_bottom_nav"
32
32
 
33
+ # SPEC: HOME-02 — when loading completes, the items are listed: the first item's row
34
+ # is on screen (the exemplar's device journey; the lane's e2eCoverage gate asks every
35
+ # feature with a screen and a spec for at least one clause proven by a flow).
36
+ - extendedWaitUntil:
37
+ visible:
38
+ id: "home_item_1"
39
+ timeout: 30000
40
+
33
41
  # SPEC: SHELL-02 — switching tabs keeps the shell
34
42
  - tapOn:
35
43
  id: "nav_profile"
@@ -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}/`));
@@ -0,0 +1,153 @@
1
+ // device-provider.mjs — the lane provisions its own device.
2
+ //
3
+ // Until 2026-09-03 every device-tier step (tokenDrift, e2eSmoke, androidChecks,
4
+ // releaseSmoke) SKIPped with "no Android device/emulator attached" and the
5
+ // receipt earned L1 with three visible gaps. Visible, and universally ignored:
6
+ // the showcase's latest receipt had e2eSmoke SKIP, four hand-written flows had
7
+ // never been executed by any gate, and 0.21.0 was published on a fleet check
8
+ // with the whole tier SKIPped. Karel: "they can be run headlessly and should
9
+ // not take too long." Measured: emulator boot 36 s, e2eSmoke 109 s,
10
+ // androidChecks 64 s. So the full lane boots a headless emulator itself when
11
+ // nothing is attached, drives it, and shuts it down when the lane exits.
12
+ //
13
+ // Rules, each bounded (PRINCIPLES #5 — never wait on nothing):
14
+ // - an attached device (adb `device` state) is used as-is, never rebooted;
15
+ // - CMP_DEVICE=none is the ONE explicit opt-out (CI runners without KVM);
16
+ // the step rows say so, and qa/receipt-check.mjs refuses such a receipt
17
+ // as done-evidence at the change stage — an opt-out is visible, never done;
18
+ // - the AVD is CMP_AVD, else the doctor's `cmp_pixel`, else the only AVD;
19
+ // several AVDs and no way to choose is a refusal naming them (per-app AVD
20
+ // isolation: a lane must never guess another app's emulator);
21
+ // - boot is bounded (BOOT_BOUND_MS); past it the emulator is killed and the
22
+ // step rows read ERROR "could not provision a device", which FAILs the
23
+ // lane — a device that never comes up is a failure to test, not a SKIP.
24
+ //
25
+ // Pure where it can be: every subprocess goes through the injected `sh`
26
+ // (the lane's own, so step deadlines apply) and the emulator spawn through
27
+ // `spawnImpl`, so the whole decision tree is unit-tested without an SDK.
28
+
29
+ import { spawn } from "node:child_process";
30
+ import fs from "node:fs";
31
+ import os from "node:os";
32
+ import path from "node:path";
33
+
34
+ /** How long a headless boot may take before it is killed and reported. */
35
+ export const BOOT_BOUND_MS = 240_000;
36
+ /** The AVD cmp-doctor creates (src/bootstrap/checks.mjs); preferred when present. */
37
+ export const PREFERRED_AVD = "cmp_pixel";
38
+ /** Headless, deterministic, no snapshot: every lane boots the same cold device. */
39
+ export const HEADLESS_ARGS = Object.freeze(["-no-window", "-no-audio", "-no-boot-anim", "-no-snapshot", "-gpu", "swiftshader_indirect"]);
40
+ const POLL_MS = 3000;
41
+
42
+ function sleepSync(ms) {
43
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
44
+ }
45
+
46
+ /** Serials in `device` state from `adb devices` output. */
47
+ export function parseAdbDevices(out) {
48
+ return String(out ?? "")
49
+ .split("\n")
50
+ .slice(1)
51
+ .map((l) => l.trim())
52
+ .filter(Boolean)
53
+ .map((l) => l.split(/\s+/))
54
+ .filter(([, state]) => state === "device")
55
+ .map(([serial]) => serial);
56
+ }
57
+
58
+ /** The emulator binary: SDK roots first, then PATH. */
59
+ export function emulatorBinary({ env = process.env, exists = fs.existsSync, home = os.homedir() } = {}) {
60
+ const roots = [env.ANDROID_HOME, env.ANDROID_SDK_ROOT, path.join(home, "Library", "Android", "sdk"), path.join(home, "Android", "Sdk")].filter(Boolean);
61
+ for (const root of roots) {
62
+ const bin = path.join(root, "emulator", "emulator");
63
+ if (exists(bin)) return bin;
64
+ }
65
+ return "emulator";
66
+ }
67
+
68
+ /**
69
+ * Which AVD to boot. Never a guess between several: another app's emulator
70
+ * carries another app's state, and a lane driving it crosses sessions.
71
+ * @param {string[]} listed `emulator -list-avds` lines
72
+ * @param {NodeJS.ProcessEnv} env
73
+ * @returns {{ok: true, avd: string} | {ok: false, reason: string}}
74
+ */
75
+ export function chooseAvd(listed, env = process.env) {
76
+ const avds = listed.map((l) => l.trim()).filter((l) => l && !l.startsWith("INFO") && !l.startsWith("WARNING"));
77
+ if (env.CMP_AVD) {
78
+ if (avds.includes(env.CMP_AVD)) return { ok: true, avd: env.CMP_AVD };
79
+ return { ok: false, reason: `CMP_AVD=${env.CMP_AVD} is not an AVD on this machine (have: ${avds.join(", ") || "none"})` };
80
+ }
81
+ if (avds.includes(PREFERRED_AVD)) return { ok: true, avd: PREFERRED_AVD };
82
+ if (avds.length === 1) return { ok: true, avd: avds[0] };
83
+ if (avds.length === 0) return { ok: false, reason: `no AVD on this machine — run the cmp-doctor skill (it creates ${PREFERRED_AVD}) or set CMP_AVD` };
84
+ return { ok: false, reason: `${avds.length} AVDs and no ${PREFERRED_AVD} (${avds.join(", ")}) — set CMP_AVD to the one this app owns; the lane will not guess between apps' emulators` };
85
+ }
86
+
87
+ /**
88
+ * Ensure a device is attached, booting a headless emulator when none is.
89
+ * @param {{
90
+ * sh: (cmd: string, opts?: object) => {ok: boolean, out: string},
91
+ * env?: NodeJS.ProcessEnv, spawnImpl?: typeof spawn, sleep?: (ms: number) => void,
92
+ * now?: () => number, log?: (line: string) => void, bootBoundMs?: number,
93
+ * exists?: (p: string) => boolean, kill?: (pid: number) => void,
94
+ * }} deps
95
+ * @returns {{ok: true, serial: string, booted: boolean, avd?: string, pid?: number, bootMs?: number}
96
+ * | {ok: false, optOut?: boolean, reason: string}}
97
+ */
98
+ export function ensureDevice({ sh, env = process.env, spawnImpl = spawn, sleep = sleepSync, now = Date.now, log = () => {}, bootBoundMs = BOOT_BOUND_MS, exists = fs.existsSync, kill = (pid) => process.kill(pid) } = {}) {
99
+ if (env.CMP_DEVICE === "none") {
100
+ return { ok: false, optOut: true, reason: "device tier disabled by CMP_DEVICE=none (the one explicit opt-out; a receipt carrying it is never done-evidence)" };
101
+ }
102
+ const attached = parseAdbDevices(sh("adb devices", { timeout: 10_000 }).out);
103
+ if (attached.length > 0) return { ok: true, serial: attached[0], booted: false };
104
+
105
+ const bin = emulatorBinary({ env, exists });
106
+ const listing = sh(`"${bin}" -list-avds`, { timeout: 30_000 });
107
+ if (!listing.ok) {
108
+ return { ok: false, reason: `no device attached and the emulator binary could not list AVDs (${bin}) — install the Android emulator (cmp-doctor) or attach a device` };
109
+ }
110
+ const choice = chooseAvd(listing.out.split("\n"), env);
111
+ if (!choice.ok) return { ok: false, reason: `no device attached and ${choice.reason}` };
112
+
113
+ log(`no device attached — booting ${choice.avd} headless (bound ${Math.round(bootBoundMs / 1000)} s)`);
114
+ const started = now();
115
+ let child;
116
+ try {
117
+ child = spawnImpl(bin, ["-avd", choice.avd, ...HEADLESS_ARGS], { detached: true, stdio: "ignore" });
118
+ if (typeof child.unref === "function") child.unref();
119
+ } catch (err) {
120
+ return { ok: false, reason: `could not start the emulator (${err && err.message ? err.message : String(err)})` };
121
+ }
122
+ let serial = null;
123
+ while (now() - started < bootBoundMs) {
124
+ sleep(POLL_MS);
125
+ const serials = parseAdbDevices(sh("adb devices", { timeout: 10_000 }).out);
126
+ if (serials.length === 0) continue;
127
+ serial = serials[0];
128
+ const booted = sh(`adb -s ${serial} shell getprop sys.boot_completed`, { timeout: 10_000 });
129
+ if (booted.ok && booted.out.trim() === "1") {
130
+ return { ok: true, serial, booted: true, avd: choice.avd, pid: child.pid, bootMs: now() - started };
131
+ }
132
+ }
133
+ // Past the bound: kill what we started, and say so. A device that never
134
+ // comes up is a failure to test — the step rows read ERROR, the lane FAILs.
135
+ if (serial) sh(`adb -s ${serial} emu kill`, { timeout: 15_000 });
136
+ try {
137
+ if (child.pid) kill(child.pid);
138
+ } catch {
139
+ /* already gone */
140
+ }
141
+ return { ok: false, reason: `emulator ${choice.avd} did not reach boot_completed within ${Math.round(bootBoundMs / 1000)} s (killed) — boot it by hand once to see why, or set CMP_AVD to a lighter AVD` };
142
+ }
143
+
144
+ /**
145
+ * Shut down the emulator THIS lane booted; an attached device is left alone.
146
+ * CMP_KEEP_DEVICE=1 keeps a booted one up for the next run (saves the boot).
147
+ */
148
+ export function releaseDevice(handle, { sh, env = process.env } = {}) {
149
+ if (!handle || !handle.ok || !handle.booted) return { shutdown: false };
150
+ if (env.CMP_KEEP_DEVICE === "1") return { shutdown: false, kept: true };
151
+ sh(`adb -s ${handle.serial} emu kill`, { timeout: 15_000 });
152
+ return { shutdown: true };
153
+ }
@@ -0,0 +1,60 @@
1
+ // e2e-coverage.mjs — every real feature with a screen has a device journey.
2
+ //
3
+ // The question (Karel, 2026-09-03): "is anything forcing e2e maestro tests to be
4
+ // written when we implement a feature?" Before this gate: no. A feature's flow
5
+ // was stamped as a skeleton that cites nothing, every clause could be proven by
6
+ // JVM tests alone, and a UI feature reached "done" with zero device evidence.
7
+ //
8
+ // The rule: a feature is REAL when it has a screen (presentation/<f>/*Screen.kt
9
+ // — reachability's definition) AND a spec (specs/<f>.spec.md). A real feature
10
+ // must have at least one live clause cited from a flow under qa/e2e — a flow
11
+ // that the lane RUNS (spec-coverage's listFlowFiles is the one list). A
12
+ // placeholder screen without a spec is not yet real (CHANGE-FLOW-DESIGN:
13
+ // "placeholder tabs earn a brief only when they become real") — reported, not
14
+ // failed. A screen declared `{ "unrouted": true }` in its brief has no journey
15
+ // to walk — exempt, by the same declare-not-gate mechanism reachability uses.
16
+ //
17
+ // Pure Node, milliseconds. FAILs by name with the file to write.
18
+
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { evaluateReachability } from "./reachability.mjs";
22
+ import { E2E_FLOW_DIR, scanCitations, scanSpecClauses } from "./spec-coverage.mjs";
23
+
24
+ /**
25
+ * @param {string} root
26
+ * @returns {{verdict: "PASS"|"FAIL"|"SKIP", reason?: string, details: {features: Array<{name: string, spec: string|null, liveClauses: number, e2eCited: string[], status: "covered"|"uncovered"|"unspecified"|"unrouted"}>}}}
27
+ */
28
+ export function evaluateE2eCoverage(root) {
29
+ if (!fs.existsSync(path.join(root, E2E_FLOW_DIR))) {
30
+ return { verdict: "SKIP", reason: `e2e harness not included in this project (no ${E2E_FLOW_DIR}/)`, details: { features: [] } };
31
+ }
32
+ const reach = evaluateReachability(root);
33
+ const screenFeatures = reach.details && Array.isArray(reach.details.features) ? reach.details.features : [];
34
+ if (screenFeatures.length === 0) {
35
+ return { verdict: "SKIP", reason: reach.reason ?? "no presentation/<feature> directory has a *Screen.kt file — nothing to cover", details: { features: [] } };
36
+ }
37
+ const clauses = scanSpecClauses(root);
38
+ const e2eTags = scanCitations(root).filter((t) => t.tier === "e2e");
39
+ const features = screenFeatures.map((f) => {
40
+ const specRel = `specs/${f.name}.spec.md`;
41
+ const spec = fs.existsSync(path.join(root, specRel)) ? specRel : null;
42
+ if (f.unrouted) return { name: f.name, spec, liveClauses: 0, e2eCited: [], status: "unrouted" };
43
+ if (!spec) return { name: f.name, spec: null, liveClauses: 0, e2eCited: [], status: "unspecified" };
44
+ const live = [...clauses.entries()].filter(([, c]) => c.file.split(path.sep).join("/") === specRel && !c.withdrawn).map(([id]) => id);
45
+ const e2eCited = [...new Set(e2eTags.filter((t) => live.includes(t.id)).map((t) => t.id))].sort();
46
+ return { name: f.name, spec, liveClauses: live.length, e2eCited, status: e2eCited.length ? "covered" : "uncovered" };
47
+ });
48
+ const uncovered = features.filter((f) => f.status === "uncovered");
49
+ if (uncovered.length === 0) return { verdict: "PASS", details: { features } };
50
+ const lines = [
51
+ `${uncovered.length} feature${uncovered.length === 1 ? " has" : "s have"} a screen and a spec but no device journey — no flow under ${E2E_FLOW_DIR}/ cites any of their clauses:`,
52
+ ...uncovered.map((f) =>
53
+ f.liveClauses === 0
54
+ ? ` [${f.name}] ${f.spec} has no live clauses — promise the behaviour there first, then prove one clause in ${E2E_FLOW_DIR}/${f.name}.yaml (# SPEC: <ID> above the steps)`
55
+ : ` [${f.name}] ${f.spec} has ${f.liveClauses} live clause${f.liveClauses === 1 ? "" : "s"} — write the journey in ${E2E_FLOW_DIR}/${f.name}.yaml and cite the clause(s) it proves (# SPEC: <ID> above the steps)`,
56
+ ),
57
+ "A UI feature is proven on a device, not only on the JVM. Declare { \"unrouted\": true } in its brief only if the screen is intentionally not reachable yet.",
58
+ ];
59
+ return { verdict: "FAIL", reason: lines.join("\n"), details: { features } };
60
+ }
@@ -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(
@@ -41,6 +41,7 @@ const SCAFFOLD_CORE = [
41
41
  "approvals",
42
42
  "componentStories",
43
43
  "reachability",
44
+ "e2eCoverage",
44
45
  "archDoc",
45
46
  "schemaHistory",
46
47
  "build",
@@ -65,6 +66,27 @@ const RELEASE_EXECUTION = "releaseSmoke";
65
66
 
66
67
  const RUNG_NAMES = { L0: "scaffold", L1: "desktop", L2: "device", L3: "release" };
67
68
 
69
+ /**
70
+ * The Compose Multiplatform pack's ladder — the step names above, as one
71
+ * object a pack hands to the spine. THE LADDER IS THE PACK'S, NOT THE SPINE'S
72
+ * (2026-09-03): vendored into a Kotlin backend, these names graded its
73
+ * strongest run — detekt, Konsist, gitleaks — as L0 "scaffold" and
74
+ * made L1 unreachable by construction. A fixed-amount understatement is not
75
+ * conservative, it is wrong, and receipts are where labels get quoted. So a
76
+ * pack declares its ladder (`evidenceLadder` on createXSteps' return); a pack
77
+ * that declares none earns no rung at all, which is the honest grade for a
78
+ * ladder nobody has calibrated. Every field is a list of step names except
79
+ * `release`, one name. `names` maps rung → label.
80
+ */
81
+ export const CMP_LADDER = Object.freeze({
82
+ scaffoldCore: Object.freeze(SCAFFOLD_CORE),
83
+ l0Required: Object.freeze(L0_REQUIRED),
84
+ l1Required: Object.freeze(L1_REQUIRED),
85
+ deviceExecution: Object.freeze(DEVICE_EXECUTION),
86
+ release: RELEASE_EXECUTION,
87
+ names: Object.freeze(RUNG_NAMES),
88
+ });
89
+
68
90
  /**
69
91
  * Derive the receipt's evidence rung from the lane's step results.
70
92
  *
@@ -81,8 +103,18 @@ const RUNG_NAMES = { L0: "scaffold", L1: "desktop", L2: "device", L3: "release"
81
103
  * was not earned. `satisfiedBy` lists the PASSed steps the rung counts as
82
104
  * its evidence, in lane order.
83
105
  */
84
- export function evidenceLevel(stepResults, profile, { mode } = {}) { // eslint-disable-line no-unused-vars
106
+ export function evidenceLevel(stepResults, profile, { mode, ladder } = {}) { // eslint-disable-line no-unused-vars
85
107
  if (mode === "fast") return null; // the inner loop derives no rung — ever
108
+ // `ladder` absent → the Compose ladder (every caller before packs declared
109
+ // one). `ladder: null` → the pack declares none: no rung, by decision.
110
+ if (ladder === null) return null;
111
+ const L = ladder ?? CMP_LADDER;
112
+ const SCAFFOLD_CORE = L.scaffoldCore ?? [];
113
+ const L0_REQUIRED = L.l0Required ?? [];
114
+ const L1_REQUIRED = L.l1Required ?? [];
115
+ const DEVICE_EXECUTION = L.deviceExecution ?? [];
116
+ const RELEASE_EXECUTION = L.release ?? null;
117
+ const RUNG_NAMES = L.names ?? CMP_LADDER.names;
86
118
  const steps = Array.isArray(stepResults) ? stepResults.filter((s) => s && typeof s.name === "string") : [];
87
119
  // A failed lane has no rung — and a lane with a step that could not run
88
120
  // (ERROR) has none either: a rung is evidence, and "could not check" is not.
@@ -108,7 +140,7 @@ export function evidenceLevel(stepResults, profile, { mode } = {}) { // eslint-d
108
140
 
109
141
  // Only a PASSed releaseSmoke lifts to L3 — a SKIP (unsigned keystore,
110
142
  // no device) never does.
111
- if (passed.has(RELEASE_EXECUTION)) {
143
+ if (RELEASE_EXECUTION && passed.has(RELEASE_EXECUTION)) {
112
144
  rung = "L3";
113
145
  counted.add(RELEASE_EXECUTION);
114
146
  }
@@ -334,15 +334,24 @@ export function deriveFeatureStatus(root, brief, pre = {}) {
334
334
  const specRels = specNames.map((n) => `specs/${n}.spec.md`);
335
335
  const specExists = specRels.every((rel) => fs.existsSync(path.join(root, rel)));
336
336
  const specRel = specRels.join(" + ");
337
- const citedIds = new Set((pre.citations ?? scanCitations(root)).map((t) => t.id));
337
+ const citations = pre.citations ?? scanCitations(root);
338
+ const citedIds = new Set(citations.map((t) => t.id));
339
+ // Which clauses a DEVICE journey proves: citations from qa/e2e flows (tier
340
+ // "e2e" — spec-coverage's tierForFile). A UI feature (screens: true) is not
341
+ // done until at least one of its live clauses is cited from a flow: JVM
342
+ // tests prove logic and structure, the flow proves the journey on a device.
343
+ const e2eCitedIds = new Set(citations.filter((t) => t.tier === "e2e").map((t) => t.id));
338
344
  const clauses = specRels
339
345
  .flatMap((rel) => clausesOfSpec(root, rel))
340
- .map((c) => ({ ...c, cited: citedIds.has(c.id) }));
346
+ .map((c) => ({ ...c, cited: citedIds.has(c.id), e2eCited: e2eCitedIds.has(c.id) }));
341
347
  const live = clauses.filter((c) => !c.withdrawn);
342
348
  const covered = live.filter((c) => c.cited).length;
349
+ const e2eCovered = live.filter((c) => c.e2eCited).length;
350
+ const needsJourney = block.screens === true && block.unrouted !== true;
343
351
 
344
352
  const receipt = pre.receipt ?? receiptAttestation(root);
345
- const provenDone = live.length > 0 && covered === live.length && receipt.verdict === "PASS" && receipt.attestsTree;
353
+ const provenDone =
354
+ live.length > 0 && covered === live.length && (!needsJourney || e2eCovered > 0) && receipt.verdict === "PASS" && receipt.attestsTree;
346
355
 
347
356
  return {
348
357
  name: brief.name,
@@ -361,6 +370,8 @@ export function deriveFeatureStatus(root, brief, pre = {}) {
361
370
  specExists,
362
371
  clauses,
363
372
  covered,
373
+ e2eCovered,
374
+ needsJourney,
364
375
  total: live.length,
365
376
  receipt,
366
377
  provenDone,
@@ -372,6 +383,8 @@ export function deriveFeatureStatus(root, brief, pre = {}) {
372
383
  ? `no spec yet (${specRel}) — behavior starts as clauses there`
373
384
  : live.length === 0
374
385
  ? `${specRel} has no live clauses — nothing is promised yet`
386
+ : needsJourney && covered === live.length && e2eCovered === 0
387
+ ? `${covered}/${live.length} clauses cited, but none from a qa/e2e flow — a UI feature is proven on a device: write the journey in qa/e2e/${brief.name}.yaml and cite the clause it proves`
375
388
  : covered < live.length
376
389
  ? `${covered}/${live.length} clauses cited — ${live.length - covered} promise(s) have no citing test`
377
390
  : !receipt.present
@@ -134,7 +134,12 @@ export function checkHarnessIntegrity(root) {
134
134
  */
135
135
  export function describeIntegrity(r) {
136
136
  if (r.status === "intact") {
137
- return `${r.name ?? "harness"} ${r.version ?? "?"} ${r.fileCount} files verified`;
137
+ // The region digest rides beside the version: two lanes can carry the same
138
+ // package version with different content (create-cmp-showcase, 2026-09-03 —
139
+ // seven files changed, "0.16.0" on both receipts), and a receipt must name
140
+ // WHICH lane produced it in words a human reads, not only in the lock file.
141
+ const region = typeof r.sha256 === "string" && r.sha256 ? ` (region ${r.sha256.slice(0, 8)})` : "";
142
+ return `${r.name ?? "harness"} ${r.version ?? "?"}${region} — ${r.fileCount} files verified`;
138
143
  }
139
144
  if (r.status === "unlocked") {
140
145
  return `no ${LOCK_PATH} — this app's lane version is unrecorded`;
@@ -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(
@@ -58,7 +58,29 @@ const TYPE_DECL_RE = /^(?:@\w+\s+)*(?:public\s+|internal\s+|private\s+|abstract\
58
58
 
59
59
  // A YAML flow's own shape counts as its test: a Maestro file IS the test, so a
60
60
  // tag in one binds to the flow rather than to a declaration inside it.
61
- const FLOW_EXTS = [".yaml", ".yml"];
61
+ export const FLOW_EXTS = [".yaml", ".yml"];
62
+ /** Where Maestro flows live. The lane runs this DIRECTORY (every top-level flow). */
63
+ export const E2E_FLOW_DIR = "qa/e2e";
64
+
65
+ /**
66
+ * The flows the lane executes: top-level `*.yaml`/`*.yml` under qa/e2e, sorted,
67
+ * root-relative. ONE list serves two readers — the e2eSmoke step (what runs)
68
+ * and scanCitations (what may count as coverage) — so a citation can only
69
+ * ever come from a flow that executes. Before 2026-09-03 the step ran ONE
70
+ * file by name (smoke.yaml) while the scan walked the whole directory: four
71
+ * hand-written flows on the showcase satisfied clauses without ever running.
72
+ * @param {string} root
73
+ * @returns {string[]}
74
+ */
75
+ export function listFlowFiles(root) {
76
+ const dir = path.join(root, E2E_FLOW_DIR);
77
+ if (!fs.existsSync(dir)) return [];
78
+ return fs
79
+ .readdirSync(dir, { withFileTypes: true })
80
+ .filter((e) => e.isFile() && FLOW_EXTS.some((ext) => e.name.endsWith(ext)))
81
+ .map((e) => `${E2E_FLOW_DIR}/${e.name}`)
82
+ .sort();
83
+ }
62
84
 
63
85
  /**
64
86
  * Does a test declaration follow `index` within BINDING_WINDOW non-blank lines,
@@ -170,8 +192,10 @@ export const DESKTOP_TIERS = Object.freeze(["commonTest", "desktopTest"]);
170
192
  */
171
193
  export function scanCitations(root) {
172
194
  const tags = [];
173
- const searchDirs = [path.join(root, "composeApp/src"), path.join(root, "qa/e2e")];
174
- const files = searchDirs.flatMap((d) => walkFiles(d, [".kt", ".kts", ".yaml", ".yml"]));
195
+ // Kotlin sources anywhere under composeApp/src; flows ONLY from the list the
196
+ // lane runs (listFlowFiles) a flow in a subfolder, or a yaml under
197
+ // composeApp/src, is not executed by e2eSmoke and therefore proves nothing.
198
+ const files = [...walkFiles(path.join(root, "composeApp/src"), [".kt", ".kts"]), ...listFlowFiles(root).map((rel) => path.join(root, rel))];
175
199
  for (const f of files) {
176
200
  const rel = path.relative(root, f);
177
201
  const tier = tierForFile(rel);