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
@@ -21,17 +21,21 @@ import fs from "node:fs";
21
21
  import path from "node:path";
22
22
  import { compareTokenDrift } from "./token-drift.mjs";
23
23
  import { evaluateApprovalsGate } from "./approvals.mjs";
24
- import { TIERS_SATISFYING, clauseTierCoverage, scanCitations, scanSpecClauses, walkFiles } from "./spec-coverage.mjs";
24
+ import { E2E_FLOW_DIR, TIERS_SATISFYING, clauseTierCoverage, listFlowFiles, scanCitations, scanSpecClauses, walkFiles } from "./spec-coverage.mjs";
25
25
  import { evaluateComponentStoryParity } from "./component-stories.mjs";
26
26
  import { evaluateReachability } from "./reachability.mjs";
27
+ import { evaluateE2eCoverage } from "./e2e-coverage.mjs";
27
28
  import { memoizeStep } from "./step-cache.mjs";
28
29
  import { changedWorkingTreePaths, deriveAffectedFilter } from "./affected-tests.mjs";
29
30
  import { acquireDeviceLease, releaseDeviceLease, formatHolder } from "./device-lease.mjs";
30
31
  import { ARCH_DOC_REL_PATH, SECTION_IDS, regenerateArchDoc } from "./arch-doc.mjs";
31
32
  import { DETERMINISM_TIMEZONES, compareOutcomes, parseJUnitOutcomes } from "./determinism.mjs";
32
33
  import { evaluateAuditCadence } from "./audit-cadence.mjs";
33
- import { androidChecksOutcome } from "./step-outcomes.mjs";
34
+ import { androidChecksOutcome, deviceLogIncidents, maestroOutcome, parseMaestroJunit } from "./step-outcomes.mjs";
35
+ import { ensureDevice, releaseDevice } from "./device-provider.mjs";
34
36
  import { checkHarnessIntegrity, describeIntegrity, LOCK_PATH } from "./harness-lock.mjs";
37
+ import { stepDisplayName } from "./lane-runner.mjs";
38
+ import { CMP_LADDER } from "./evidence-level.mjs";
35
39
 
36
40
  /**
37
41
  * @param {object} ctx
@@ -113,6 +117,33 @@ function deviceAttached() {
113
117
  // honest and visible is exactly why SKIP is the right verdict.
114
118
  let laneDeviceLease = null;
115
119
 
120
+ // ── The lane's device (qa/lib/device-provider.mjs) ──────────────────────────
121
+ // Provisioned ONCE per run by the first device step that needs it: an attached
122
+ // device is used as-is; with none attached the lane boots a headless emulator
123
+ // (bounded) and shuts it down in the runner's finally. A device that cannot
124
+ // be provisioned is an ERROR row — "could not check" — never a SKIP that
125
+ // reads as an honest gap and is then ignored forever (2026-09-03: the whole
126
+ // tier had SKIPped on every receipt anyone looked at). The one SKIP left is
127
+ // the explicit opt-out CMP_DEVICE=none, marked skipKind "environment" so the
128
+ // receipt check refuses it as done-evidence.
129
+ let laneDevice = null;
130
+
131
+ function deviceRow(stepName, d) {
132
+ if (d.optOut) return { name: stepName, verdict: "SKIP", skipKind: "environment", reason: d.reason, durationMs: 0 };
133
+ return { name: stepName, verdict: "ERROR", reason: `could not provision a device: ${d.reason}`, durationMs: 0 };
134
+ }
135
+
136
+ /** null when the lane has a device; otherwise the row the step returns verbatim. */
137
+ function ensureLaneDevice(stepName) {
138
+ if (!laneDevice) {
139
+ laneDevice = ensureDevice({ sh, log: (line) => console.error(`· ${line}`) });
140
+ if (laneDevice.ok && laneDevice.booted) {
141
+ console.error(`· booted ${laneDevice.avd} headless (${laneDevice.serial}) in ${Math.round(laneDevice.bootMs / 1000)} s — shut down when the lane exits (CMP_KEEP_DEVICE=1 keeps it)`);
142
+ }
143
+ }
144
+ return laneDevice.ok ? null : deviceRow(stepName, laneDevice);
145
+ }
146
+
116
147
  /** Serials of devices currently in `device` state (same parse as deviceAttached). */
117
148
  function attachedDeviceSerials() {
118
149
  const res = sh("adb devices", { timeout: 10_000 });
@@ -148,6 +179,7 @@ function leaseDeviceForStep(stepName) {
148
179
  return {
149
180
  name: stepName,
150
181
  verdict: "SKIP",
182
+ skipKind: "environment",
151
183
  reason: `${serials.length} devices attached (${serials.join(", ")}) — the lane cannot tell which one it would drive, so it leases none rather than guessing. Set ANDROID_SERIAL to the device this lane should own, or detach the extras.`,
152
184
  durationMs: 0,
153
185
  };
@@ -158,6 +190,7 @@ function leaseDeviceForStep(stepName) {
158
190
  return {
159
191
  name: stepName,
160
192
  verdict: "SKIP",
193
+ skipKind: "environment",
161
194
  reason: `device ${serial} is held by ${formatHolder(res.heldBy)} — device evidence is batched, not concurrent; wait for it or run once when it finishes`,
162
195
  durationMs: 0,
163
196
  };
@@ -381,6 +414,16 @@ function stepReachability() {
381
414
  // (regenerateArchDoc); this step only adds the name/duration bookkeeping every
382
415
  // step in this file carries, plus wording the FAIL reason for an AI
383
416
  // collaborator (name the stale/missing section, name the fix command).
417
+ // Every real feature has a device journey (qa/lib/e2e-coverage.mjs): a screen
418
+ // plus a spec means at least one live clause cited from a flow the lane runs.
419
+ // Pure Node. This is the gate that makes "write the Maestro flow" a lane
420
+ // verdict instead of a habit (Karel, 2026-09-03).
421
+ function stepE2eCoverage() {
422
+ const started = Date.now();
423
+ const { verdict, reason, details } = evaluateE2eCoverage(ROOT);
424
+ return { name: "e2eCoverage", verdict, reason, durationMs: Date.now() - started, details };
425
+ }
426
+
384
427
  function stepArchDoc() {
385
428
  const started = Date.now();
386
429
  const elapsed = () => Date.now() - started;
@@ -787,14 +830,8 @@ function stepTokenDrift() {
787
830
  const started = Date.now();
788
831
  const elapsed = () => Date.now() - started;
789
832
 
790
- if (!deviceAttached()) {
791
- return {
792
- name: "tokenDrift",
793
- verdict: "SKIP",
794
- reason: "no Android device/emulator attached (adb) — runtime token drift needs the live inspector tier",
795
- durationMs: elapsed(),
796
- };
797
- }
833
+ const device = ensureLaneDevice("tokenDrift");
834
+ if (device) return { ...device, durationMs: elapsed() };
798
835
 
799
836
  const unreachable = () => ({
800
837
  name: "tokenDrift",
@@ -868,15 +905,17 @@ function maestroAvailable() {
868
905
  // The e2e guard trio, shared by every step that drives the smoke flow on a device.
869
906
  // Returns null when the harness is fully available, else the SKIP result for [name].
870
907
  function maestroGuards(name) {
871
- if (!fs.existsSync(path.join(ROOT, "qa/e2e"))) {
872
- return { name, verdict: "SKIP", reason: "e2e harness not included in this project (--no-e2e)", durationMs: 0 };
908
+ if (!fs.existsSync(path.join(ROOT, E2E_FLOW_DIR))) {
909
+ return { name, verdict: "SKIP", skipKind: "structure", reason: "e2e harness not included in this project (--no-e2e)", durationMs: 0 };
873
910
  }
874
- if (!deviceAttached()) {
875
- return { name, verdict: "SKIP", reason: "no Android device/emulator attached (adb)", durationMs: 0 };
911
+ if (listFlowFiles(ROOT).length === 0) {
912
+ return { name, verdict: "SKIP", skipKind: "structure", reason: `${E2E_FLOW_DIR}/ holds no flows (*.yaml) — nothing to drive`, durationMs: 0 };
876
913
  }
877
914
  if (!maestroAvailable()) {
878
- return { name, verdict: "SKIP", reason: "maestro CLI not installed — curl -fsSL https://get.maestro.mobile.dev | bash", durationMs: 0 };
915
+ return { name, verdict: "SKIP", skipKind: "environment", reason: "maestro CLI not installed — curl -fsSL https://get.maestro.mobile.dev | bash", durationMs: 0 };
879
916
  }
917
+ const device = ensureLaneDevice(name);
918
+ if (device) return device;
880
919
  return null;
881
920
  }
882
921
 
@@ -895,32 +934,59 @@ function maestroGuards(name) {
895
934
  // hide_error_dialogs suppresses the OS dialog, NEVER the underlying event — so after the
896
935
  // run we grep the device log for ANR/crash lines the dialog would have shown, and FAIL on
897
936
  // them. The eyes must report what automation stability had to hide.
937
+ // EVERY flow runs (2026-09-03). This used to run qa/e2e/smoke.yaml by name
938
+ // while spec coverage counted a citation from ANY yaml under qa/e2e — four
939
+ // hand-written per-feature flows on the showcase satisfied clauses without
940
+ // ever executing. The directory runs in ONE Maestro session (one driver
941
+ // start-up, per-flow rows from the JUnit report); listFlowFiles is the same
942
+ // list the coverage scan reads, so cited ⊆ executed holds by construction.
943
+ /** Driver start-up plus three minutes per flow: the Maestro run's own bound, whatever the journal says. */
944
+ const E2E_RUN_BOUND_MS = (flowCount) => 120_000 + 180_000 * Math.max(1, flowCount);
945
+
898
946
  function runMaestroSmoke(name, priorDurationMs) {
947
+ const flows = listFlowFiles(ROOT);
948
+ const report = path.join(ROOT, "qa-artifacts", `maestro-${name}.xml`);
949
+ fs.mkdirSync(path.dirname(report), { recursive: true });
950
+ fs.rmSync(report, { force: true });
899
951
  const prevHideErrorDialogs = sh("adb shell settings get global hide_error_dialogs").out.trim();
900
952
  sh("adb shell settings put global hide_error_dialogs 1");
901
953
  sh("adb logcat -c"); // clear so the post-run dump only reflects this run
902
954
  try {
903
- const res = sh("maestro test qa/e2e/smoke.yaml", { env: { ...process.env, MAESTRO_DRIVER_STARTUP_TIMEOUT: "120000" } });
904
- if (!res.ok) {
905
- return {
906
- name,
907
- verdict: "FAIL",
908
- reason: `Maestro smoke failed (flow cites the SHELL spec clauses it proves):\n${res.out.split("\n").slice(-15).join("\n")}`,
909
- durationMs: priorDurationMs + res.durationMs,
910
- };
955
+ // Bounded on its own, not only by the step deadline: a FIRST run has no
956
+ // journal, so its deadline is the 30-minute ceiling — and on 2026-09-03
957
+ // Maestro selected the device and then sat, driver never started, app
958
+ // never in the foreground, under another lane's build load. Two
959
+ // minutes of driver start-up plus three per flow is generous for a
960
+ // healthy device; past it the run is killed and the row reads ERROR
961
+ // (StepTimeout), which is what "did not get to check" should say.
962
+ const runBoundMs = E2E_RUN_BOUND_MS(flows.length);
963
+ const res = sh(`maestro test ${E2E_FLOW_DIR} --format junit --output "${report}"`, { env: { ...process.env, MAESTRO_DRIVER_STARTUP_TIMEOUT: "120000" }, timeout: runBoundMs });
964
+ let xml = null;
965
+ try {
966
+ xml = fs.readFileSync(report, "utf8");
967
+ } catch {
968
+ xml = null;
969
+ }
970
+ const outcome = maestroOutcome(res, parseMaestroJunit(xml), flows);
971
+ if (outcome.verdict !== "PASS") {
972
+ return { name, verdict: outcome.verdict, reason: outcome.reason, durationMs: priorDurationMs + res.durationMs, details: outcome.details };
911
973
  }
974
+ // The post-run sweep is scoped to THIS app's process(es): the flows' own
975
+ // appId lines say which. Another app misbehaving on a shared emulator is
976
+ // not this lane's red (deviceLogIncidents).
977
+ const appIds = [...new Set(flows.map((rel) => (fs.readFileSync(path.join(ROOT, rel), "utf8").match(/^appId:\s*(\S+)/m) || [])[1]).filter(Boolean))];
912
978
  const anrDump = sh("adb logcat -d -b system,crash,main");
913
- const anrRe = /ANR in |FATAL EXCEPTION/i;
914
- if (anrDump.ok && anrRe.test(anrDump.out)) {
915
- const anrLines = anrDump.out.split("\n").filter((l) => anrRe.test(l)).slice(0, 10).join("\n");
979
+ const incidents = anrDump.ok ? deviceLogIncidents(anrDump.out, appIds) : { lines: [], scoped: appIds.length > 0 };
980
+ if (incidents.lines.length) {
916
981
  return {
917
982
  name,
918
983
  verdict: "FAIL",
919
- reason: `Maestro smoke passed, but the device log shows an ANR/crash during the run (hide_error_dialogs only suppresses the OS dialog, never the underlying event):\n${anrLines}`,
984
+ reason: `Maestro flows passed, but the device log shows an ANR/crash in ${incidents.scoped ? appIds.join(", ") : "some process (no appId known to scope by)"} during the run (hide_error_dialogs only suppresses the OS dialog, never the underlying event):\n${incidents.lines.slice(0, 10).join("\n")}`,
920
985
  durationMs: priorDurationMs + res.durationMs,
986
+ details: outcome.details,
921
987
  };
922
988
  }
923
- return { name, verdict: "PASS", durationMs: priorDurationMs + res.durationMs };
989
+ return { name, verdict: "PASS", durationMs: priorDurationMs + res.durationMs, note: `${flows.length} flow${flows.length === 1 ? "" : "s"}`, details: outcome.details, ...(outcome.reason ? { reason: outcome.reason } : {}) };
924
990
  } finally {
925
991
  if (prevHideErrorDialogs && prevHideErrorDialogs !== "null") {
926
992
  sh(`adb shell settings put global hide_error_dialogs ${prevHideErrorDialogs}`);
@@ -966,18 +1032,13 @@ function stepAndroidChecks() {
966
1032
  return {
967
1033
  name: "androidChecks",
968
1034
  verdict: "SKIP",
1035
+ skipKind: "structure",
969
1036
  reason: "no instrumented tests (composeApp/src/androidInstrumentedTest has no Kotlin sources)",
970
1037
  durationMs: Date.now() - started,
971
1038
  };
972
1039
  }
973
- if (!deviceAttached()) {
974
- return {
975
- name: "androidChecks",
976
- verdict: "SKIP",
977
- reason: "no Android device/emulator attached (adb) — instrumented behavior needs the real process boundary",
978
- durationMs: Date.now() - started,
979
- };
980
- }
1040
+ const device = ensureLaneDevice("androidChecks");
1041
+ if (device) return { ...device, durationMs: Date.now() - started };
981
1042
  // Machine-global lease before the first device touch (contention = SKIP).
982
1043
  const leaseSkip = leaseDeviceForStep("androidChecks");
983
1044
  if (leaseSkip) return { ...leaseSkip, durationMs: Date.now() - started };
@@ -1152,6 +1213,7 @@ const MEMOIZED_STEP_INPUTS = {
1152
1213
  approvals: ["qa/approvals.json", "specs", "docs/features", "docs/ARCHITECTURE.md", "composeApp/src"],
1153
1214
  componentStories: ["composeApp/src"],
1154
1215
  reachability: ["composeApp/src", "docs/features"],
1216
+ e2eCoverage: ["composeApp/src", "docs/features", "specs", "qa/e2e"],
1155
1217
  archDoc: ["docs/ARCHITECTURE.md", "docs/adr", "specs", "composeApp/src"],
1156
1218
  };
1157
1219
 
@@ -1171,12 +1233,13 @@ const stepSpecCoverageMemo = memoized("specCoverage", stepSpecCoverage);
1171
1233
  const stepApprovalsMemo = memoized("approvals", stepApprovals);
1172
1234
  const stepComponentStoriesMemo = memoized("componentStories", stepComponentStories);
1173
1235
  const stepReachabilityMemo = memoized("reachability", stepReachability);
1236
+ const stepE2eCoverageMemo = memoized("e2eCoverage", stepE2eCoverage);
1174
1237
  const stepArchDocMemo = memoized("archDoc", stepArchDoc);
1175
1238
 
1176
1239
  const stepsForProfile = {
1177
1240
  // scaffold: what `create-cmp --verify` proves at stamp time — specCoverage,
1178
1241
  // the full JVM tier (unit + conformance + golden + UI tests) plus the Android build.
1179
- scaffold: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepArchDocMemo, stepSchemaHistory, stepBuild, stepUnitTests],
1242
+ scaffold: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepE2eCoverageMemo, stepArchDocMemo, stepSchemaHistory, stepBuild, stepUnitTests],
1180
1243
  // smoke (docs/GATE-RULES.md Rule 0, docs/PRINCIPLES.md #2): the smallest
1181
1244
  // end-to-end lane — every pure-Node step through the REAL runner, marker,
1182
1245
  // receipt and journal, and NO Gradle, no device, no network. Its job is to
@@ -1185,7 +1248,7 @@ const stepsForProfile = {
1185
1248
  // fresh scaffold, then FAIL BY NAME on one planted spec edit, each bounded
1186
1249
  // in seconds. Its receipt is refused as done-evidence (qa/receipt-check.mjs)
1187
1250
  // exactly like --fast: it proves the instrument, never the change.
1188
- smoke: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepArchDocMemo, stepSchemaHistory],
1251
+ smoke: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepE2eCoverageMemo, stepArchDocMemo, stepSchemaHistory],
1189
1252
  local: [
1190
1253
  // First, always: every verdict below is only worth what the lane issuing
1191
1254
  // it is worth.
@@ -1194,6 +1257,7 @@ const stepsForProfile = {
1194
1257
  stepApprovalsMemo,
1195
1258
  stepComponentStoriesMemo,
1196
1259
  stepReachabilityMemo,
1260
+ stepE2eCoverageMemo,
1197
1261
  stepArchDocMemo,
1198
1262
  stepSchemaHistory,
1199
1263
  stepBuild,
@@ -1246,13 +1310,35 @@ stepsForProfile.release = [...stepsForProfile.ci, stepAuditCadence, stepReleaseS
1246
1310
  // nightly (evidence-economics S6 / proposal P4): the stage for proofs whose cost
1247
1311
  // scales with the SUITE rather than with the change — the determinism probe
1248
1312
  // today (forced on above; `--determinism` is implied), and the place any
1249
- // future mutation / load / chaos step lands, so the placement decision is made
1313
+ // future load / chaos step lands, so the placement decision is made
1250
1314
  // once instead of per expensive step. It proves the harness and the tree's
1251
1315
  // invariants, not a change: qa/receipt-check.mjs refuses its receipt as
1252
1316
  // done-evidence, exactly as it refuses --fast. Same step set as ci on purpose —
1253
1317
  // what differs is what is forced, and what the receipt is allowed to mean.
1254
1318
  stepsForProfile.nightly = [...stepsForProfile.ci];
1255
1319
 
1320
+ // Which layer of the stack each step proves — stamped onto the receipt row by
1321
+ // the runner (lane-runner.mjs) so the Evidence pane can group by it and a
1322
+ // multi-pack lane (a Compose app over a Kotlin backend) reads as one lane
1323
+ // with per-layer tallies. Three layers for this pack: `spine` — the harness
1324
+ // proving itself and the governed record (integrity, spec coverage,
1325
+ // approvals, the architecture doc, the schema history, the audit cadence);
1326
+ // `compose` — the JVM tier of the app (build, tests, conformance, goldens,
1327
+ // a11y, release compile); `device` — anything that needs an emulator or a
1328
+ // physical device. Layer names are free-form strings on the wire; these are
1329
+ // this pack's. Derived by NAME after the lists are built so a step listed in
1330
+ // two profiles is tagged once, and a step nobody listed is never tagged.
1331
+ const SPINE_STEP_NAMES = new Set(["harnessIntegrity", "specCoverage", "approvals", "componentStories", "reachability", "e2eCoverage", "archDoc", "schemaHistory", "auditCadence", "determinism"]);
1332
+ function layerForStep(name) {
1333
+ if (DEVICE_STEPS.includes(name)) return "device";
1334
+ if (SPINE_STEP_NAMES.has(name)) return "spine";
1335
+ return "compose";
1336
+ }
1337
+ for (const fn of new Set(Object.values(stepsForProfile).flat())) {
1338
+ const name = stepDisplayName(fn);
1339
+ if (name) fn.layer = layerForStep(name);
1340
+ }
1341
+
1256
1342
  const FAST_EXCLUDED_NAMES = [...DEVICE_STEPS, "releaseBuild"];
1257
1343
  const STEP_FN_BY_NAME = {
1258
1344
  e2eSmoke: stepE2eSmoke,
@@ -1275,10 +1361,18 @@ for (const name of FAST_EXCLUDED_NAMES) {
1275
1361
  FAST_EXCLUDED_NAMES,
1276
1362
  STEP_FN_BY_NAME,
1277
1363
  stepDeterminism,
1364
+ // The ladder this pack's steps can earn (evidence-level.mjs). A pack that
1365
+ // returns none earns no rung — the spine never grades a pack by another
1366
+ // pack's step names.
1367
+ evidenceLadder: CMP_LADDER,
1278
1368
  // The device lease is held to the very end of the run (see the scope
1279
1369
  // decision above); the spine releases it in the runner's finally.
1280
1370
  releaseLease: () => {
1281
1371
  if (laneDeviceLease) releaseDeviceLease(laneDeviceLease);
1372
+ // The emulator this lane booted goes down with the lane; an attached
1373
+ // device is never touched (device-provider.mjs).
1374
+ const down = releaseDevice(laneDevice, { sh });
1375
+ if (down.shutdown) console.error(`· shut down ${laneDevice.avd} (${laneDevice.serial})`);
1282
1376
  },
1283
1377
  };
1284
1378
  }
@@ -108,6 +108,31 @@ function evaluate() {
108
108
  profile: receipt.profile,
109
109
  };
110
110
  }
111
+ // The device tier must have RUN (2026-09-03). The lane boots a headless
112
+ // emulator itself, so e2eSmoke/androidChecks only SKIP for two kinds of
113
+ // reason: the project's own structure (no qa/e2e harness, no instrumented
114
+ // sources — honest, allowed) or the ENVIRONMENT (CMP_DEVICE=none, maestro
115
+ // not installed, a lease held elsewhere, an ambiguous serial). The second
116
+ // kind is a gap a human can close, and a change is not done while it stands.
117
+ // Receipts predating `skipKind` are read by their reason text.
118
+ const DEVICE_TIER = ["e2eSmoke", "androidChecks"];
119
+ const envSkipped = (Array.isArray(receipt.steps) ? receipt.steps : []).filter(
120
+ (s) =>
121
+ s &&
122
+ DEVICE_TIER.includes(s.name) &&
123
+ s.verdict === "SKIP" &&
124
+ (s.skipKind === "environment" ||
125
+ (!s.skipKind && /no Android device|maestro CLI not installed|is held by|devices attached|CMP_DEVICE=none/.test(String(s.reason ?? "")))),
126
+ );
127
+ if (envSkipped.length) {
128
+ return {
129
+ valid: false,
130
+ reason:
131
+ `the device tier did not run — ${envSkipped.map((s) => `${s.name}: ${String(s.reason ?? "").split("\n")[0]}`).join("; ")}. ` +
132
+ "The lane boots a headless emulator itself (set CMP_AVD if it cannot choose one); fix the cause and run `node qa/verify.mjs` again before finishing",
133
+ profile: receipt.profile,
134
+ };
135
+ }
111
136
  // A surface this project cannot resolve is a REFUSAL with an explanation,
112
137
  // never an unhandled stack trace: this runs as the Stop hook on every turn
113
138
  // end, and a crash there reads as a broken harness rather than as the
@@ -315,6 +315,55 @@ const ALL_FILES = [
315
315
  { from: path.join(ROOT, `specs/${SOURCE_f}.spec.md`), to: path.join(ROOT, `specs/${f}.spec.md`), isDefaultSpec: true, presets: ["feature", "screen"] },
316
316
  ];
317
317
 
318
+ // The feature's Maestro flow (2026-09-03): every feature with a screen gets
319
+ // qa/e2e/<feature>.yaml, because the lane now runs EVERY flow in that
320
+ // directory and a feature with no flow has no device-tier journey by
321
+ // construction. It is a SKELETON that passes — launch + shell — with the
322
+ // feature's screen id and its spec clauses named as the work to do; it cites
323
+ // nothing (a citation it has not earned would read as coverage). Demand comes
324
+ // from the spec: a clause marked `[tier: e2e]` fails specCoverage until a
325
+ // flow cites them. Written only when the project carries the e2e harness.
326
+ const E2E_FLOW_PATH = path.join(ROOT, "qa", "e2e", `${f}.yaml`);
327
+ const WRITE_E2E_FLOW = ["feature", "screen"].includes(preset) && fs.existsSync(path.join(ROOT, "qa", "e2e")) && !fs.existsSync(E2E_FLOW_PATH);
328
+ function e2eFlowSkeleton() {
329
+ const appId = (() => {
330
+ try {
331
+ const cfg = JSON.parse(fs.readFileSync(path.join(ROOT, "create-cmp.json"), "utf8"));
332
+ if (typeof cfg.package === "string" && cfg.package) return cfg.package;
333
+ } catch {
334
+ /* fall through to the smoke flow's own appId */
335
+ }
336
+ try {
337
+ const m = fs.readFileSync(path.join(ROOT, "qa", "e2e", "smoke.yaml"), "utf8").match(/^appId:\s*(\S+)/m);
338
+ if (m) return m[1];
339
+ } catch {
340
+ /* no smoke flow */
341
+ }
342
+ return "__PACKAGE__";
343
+ })();
344
+ return `# E2E flow — ${f}. Maestro. The lane runs every flow in qa/e2e/ (e2eSmoke).
345
+ #
346
+ # SKELETON stamped by qa/scaffold-feature.mjs: it launches the app and proves the
347
+ # shell, then stops. Make it the ${f} journey:
348
+ # 1. navigate to the screen (its title carries testTag "${f}_title");
349
+ # 2. assert the behaviour the clauses in specs/${f}.spec.md promise — one
350
+ # "# SPEC: ${F_UPPER}-NN" line above the steps that prove each clause;
351
+ # 3. mark clauses only a device can observe "[tier: e2e]" in the spec — until a
352
+ # flow cites them, specCoverage FAILs by name, which is the point.
353
+ # Selectors by testTag (id:), never display text. After any interaction that
354
+ # triggers async state, assert with extendedWaitUntil (see smoke.yaml's SETTLE RULE).
355
+ appId: ${appId}
356
+ ---
357
+ - launchApp:
358
+ clearState: true
359
+ - extendedWaitUntil:
360
+ visible:
361
+ id: "app_bottom_nav"
362
+ timeout: 60000
363
+ # TODO(${f}): navigate to ${f} and assertVisible id: "${f}_title", then the ${F_UPPER}-NN journeys.
364
+ `;
365
+ }
366
+
318
367
  const FILES = ALL_FILES.filter((file) => file.presets.includes(preset));
319
368
 
320
369
  // Golden baseline: NOT copied (a feature's golden tree is captured fresh via
@@ -689,6 +738,12 @@ for (const file of FILES) {
689
738
  fs.writeFileSync(file.to, contents);
690
739
  filesWritten += 1;
691
740
  }
741
+ if (WRITE_E2E_FLOW) {
742
+ fs.mkdirSync(path.dirname(E2E_FLOW_PATH), { recursive: true });
743
+ fs.writeFileSync(E2E_FLOW_PATH, e2eFlowSkeleton());
744
+ console.log(` wrote ${path.relative(ROOT, E2E_FLOW_PATH)} — the feature's Maestro flow (skeleton; the lane runs it)`);
745
+ console.log(` NOTE: the lane's e2eCoverage gate FAILs for ${f} until that flow is the journey and cites a ${F_UPPER}-NN clause it proves — that is the point.`);
746
+ }
692
747
 
693
748
  let injectionsApplied = 0;
694
749
  for (const result of fileResults) {
@@ -31,7 +31,7 @@ import fs from "node:fs";
31
31
  import path from "node:path";
32
32
  import { fileURLToPath } from "node:url";
33
33
 
34
- import { computeInputsHash } from "./lib/inputs-hash.mjs";
34
+ import { computeInputsHash, undeclaredTopLevel } from "./lib/inputs-hash.mjs";
35
35
  import { evidenceLevel } from "./lib/evidence-level.mjs";
36
36
  import { updateReadmeBadge, README_REL_PATH } from "./lib/evidence-badge.mjs";
37
37
  import { appendFlightRecord, buildFlightEntry, neverRunTiers, readFlightJournal } from "./lib/flight-recorder.mjs";
@@ -450,7 +450,10 @@ const strengthLabel = onDeviceSteps.length ? `on-device: ${onDeviceSteps.join("+
450
450
  // fine print; the rung is added alongside, never in place of it. null on FAIL —
451
451
  // a failed lane has no rung. null on a --fast run too: the inner loop is a
452
452
  // signal, never evidence, so a fast receipt derives NO rung at all.
453
- const level = evidenceLevel(steps, profile, { mode });
453
+ // The ladder is the PACK's: a pack that declares none earns no rung (a
454
+ // backend graded by Compose step names was L0 by construction — wrong, not
455
+ // conservative).
456
+ const level = evidenceLevel(steps, profile, { mode, ladder: pack.evidenceLadder ?? null });
454
457
 
455
458
  // Artifacts: hash whatever the run left under qa-artifacts/ (never committed).
456
459
  const artifacts = [];
@@ -494,6 +497,15 @@ function harnessForReceipt() {
494
497
  }
495
498
 
496
499
  const inputs = computeInputsHash(ROOT);
500
+ // What the surface does NOT cover, at the top level. A surface is an allowlist,
501
+ // and a new top-level directory is simply unmatched: no error, silently
502
+ // unattested (payment-blueprint's finding, 2026-09-03). This is a REPORT on the
503
+ // receipt, never a gate — the Compose default deliberately leaves docs/, the
504
+ // README and the wrapper out — so a reader can see the gap and decide.
505
+ const undeclared = undeclaredTopLevel(ROOT);
506
+ if (undeclared.length) {
507
+ console.log(` ⓘ inputs: ${undeclared.length} top-level entr${undeclared.length === 1 ? "y is" : "ies are"} outside the verified surface (unattested): ${undeclared.join(", ")}`);
508
+ }
497
509
 
498
510
  // The receipt. Deterministic key order; ONE volatile timestamp field.
499
511
  // commit.sha is the parent HEAD at run time (you cannot know the sha of the
@@ -520,6 +532,10 @@ const receipt = {
520
532
  inputs: {
521
533
  hash: inputs.hash,
522
534
  fileCount: inputs.fileCount,
535
+ // Top-level entries the surface leaves unattested (see above). Absent when
536
+ // there are none, so a receipt whose surface covers everything keeps its
537
+ // exact prior shape.
538
+ ...(undeclared.length ? { undeclared } : {}),
523
539
  },
524
540
  steps,
525
541
  // WHICH LANE issued this verdict. A receipt that cannot name its own harness
@@ -232,6 +232,33 @@ function resolveSurfaceFiles(root, VERIFIED_SURFACE) {
232
232
  return collected.filter((relPath) => !isExcluded(relPath));
233
233
  }
234
234
 
235
+ /**
236
+ * Top-level entries (first path segment of every file git would commit) that
237
+ * NO surface entry covers — the files the receipt does not attest. An
238
+ * allowlist is silent about what it omits: a new top-level directory is simply
239
+ * unmatched, no error, unattested (payment-blueprint, 2026-09-03). This names
240
+ * the omission so the receipt can carry it and a reader can decide whether
241
+ * it belongs in qa/verified-surface.json. Sorted; [] when git is unavailable
242
+ * (the walk fallback has no notion of "what git sees") or everything is
243
+ * covered. Lane outputs (EXCLUDED_PREFIXES) are not "undeclared" — they are
244
+ * excluded by decision.
245
+ * @param {string} root
246
+ * @param {string[]} [surface] defaults to resolveVerifiedSurface(root)
247
+ * @returns {string[]}
248
+ */
249
+ export function undeclaredTopLevel(root, surface = resolveVerifiedSurface(root)) {
250
+ const gitFiles = tryGitLsFiles(root);
251
+ if (!gitFiles) return [];
252
+ const covered = (relPath) => surface.some((entry) => relPath === entry || relPath.startsWith(`${entry}/`)) || isExcluded(relPath);
253
+ const out = new Set();
254
+ for (const raw of gitFiles) {
255
+ const relPath = raw.split(path.sep).join("/");
256
+ if (covered(relPath)) continue;
257
+ out.add(relPath.includes("/") ? relPath.slice(0, relPath.indexOf("/")) : relPath);
258
+ }
259
+ return [...out].sort();
260
+ }
261
+
235
262
  /**
236
263
  * Compute the sha256 hash of the verified surface for the project rooted at `root`.
237
264
  * Deterministic: same tree (same file paths + same file bytes) → same hash.
@@ -41,7 +41,7 @@ import { flagBool } from "../lib/args.mjs";
41
41
  import { colors, ok, warn, fail, step } from "../lib/log.mjs";
42
42
  import { consent } from "../bootstrap/exec.mjs";
43
43
  import { loadRegistry, latestSet, getSet } from "../lib/registry.mjs";
44
- import { planUpgrade, BACKUP_SUFFIX } from "../lib/upgrade.mjs";
44
+ import { planUpgrade, BACKUP_SUFFIX, sidecarDroppedLines, staleBackupPaths } from "../lib/upgrade.mjs";
45
45
  import { writeHarnessLock, checkHarnessIntegrity, describeIntegrity } from "../../packages/harness/src/lib/harness-lock.mjs";
46
46
  import { LOCAL_PATCH_PATH, stampBaseWith } from "../lib/harness-upgrade.mjs";
47
47
  import { buildTokenMap } from "../lib/tokens.mjs";
@@ -383,11 +383,25 @@ async function harnessPlanAndApply({ flags, record, projectDir, targetDir, tmpRo
383
383
  const actionable = plan.entries.filter(
384
384
  (e) => e.write !== null || e.sidecar !== null || e.remove
385
385
  );
386
+ removeStaleBackups(projectDir);
386
387
  const result = applyHarnessPlan(projectDir, actionable);
387
388
  for (const f of result.written) ok(`wrote ${f} ${colors.dim(`(backup: ${f}${BACKUP_SUFFIX})`)}`);
388
389
  for (const f of result.created) ok(`created ${f}`);
389
390
  for (const f of result.deleted) ok(`deleted ${f} ${colors.dim(`(backup: ${f}${BACKUP_SUFFIX})`)}`);
390
- for (const f of result.sidecars) warn(`conflict sidecar ${f} — resolve by hand, then delete it`);
391
+ for (const f of result.sidecars) {
392
+ warn(`conflict sidecar ${f} — resolve by hand, then delete it`);
393
+ // Name what taking the sidecar would DROP, so the obvious resolution is
394
+ // never a silent loss (the showcase's signing-key ignores, three upgrades running).
395
+ let dropped = [];
396
+ try {
397
+ dropped = sidecarDroppedLines(fs.readFileSync(path.join(projectDir, f), "utf8"), fs.readFileSync(path.join(projectDir, f + SIDECAR_SUFFIX), "utf8"));
398
+ } catch {
399
+ dropped = [];
400
+ }
401
+ if (dropped.length) {
402
+ warn(` the sidecar lacks ${dropped.length} line${dropped.length === 1 ? "" : "s"} your ${f} has — taking it as-is would drop: ${dropped.slice(0, 4).join(" | ")}${dropped.length > 4 ? " | …" : ""}`);
403
+ }
404
+ }
391
405
 
392
406
  // ── Re-lock the lane ──────────────────────────────────────────────────────
393
407
  // The machine-owned region always lands on the new engine's content, whether
@@ -576,6 +590,7 @@ export async function runUpgrade(flags, positional) {
576
590
  { path: wrapperPropsPath, content: plan.newWrapperPropertiesContent },
577
591
  { path: buildGradlePath, content: plan.newBuildGradleContent },
578
592
  ];
593
+ removeStaleBackups(projectDir);
579
594
  for (const w of writes) {
580
595
  if (w.content === null) continue;
581
596
  fs.copyFileSync(w.path, w.path + BACKUP_SUFFIX);
@@ -602,3 +617,17 @@ export async function runUpgrade(flags, positional) {
602
617
  );
603
618
  process.exit(0);
604
619
  }
620
+
621
+
622
+ /** Backups an EARLIER upgrade left (gitignored *.bak-upgrade) go before this one writes its own. */
623
+ function removeStaleBackups(projectDir) {
624
+ const stale = staleBackupPaths(projectDir);
625
+ for (const rel of stale) {
626
+ try {
627
+ fs.rmSync(path.join(projectDir, rel), { force: true });
628
+ } catch {
629
+ /* a backup that cannot be removed is left, and named below either way */
630
+ }
631
+ }
632
+ if (stale.length) ok(`removed ${stale.length} stale *${BACKUP_SUFFIX} file${stale.length === 1 ? "" : "s"} from earlier upgrades`);
633
+ }
package/src/lib/tabs.mjs CHANGED
@@ -155,10 +155,25 @@ ${entries}
155
155
  * the static template file for the default tabs.
156
156
  * @param {ReturnType<typeof tabInfos>} infos
157
157
  */
158
+ const HOME_ITEM_BLOCK = `
159
+
160
+ # SPEC: HOME-02 — when loading completes, the items are listed: the first item's row
161
+ # is on screen (the exemplar's device journey; the lane's e2eCoverage gate asks every
162
+ # feature with a screen and a spec for at least one clause proven by a flow).
163
+ - extendedWaitUntil:
164
+ visible:
165
+ id: "home_item_1"
166
+ timeout: 30000`;
167
+
158
168
  export function renderSmokeYaml(infos) {
159
169
  const [first, ...rest] = infos;
160
170
  const lines = [];
161
- lines.push(`# E2E smoke Maestro flow. SPEC: SHELL-01, SHELL-02.
171
+ // The exemplar tab (`home`, HomeScreen over the stub repository) carries the
172
+ // one device-proven HOME clause a fresh scaffold needs to pass e2eCoverage:
173
+ // after loading, the first item's row is on screen. Emitted only for that
174
+ // tab — a placeholder tab has no clauses to prove.
175
+ const homeBlock = (tab) => (tab.slug === "home" ? HOME_ITEM_BLOCK : "");
176
+ lines.push(`# E2E smoke — Maestro flow. SPEC: SHELL-01, SHELL-02${infos.some((t) => t.slug === "home") ? ", HOME-02" : ""}.
162
177
  #
163
178
  # Proves the real app boots on a device/emulator and the bottom-nav shell works.
164
179
  # Selectors go by testTag (surfaced as resource-ids on Android via TestTagAutomation),
@@ -188,7 +203,7 @@ appId: __PACKAGE__
188
203
  id: "${first.slug}_title"
189
204
  timeout: 60000
190
205
  - assertVisible:
191
- id: "app_bottom_nav"`);
206
+ id: "app_bottom_nav"${homeBlock(first)}`);
192
207
 
193
208
  rest.forEach((tab, i) => {
194
209
  lines.push("");
@@ -198,7 +213,7 @@ appId: __PACKAGE__
198
213
  - assertVisible:
199
214
  id: "${tab.slug}_title"
200
215
  - assertVisible:
201
- id: "app_bottom_nav"`);
216
+ id: "app_bottom_nav"${homeBlock(tab)}`);
202
217
  });
203
218
 
204
219
  if (rest.length > 0) {