create-cmp-cli 0.21.0 → 0.23.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 (31) hide show
  1. package/package.json +1 -1
  2. package/packages/harness/package.json +1 -1
  3. package/packages/harness/src/lib/device-provider.mjs +153 -0
  4. package/packages/harness/src/lib/e2e-coverage.mjs +60 -0
  5. package/packages/harness/src/lib/evidence-level.mjs +2 -1
  6. package/packages/harness/src/lib/feature-brief.mjs +16 -3
  7. package/packages/harness/src/lib/harness-lock.mjs +6 -1
  8. package/packages/harness/src/lib/harness-region.mjs +50 -2
  9. package/packages/harness/src/lib/spec-coverage.mjs +27 -3
  10. package/packages/harness/src/lib/step-outcomes.mjs +104 -0
  11. package/packages/harness/src/lib/steps-cmp.mjs +106 -40
  12. package/packages/harness/src/receipt-check.mjs +25 -0
  13. package/packages/harness/src/scaffold-feature.mjs +55 -0
  14. package/src/commands/upgrade.mjs +31 -2
  15. package/src/lib/tabs.mjs +18 -3
  16. package/src/lib/upgrade.mjs +50 -0
  17. package/template/.github/workflows/verify.yml +9 -0
  18. package/template/gitignore +9 -0
  19. package/template/qa/e2e/README.md +23 -4
  20. package/template/qa/e2e/smoke.yaml +9 -1
  21. package/template/qa/lib/device-provider.mjs +153 -0
  22. package/template/qa/lib/e2e-coverage.mjs +60 -0
  23. package/template/qa/lib/evidence-level.mjs +2 -1
  24. package/template/qa/lib/feature-brief.mjs +16 -3
  25. package/template/qa/lib/harness-lock.mjs +6 -1
  26. package/template/qa/lib/harness-region.mjs +50 -2
  27. package/template/qa/lib/spec-coverage.mjs +27 -3
  28. package/template/qa/lib/step-outcomes.mjs +104 -0
  29. package/template/qa/lib/steps-cmp.mjs +106 -40
  30. package/template/qa/receipt-check.mjs +25 -0
  31. package/template/qa/scaffold-feature.mjs +55 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-cmp-cli",
3
- "version": "0.21.0",
3
+ "version": "0.23.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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@create-cmp/harness",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "The create-cmp verify lane — the machine-owned harness code every stamped app carries byte-identical: evidence receipts, spec coverage, approvals, conformance reporting, golden trees, a11y, and the preview/inspector libs. Dependency-free ESM, vendored into each generated project so the lane runs offline with no install step, and content-hashed so a receipt can name the exact lane that issued it.",
5
5
  "type": "module",
6
6
  "main": "src/verify.mjs",
@@ -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
+ }
@@ -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",
@@ -69,7 +70,7 @@ const RUNG_NAMES = { L0: "scaffold", L1: "desktop", L2: "device", L3: "release"
69
70
  * The Compose Multiplatform pack's ladder — the step names above, as one
70
71
  * object a pack hands to the spine. THE LADDER IS THE PACK'S, NOT THE SPINE'S
71
72
  * (2026-09-03): vendored into a Kotlin backend, these names graded its
72
- * strongest run — detekt, Konsist, mutation, gitleaks — as L0 "scaffold" and
73
+ * strongest run — detekt, Konsist, gitleaks — as L0 "scaffold" and
73
74
  * made L1 unreachable by construction. A fixed-amount understatement is not
74
75
  * conservative, it is wrong, and receipts are where labels get quoted. So a
75
76
  * pack declares its ladder (`evidenceLadder` on createXSteps' return); a pack
@@ -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`;
@@ -53,17 +53,58 @@ import path from "node:path";
53
53
  */
54
54
  export const HARNESS_DIRS = ["qa", "qa/lib"];
55
55
 
56
+ /**
57
+ * The lane's OWN tests, when a project carries them (payment-blueprint's
58
+ * qa/test/**, 2026-09-03). Every `.mjs` under it, recursively, is in the
59
+ * region: the tests that prove the gates are locked WITH the gates, or the
60
+ * lane can no longer tell you that the suite vouching for its verdicts is the
61
+ * one it was locked with — a narrower claim in the same words, which is the
62
+ * failure this mechanism exists to prevent. A Compose app has no qa/test and
63
+ * its region (and lock) is unchanged.
64
+ */
65
+ export const HARNESS_TEST_DIR = "qa/test";
66
+
67
+ /**
68
+ * DECLARATIONS the lane READS to decide what it attests — locked for the same
69
+ * reason verify.mjs is. payment-blueprint's planted proof (2026-09-03): remove
70
+ * one entry from qa/verified-surface.json and 203 files — the whole backend —
71
+ * stop being attested; the next lane run mints a fresh receipt over the smaller
72
+ * surface, harnessIntegrity PASS, nothing in the chain says the coverage moved.
73
+ * An edited checker and an edited definition of what the checker looks at are
74
+ * the same attack. State a lane WRITES (approvals.json, comments.json, the
75
+ * journal, evidence/) stays out — that is the EXCLUDED_PREFIXES distinction.
76
+ * Neither file exists in a Compose app by default; its region is unchanged.
77
+ */
78
+ export const HARNESS_DECLARATIONS = ["qa/verified-surface.json", "qa/harness-manifest.json"];
79
+
56
80
  /**
57
81
  * Is this project-relative path part of the machine-owned harness region?
58
82
  * @param {string} relPath project-relative path, "/"-separated
59
83
  * @returns {boolean}
60
84
  */
61
85
  export function isHarnessFile(relPath) {
62
- if (typeof relPath !== "string" || !relPath.endsWith(".mjs")) return false;
86
+ if (typeof relPath !== "string") return false;
87
+ if (HARNESS_DECLARATIONS.includes(relPath)) return true;
88
+ if (!relPath.endsWith(".mjs")) return false;
89
+ if (relPath.startsWith(`${HARNESS_TEST_DIR}/`)) return true;
63
90
  const dir = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/")) : "";
64
91
  return HARNESS_DIRS.includes(dir);
65
92
  }
66
93
 
94
+ function walkMjs(dirAbs, relPrefix, out) {
95
+ let entries;
96
+ try {
97
+ entries = fs.readdirSync(dirAbs, { withFileTypes: true });
98
+ } catch {
99
+ return;
100
+ }
101
+ for (const ent of entries) {
102
+ const rel = `${relPrefix}/${ent.name}`;
103
+ if (ent.isDirectory()) walkMjs(path.join(dirAbs, ent.name), rel, out);
104
+ else if (ent.isFile() && isHarnessFile(rel)) out.push(rel);
105
+ }
106
+ }
107
+
67
108
  /**
68
109
  * Every machine-owned file present under `root`, as project-relative posix
69
110
  * paths, sorted — so the list (and any hash over it) is deterministic.
@@ -85,7 +126,14 @@ export function listHarnessFiles(root) {
85
126
  if (isHarnessFile(rel)) found.push(rel);
86
127
  }
87
128
  }
88
- return found.sort();
129
+ walkMjs(path.join(root, HARNESS_TEST_DIR), HARNESS_TEST_DIR, found);
130
+ // A declaration directly under qa/ is already seen by the scan above (it is
131
+ // a harness file by name); the explicit loop covers one that lives deeper.
132
+ // Deduplicated so no path is hashed twice.
133
+ for (const rel of HARNESS_DECLARATIONS) {
134
+ if (fs.existsSync(path.join(root, ...rel.split("/")))) found.push(rel);
135
+ }
136
+ return [...new Set(found)].sort();
89
137
  }
90
138
 
91
139
  /** sha256 of one file's bytes, hex. */
@@ -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);
@@ -121,3 +121,107 @@ export function stepErrorResult(name, err, durationMs) {
121
121
  `Nothing here is a claim about your change.`;
122
122
  return { name, verdict: "ERROR", reason, durationMs, details: { executed: false, kind: timeout ? "deadline" : "threw" } };
123
123
  }
124
+
125
+ // ── Maestro directory run → per-flow outcome ────────────────────────────────
126
+ /**
127
+ * Parse Maestro's JUnit report (`maestro test <dir> --format junit --output f`)
128
+ * into one row per flow. Tolerant: a report that is missing or unparsable
129
+ * returns null and the caller falls back to the exit code — never a fabricated
130
+ * per-flow list.
131
+ * @param {string|null} xml
132
+ * @returns {Array<{flow: string, ok: boolean, message?: string}>|null}
133
+ */
134
+ export function parseMaestroJunit(xml) {
135
+ if (typeof xml !== "string" || !/<testcase\b/.test(xml)) return null;
136
+ const rows = [];
137
+ const caseRe = /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g;
138
+ let m;
139
+ while ((m = caseRe.exec(xml))) {
140
+ const attrs = m[1] || "";
141
+ const body = m[2] || "";
142
+ const name = (attrs.match(/\bname="([^"]*)"/) || [])[1] ?? (attrs.match(/\bid="([^"]*)"/) || [])[1] ?? "?";
143
+ const status = (attrs.match(/\bstatus="([^"]*)"/) || [])[1];
144
+ const failed = /<(failure|error)\b/.test(body) || (status && !/^(SUCCESS|PASSED?|OK)$/i.test(status));
145
+ const message = failed ? ((body.match(/<(?:failure|error)\b[^>]*message="([^"]*)"/) || [])[1] ?? body.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 300)) : undefined;
146
+ rows.push(failed ? { flow: name, ok: false, message } : { flow: name, ok: true });
147
+ }
148
+ return rows.length ? rows : null;
149
+ }
150
+
151
+ /**
152
+ * The e2e step's verdict from the Maestro run: exit code + per-flow report +
153
+ * the list of flows the directory held. FAIL names every failing flow; a run
154
+ * whose report lists fewer flows than the directory holds is ERROR — the lane
155
+ * cannot claim flows it has no row for.
156
+ * @param {{ok: boolean, out: string}} res
157
+ * @param {Array<{flow: string, ok: boolean, message?: string}>|null} perFlow
158
+ * @param {string[]} flows root-relative flow files the directory holds
159
+ * @returns {{verdict: "PASS"|"FAIL"|"ERROR", reason?: string, details: object}}
160
+ */
161
+ export function maestroOutcome(res, perFlow, flows) {
162
+ const details = { flows, results: perFlow ?? undefined };
163
+ if (perFlow) {
164
+ const failed = perFlow.filter((r) => !r.ok);
165
+ if (failed.length) {
166
+ return {
167
+ verdict: "FAIL",
168
+ reason: `Maestro: ${failed.length} of ${perFlow.length} flow${perFlow.length === 1 ? "" : "s"} failed — ${failed.map((r) => `${r.flow}${r.message ? ` (${r.message.split("\n")[0].slice(0, 120)})` : ""}`).join("; ")}`,
169
+ details,
170
+ };
171
+ }
172
+ if (perFlow.length < flows.length) {
173
+ return {
174
+ verdict: "ERROR",
175
+ reason: `Maestro reported ${perFlow.length} flow${perFlow.length === 1 ? "" : "s"} but ${E2E_DIR_LABEL} holds ${flows.length} — the run did not cover every flow, so no verdict can be claimed for the rest`,
176
+ details,
177
+ };
178
+ }
179
+ if (!res.ok) {
180
+ return { verdict: "FAIL", reason: `Maestro exited non-zero with every flow reported green — treat as a run failure:\n${String(res.out).split("\n").slice(-10).join("\n")}`, details };
181
+ }
182
+ return { verdict: "PASS", details };
183
+ }
184
+ if (!res.ok) {
185
+ return { verdict: "FAIL", reason: `Maestro failed (no per-flow report was written):\n${String(res.out).split("\n").slice(-15).join("\n")}`, details };
186
+ }
187
+ return { verdict: "PASS", reason: "Maestro exited 0 but wrote no per-flow report — verdict from the exit code only", details };
188
+ }
189
+ const E2E_DIR_LABEL = "qa/e2e";
190
+
191
+ // ── Device-log incidents, scoped to the app under test ──────────────────────
192
+ /**
193
+ * ANR / fatal-exception lines from `adb logcat -d -b system,crash,main` that
194
+ * belong to one of `appIds` (an app's process may be `pkg` or `pkg:remote`).
195
+ * An emulator carries other apps — on 2026-09-03 the first self-booted lane
196
+ * went red on `ANR in com.karel.bratometer` while driving com.fleet.check —
197
+ * so an incident in another package is NOT this lane's failure. With no
198
+ * appIds known the sweep stays unscoped (every incident counts) and says so.
199
+ * @param {string} log
200
+ * @param {string[]} appIds
201
+ * @returns {{lines: string[], scoped: boolean}}
202
+ */
203
+ export function deviceLogIncidents(log, appIds = []) {
204
+ const lines = String(log ?? "").split("\n");
205
+ const ids = appIds.filter((id) => typeof id === "string" && id.trim() && id !== "__PACKAGE__");
206
+ const scoped = ids.length > 0;
207
+ const ours = (proc) => !scoped || ids.some((id) => proc === id || proc.startsWith(`${id}:`));
208
+ const out = [];
209
+ lines.forEach((line, i) => {
210
+ const anr = line.match(/ANR in (\S+?)(?:\s|,|$)/);
211
+ if (anr) {
212
+ if (ours(anr[1].replace(/[,)]$/, ""))) out.push(line);
213
+ return;
214
+ }
215
+ if (/FATAL EXCEPTION/i.test(line)) {
216
+ if (!scoped) {
217
+ out.push(line);
218
+ return;
219
+ }
220
+ // The crash buffer names the process on one of the next few lines.
221
+ const window = lines.slice(i, i + 8).join("\n");
222
+ const proc = window.match(/Process:\s*(\S+?)(?:,|\s|$)/);
223
+ if (proc && ours(proc[1])) out.push(line);
224
+ }
225
+ });
226
+ return { lines: out, scoped };
227
+ }