create-cmp-cli 0.11.0 → 0.13.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 (51) hide show
  1. package/README.md +11 -9
  2. package/bin/create-cmp.mjs +3 -0
  3. package/package.json +1 -1
  4. package/src/commands/upgrade.mjs +287 -0
  5. package/src/lib/harness-upgrade.mjs +364 -0
  6. package/src/lib/package-name.mjs +72 -0
  7. package/src/scaffold.mjs +7 -2
  8. package/template/.claude/settings.json +30 -0
  9. package/template/CLAUDE.md +51 -6
  10. package/template/README.md +4 -0
  11. package/template/composeApp/build.gradle.kts +44 -0
  12. package/template/composeApp/src/androidDebug/AndroidManifest.xml +9 -0
  13. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/PlatformBehaviorSeamTest.kt +277 -0
  14. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/RuntimeStateSeamTest.kt +308 -0
  15. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/AlarmAsserts.kt +152 -0
  16. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ConfigControl.kt +124 -0
  17. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/DozeControl.kt +113 -0
  18. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NetworkControl.kt +137 -0
  19. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NotificationAsserts.kt +163 -0
  20. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/PermissionControl.kt +132 -0
  21. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ProcessControl.kt +217 -0
  22. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/Shell.kt +79 -0
  23. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/SystemState.kt +113 -0
  24. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt +114 -0
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +5 -2
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +1 -1
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +1 -1
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +1 -1
  29. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +58 -0
  30. package/template/docs/ARCHITECTURE.md +41 -2
  31. package/template/docs/TESTING.md +165 -0
  32. package/template/gradle/libs.versions.toml +15 -0
  33. package/template/manifest.json +1 -0
  34. package/template/qa/evidence/schema.json +20 -2
  35. package/template/qa/lib/affected-tests.mjs +147 -0
  36. package/template/qa/lib/audit-cadence.mjs +290 -0
  37. package/template/qa/lib/determinism.mjs +179 -0
  38. package/template/qa/lib/device-lease.mjs +249 -0
  39. package/template/qa/lib/evidence-badge.mjs +158 -0
  40. package/template/qa/lib/evidence-level.mjs +117 -0
  41. package/template/qa/lib/flight-recorder.mjs +332 -0
  42. package/template/qa/lib/inputs-hash.mjs +16 -1
  43. package/template/qa/lib/spec-coverage.mjs +54 -3
  44. package/template/qa/lib/step-cache.mjs +221 -0
  45. package/template/qa/receipt-check.mjs +22 -2
  46. package/template/qa/record-audit.mjs +83 -0
  47. package/template/qa/retrospective.mjs +51 -0
  48. package/template/qa/scaffold-feature.mjs +20 -1
  49. package/template/qa/verify.mjs +934 -57
  50. package/template/qa/watch.mjs +622 -0
  51. package/template/specs/app-base.spec.md +11 -0
@@ -0,0 +1,249 @@
1
+ // The machine-global device lease — mutual exclusion for the ONE Android
2
+ // device/emulator a machine typically has.
3
+ //
4
+ // WHY THIS EXISTS: the lane marker (composeApp/build/.cmp-lane-in-progress) is
5
+ // per-PROJECT, but the device is machine-GLOBAL. A scratch app stamped in /tmp
6
+ // and the real app are different roots sharing one emulator: each stamps its
7
+ // own marker, neither sees the other, and two concurrent device drivers produce
8
+ // exactly the observed failure class — wedged adbd, `device offline` while
9
+ // `adb devices` looks fine, crossed app state between sessions, false-red runs.
10
+ // The lease is keyed by the DEVICE (its adb serial), not the project, so the
11
+ // primitive finally matches the scarce resource it protects.
12
+ //
13
+ // ── ON-DISK CONTRACT ────────────────────────────────────────────────────────
14
+ // This exact contract is implemented independently by the create-cmp
15
+ // inspector MCP (inspector/mcp/src/lib/device-lease.mjs in the create-cmp
16
+ // repo — a check-only reader for connect_live / navigate_and_inspect). The two
17
+ // codebases ship separately and cannot import each other, so the contract
18
+ // lives verbatim in BOTH file headers, each pointing at the other. Changing
19
+ // anything below means changing it there too.
20
+ //
21
+ // Location <os.tmpdir()>/create-cmp/device-leases/<sanitized-serial>.json
22
+ // tmpdir on purpose: a lease must never survive a reboot.
23
+ // Sanitizing serial chars outside [A-Za-z0-9._-] become "_"
24
+ // ("emulator-5554" → emulator-5554.json,
25
+ // "192.168.1.5:5555" → 192.168.1.5_5555.json).
26
+ // Shape { "pid": number, "holder": string, "root": string,
27
+ // "serial": string, "acquiredAt": ISO-8601 string }
28
+ // `holder` is a human/agent-readable label naming WHO is driving
29
+ // ("verify lane e2eSmoke", "connect_live", "fleet-check scratch
30
+ // lane"); `root` is the holder's project root.
31
+ // Staleness a lease is DEAD (silently reclaimable) when EITHER
32
+ // - its pid is not alive — process.kill(pid, 0) throws ESRCH.
33
+ // EPERM means the process EXISTS under another user: ALIVE.
34
+ // - OR acquiredAt is older than MAX_LEASE_AGE_MS.
35
+ // An unparseable lease file (torn write from a crashed holder)
36
+ // counts as dead. Readers treat dead as free; only acquirers
37
+ // delete/overwrite.
38
+ // Writes atomic — temp file in the same directory + rename. After the
39
+ // rename the acquirer re-reads and confirms its OWN pid is in
40
+ // the file: two simultaneous renames resolve last-writer-wins,
41
+ // and the loser reports contention instead of believing it holds
42
+ // the device.
43
+ // ────────────────────────────────────────────────────────────────────────────
44
+ //
45
+ // Dependency-free Node. Every function takes an optional { dir } override so
46
+ // tests exercise real files in a temp dir without touching the machine's
47
+ // actual leases, and an optional { killImpl } so the EPERM-means-alive branch
48
+ // is testable without a foreign-user process.
49
+
50
+ import crypto from "node:crypto";
51
+ import fs from "node:fs";
52
+ import os from "node:os";
53
+ import path from "node:path";
54
+
55
+ // 30 minutes: the longest legitimate single holder is a full release-profile
56
+ // device phase on a cold emulator (installDebug + Maestro smoke +
57
+ // connectedDebugAndroidTest + installRelease + release smoke), observed in the
58
+ // low tens of minutes — a live holder is protected by the pid check anyway, so
59
+ // this cap only decides how long a crashed holder whose pid was RECYCLED by an
60
+ // unrelated long-lived process can wedge the device. 30 min bounds that to
61
+ // roughly one lane-length: long enough never to reclaim under a healthy run,
62
+ // short enough that the machine heals itself within the hour.
63
+ export const MAX_LEASE_AGE_MS = 30 * 60 * 1000;
64
+
65
+ /** Serial → safe file stem: anything outside [A-Za-z0-9._-] becomes "_". */
66
+ export function sanitizeSerial(serial) {
67
+ return String(serial).replace(/[^A-Za-z0-9._-]/g, "_");
68
+ }
69
+
70
+ /** The machine-global lease directory (override with { dir } in tests only). */
71
+ export function leaseDir(dir) {
72
+ return dir || path.join(os.tmpdir(), "create-cmp", "device-leases");
73
+ }
74
+
75
+ /** Absolute path of the lease file for one serial. */
76
+ export function leasePath(serial, { dir } = {}) {
77
+ return path.join(leaseDir(dir), `${sanitizeSerial(serial)}.json`);
78
+ }
79
+
80
+ /**
81
+ * Is a pid alive? ESRCH = no such process → dead. EPERM = the process exists
82
+ * but belongs to another user → ALIVE (killing rights are not liveness).
83
+ * Any other error is treated as alive — when in doubt, never steal a lease.
84
+ */
85
+ export function pidAlive(pid, { killImpl = process.kill.bind(process) } = {}) {
86
+ if (!Number.isInteger(pid) || pid <= 0) return false;
87
+ try {
88
+ killImpl(pid, 0);
89
+ return true;
90
+ } catch (err) {
91
+ return !(err && err.code === "ESRCH");
92
+ }
93
+ }
94
+
95
+ function readLeaseFile(file) {
96
+ try {
97
+ return JSON.parse(fs.readFileSync(file, "utf8"));
98
+ } catch {
99
+ return null; // missing OR unparseable — both mean "no live lease here"
100
+ }
101
+ }
102
+
103
+ function describeLease(lease, nowMs) {
104
+ const acquiredMs = Date.parse(lease.acquiredAt);
105
+ return {
106
+ holder: lease.holder ?? "unknown",
107
+ pid: lease.pid ?? null,
108
+ root: lease.root ?? null,
109
+ acquiredAt: lease.acquiredAt ?? null,
110
+ ageMs: Number.isFinite(acquiredMs) ? Math.max(0, nowMs - acquiredMs) : Number.POSITIVE_INFINITY,
111
+ };
112
+ }
113
+
114
+ function leaseDead(lease, nowMs, { killImpl } = {}) {
115
+ if (!pidAlive(lease.pid, { ...(killImpl ? { killImpl } : {}) })) return true;
116
+ const acquiredMs = Date.parse(lease.acquiredAt);
117
+ if (!Number.isFinite(acquiredMs)) return true; // no readable birth time — unverifiable, dead
118
+ return nowMs - acquiredMs >= MAX_LEASE_AGE_MS;
119
+ }
120
+
121
+ /** `"verify lane e2eSmoke" (pid 4711, /tmp/scratch-x, 2m ago)` — for reasons/errors. */
122
+ export function formatHolder(heldBy) {
123
+ if (!heldBy) return "an unknown holder";
124
+ const age =
125
+ !Number.isFinite(heldBy.ageMs) ? "age unknown"
126
+ : heldBy.ageMs < 60_000 ? `${Math.max(1, Math.round(heldBy.ageMs / 1000))}s ago`
127
+ : `${Math.round(heldBy.ageMs / 60_000)}m ago`;
128
+ return `"${heldBy.holder}" (pid ${heldBy.pid ?? "?"}, ${heldBy.root ?? "unknown root"}, ${age})`;
129
+ }
130
+
131
+ /**
132
+ * Read the live lease on a serial, if any. Dead/stale leases read as null
133
+ * (free) — reading never deletes; reclaim-by-overwrite is the acquirer's job.
134
+ *
135
+ * @returns {{holder,pid,root,acquiredAt,ageMs}|null}
136
+ */
137
+ export function readDeviceLease(serial, { dir, killImpl, now = Date.now } = {}) {
138
+ const lease = readLeaseFile(leasePath(serial, { dir }));
139
+ if (!lease) return null;
140
+ const nowMs = now();
141
+ if (leaseDead(lease, nowMs, { killImpl })) return null;
142
+ return describeLease(lease, nowMs);
143
+ }
144
+
145
+ /**
146
+ * Acquire the machine-global lease on one device serial.
147
+ *
148
+ * @param {{serial: string, holder: string, root: string, dir?: string,
149
+ * killImpl?: Function, now?: () => number}} opts
150
+ * @returns {{ok: true, handle: {file, serial, pid, acquiredAt}, reclaimed: object|null}
151
+ * | {ok: false, heldBy: {holder, pid, root, acquiredAt, ageMs}}}
152
+ * `reclaimed` names the dead lease this acquire silently replaced (a crashed
153
+ * run must never wedge the machine forever) so the acquiring run can note it
154
+ * in its own output.
155
+ */
156
+ export function acquireDeviceLease({ serial, holder, root, dir, killImpl, now = Date.now } = {}) {
157
+ if (!serial) throw new Error("acquireDeviceLease: serial is required");
158
+ const d = leaseDir(dir);
159
+ fs.mkdirSync(d, { recursive: true });
160
+ const file = leasePath(serial, { dir });
161
+ const nowMs = now();
162
+
163
+ let reclaimed = null;
164
+ const existing = readLeaseFile(file);
165
+ if (existing) {
166
+ if (!leaseDead(existing, nowMs, { killImpl })) {
167
+ return { ok: false, heldBy: describeLease(existing, nowMs) };
168
+ }
169
+ reclaimed = describeLease(existing, nowMs); // dead — reclaim silently
170
+ } else if (fs.existsSync(file)) {
171
+ reclaimed = { holder: "unreadable lease (torn write)", pid: null, root: null, acquiredAt: null, ageMs: Number.POSITIVE_INFINITY };
172
+ }
173
+
174
+ const lease = {
175
+ pid: process.pid,
176
+ holder: String(holder || "unknown"),
177
+ root: String(root || process.cwd()),
178
+ serial: String(serial),
179
+ acquiredAt: new Date(nowMs).toISOString(),
180
+ };
181
+ // Atomic claim: temp file + rename means no reader ever sees a half-written
182
+ // lease, and two simultaneous acquirers cannot interleave bytes.
183
+ const tmp = path.join(d, `.${sanitizeSerial(serial)}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`);
184
+ fs.writeFileSync(tmp, `${JSON.stringify(lease, null, 2)}\n`);
185
+ fs.renameSync(tmp, file);
186
+
187
+ // Last-writer-wins detection: both racers reached the rename; whoever's bytes
188
+ // survived owns the device. Re-read and confirm it is US — the loser reports
189
+ // contention instead of driving a device someone else holds.
190
+ const confirm = readLeaseFile(file);
191
+ if (!confirm || confirm.pid !== lease.pid || confirm.acquiredAt !== lease.acquiredAt || confirm.holder !== lease.holder) {
192
+ return {
193
+ ok: false,
194
+ heldBy: confirm
195
+ ? describeLease(confirm, now())
196
+ : { holder: "unknown (lease vanished mid-acquire)", pid: null, root: null, acquiredAt: null, ageMs: Number.POSITIVE_INFINITY },
197
+ };
198
+ }
199
+ return { ok: true, handle: { file, serial: lease.serial, pid: lease.pid, acquiredAt: lease.acquiredAt }, reclaimed };
200
+ }
201
+
202
+ /**
203
+ * Release a held lease. Idempotent and safe: missing file is fine, and a file
204
+ * that no longer carries OUR pid+acquiredAt belongs to a newer holder (they
205
+ * reclaimed us as stale, or won a race) — another holder's lease is NEVER
206
+ * deleted.
207
+ */
208
+ export function releaseDeviceLease(handle) {
209
+ if (!handle || !handle.file) return;
210
+ const current = readLeaseFile(handle.file);
211
+ if (!current) return; // already gone (or unreadable — not provably ours, leave it)
212
+ if (current.pid !== handle.pid || current.acquiredAt !== handle.acquiredAt) return; // someone else's now
213
+ try {
214
+ fs.rmSync(handle.file, { force: true });
215
+ } catch {
216
+ /* releasing is best-effort; staleness reclaim is the backstop */
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Run `fn(handle)` under the lease, releasing in a finally. Sync or async fn.
222
+ * A refused acquire is returned as-is ({ok:false, heldBy}) — the caller decides
223
+ * what contention means (the verify lane turns it into a SKIP, never a FAIL).
224
+ */
225
+ export function withDeviceLease(opts, fn) {
226
+ const res = acquireDeviceLease(opts);
227
+ if (!res.ok) return res;
228
+ let out;
229
+ try {
230
+ out = fn(res.handle);
231
+ } catch (err) {
232
+ releaseDeviceLease(res.handle);
233
+ throw err;
234
+ }
235
+ if (out && typeof out.then === "function") {
236
+ return out.then(
237
+ (value) => {
238
+ releaseDeviceLease(res.handle);
239
+ return { ok: true, result: value, reclaimed: res.reclaimed };
240
+ },
241
+ (err) => {
242
+ releaseDeviceLease(res.handle);
243
+ throw err;
244
+ },
245
+ );
246
+ }
247
+ releaseDeviceLease(res.handle);
248
+ return { ok: true, result: out, reclaimed: res.reclaimed };
249
+ }
@@ -0,0 +1,158 @@
1
+ // The README's evidence badge — the evidence ladder, rendered where a human
2
+ // actually looks (roadmap §10 item 2: "render the rung in the console and
3
+ // README badge").
4
+ //
5
+ // The console already shows the rung (rail foot, Evidence section, receipt
6
+ // timeline). The README is the surface a human meets FIRST, and the one that
7
+ // travels — into a GitHub repo page, a PR, a screenshot in a deck. That makes
8
+ // it the surface where an overclaim does the most damage, so the badge obeys
9
+ // one rule above all others:
10
+ //
11
+ // **The badge is a statement about a specific commit, never about "now".**
12
+ //
13
+ // A badge that says "L2 device" says nothing about whether the code has moved
14
+ // since. So it never renders a bare rung: it renders the rung AND the commit
15
+ // it was attested against AND the date. That sentence stays true forever — a
16
+ // reader can see at a glance whether the sha still matches what they are
17
+ // looking at. Everything else follows from the same rule: no receipt says so,
18
+ // a FAIL says so, and a --fast run (which the ladder deliberately grants no
19
+ // rung) says so rather than borrowing the last good one.
20
+ //
21
+ // Written by the lane AFTER the receipt (it is an output derived from the
22
+ // receipt, never a gate), and committed alongside it.
23
+
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+
27
+ export const README_REL_PATH = "README.md";
28
+ export const BADGE_SECTION_ID = "evidence";
29
+
30
+ const MARKER_RE = new RegExp(
31
+ `<!-- cmp:generated ${BADGE_SECTION_ID} -->\\n([\\s\\S]*?)<!-- /cmp:generated -->`
32
+ );
33
+
34
+ /** Shields.io colours, one per rung — the ladder read at a glance. */
35
+ const RUNG_COLOR = {
36
+ L0: "9E9E9E", // scaffold — grey: a green build, nothing proven about behavior
37
+ L1: "42A5F5", // desktop — blue
38
+ L2: "26A69A", // device — teal
39
+ L3: "43A047", // release — green: the strongest rung this harness can attest
40
+ };
41
+
42
+ /** shields.io escaping: `-` → `--`, `_` → `__`, space → `_`. */
43
+ function shieldEscape(s) {
44
+ return String(s).replace(/-/g, "--").replace(/_/g, "__").replace(/ /g, "_");
45
+ }
46
+
47
+ /**
48
+ * The badge body for a receipt — Markdown, no trailing newline handling (the
49
+ * caller frames it). Pure: every degraded state has its own honest rendering
50
+ * and NONE of them fall back to a rung.
51
+ *
52
+ * @param {object|null} receipt parsed qa/evidence/latest.json, or null
53
+ * @returns {string} Markdown
54
+ */
55
+ export function renderEvidenceBadge(receipt) {
56
+ const link = "https://github.com/kvdm-co-pilot/create-cmp";
57
+ const badge = (label, message, color, title) =>
58
+ `[![${title}](https://img.shields.io/badge/${shieldEscape(label)}-${shieldEscape(message)}-${color})](${link})`;
59
+
60
+ if (!receipt || typeof receipt !== "object") {
61
+ return `${badge("evidence", "none yet", "9E9E9E", "No evidence receipt")} — no verify receipt yet. Run \`node qa/verify.mjs\`.`;
62
+ }
63
+
64
+ const verdict = typeof receipt.verdict === "string" ? receipt.verdict : "?";
65
+ const mode = receipt.mode === "fast" ? "fast" : "full";
66
+ const sha = typeof receipt.commit?.sha === "string" ? receipt.commit.sha.slice(0, 7) : null;
67
+ const dirty = Array.isArray(receipt.commit?.dirty) ? receipt.commit.dirty.length : 0;
68
+ const when = typeof receipt.generatedAt === "string" ? receipt.generatedAt.slice(0, 10) : null;
69
+
70
+ // Provenance is not decoration — it is what keeps the sentence true later.
71
+ const at = sha ? ` at \`${sha}\`` : "";
72
+ const on = when ? ` on ${when}` : "";
73
+ const uncommitted =
74
+ dirty > 0
75
+ ? ` The tree had ${dirty} uncommitted file${dirty === 1 ? "" : "s"} at attestation, so this describes that run, not that commit.`
76
+ : "";
77
+
78
+ if (verdict !== "PASS") {
79
+ return `${badge("evidence", `lane ${verdict}`, "E53935", `Verify lane ${verdict}`)} — the last lane run${at}${on} did not pass. No rung is earned by a failed lane.${uncommitted}`;
80
+ }
81
+
82
+ if (mode === "fast") {
83
+ // The inner loop is a signal, never evidence. Borrowing the previous
84
+ // full run's rung here is exactly the lie the ladder exists to prevent.
85
+ return `${badge("evidence", "fast run, no rung", "9E9E9E", "Fast run — no evidence rung")} — the last run${at}${on} was \`--fast\`: the device and release tiers were skipped, so it earns no rung.${uncommitted} Run \`node qa/verify.mjs\` for evidence.`;
86
+ }
87
+
88
+ const level = receipt.evidenceLevel;
89
+ if (!level || typeof level.rung !== "string" || typeof level.name !== "string") {
90
+ return `${badge("evidence", `PASS, rung unrecorded`, "9E9E9E", "Lane PASS, no rung recorded")} — the lane passed${at}${on} but the receipt records no evidence rung.`;
91
+ }
92
+
93
+ const color = RUNG_COLOR[level.rung] || "9E9E9E";
94
+ const satisfied = Array.isArray(level.satisfiedBy) && level.satisfiedBy.length
95
+ ? ` Earned by: ${level.satisfiedBy.map((s) => `\`${s}\``).join(", ")}.`
96
+ : "";
97
+ return (
98
+ `${badge("evidence", `${level.rung} ${level.name}`, color, `Evidence ${level.rung} — ${level.name}`)}` +
99
+ ` — the verify lane passed${at}${on} at rung **${level.rung} · ${level.name}**.` +
100
+ `${satisfied}${uncommitted}` +
101
+ ` The rung describes that run; it says nothing about changes made since.`
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Read the project's receipt and rewrite README.md's `cmp:generated evidence`
107
+ * block from it. Never creates the marker — a project that removed the block
108
+ * has opted out, and that is honoured silently.
109
+ *
110
+ * NOTE the asymmetry with renderEvidenceBadge above: the RENDERER is total —
111
+ * every receipt, including a `--fast` one, has an honest rendering. The WRITER
112
+ * is selective: a fast receipt is not written to the README at all. Two
113
+ * reasons, and the second is the load-bearing one:
114
+ * 1. The badge reports EVIDENCE. A fast run produces none, so it has nothing
115
+ * to say — and overwriting a true statement about a real full-lane run
116
+ * with "no rung" loses information rather than adding honesty.
117
+ * 2. `qa/watch.mjs` runs the fast lane on every save. A writer that fired
118
+ * there would rewrite README.md on every keystroke-to-save cycle, putting
119
+ * a permanently-dirty file in the inner loop. A recorder must not disturb
120
+ * what it records.
121
+ * The badge therefore always describes the last run that could BEAR evidence,
122
+ * and says so by naming that run's commit and date.
123
+ *
124
+ * @param {string} root project root
125
+ * @returns {{changed: boolean, reason?: string}}
126
+ */
127
+ export function updateReadmeBadge(root) {
128
+ const readmePath = path.join(root, README_REL_PATH);
129
+ let readme;
130
+ try {
131
+ readme = fs.readFileSync(readmePath, "utf8");
132
+ } catch {
133
+ return { changed: false, reason: `${README_REL_PATH} not found` };
134
+ }
135
+ if (!MARKER_RE.test(readme)) {
136
+ return { changed: false, reason: `${README_REL_PATH} has no cmp:generated ${BADGE_SECTION_ID} block` };
137
+ }
138
+
139
+ let receipt = null;
140
+ try {
141
+ receipt = JSON.parse(fs.readFileSync(path.join(root, "qa", "evidence", "latest.json"), "utf8"));
142
+ } catch {
143
+ receipt = null; // no receipt / unreadable → the "none yet" rendering, never a guess
144
+ }
145
+
146
+ if (receipt && receipt.mode === "fast") {
147
+ return { changed: false, reason: "fast run — the inner loop bears no evidence, so the badge is left as it stands" };
148
+ }
149
+
150
+ const body = `${renderEvidenceBadge(receipt)}\n`;
151
+ const next = readme.replace(
152
+ MARKER_RE,
153
+ () => `<!-- cmp:generated ${BADGE_SECTION_ID} -->\n${body}<!-- /cmp:generated -->`
154
+ );
155
+ if (next === readme) return { changed: false };
156
+ fs.writeFileSync(readmePath, next);
157
+ return { changed: true };
158
+ }
@@ -0,0 +1,117 @@
1
+ // The evidence ladder — the receipt's COARSE grade, derived, never declared.
2
+ //
3
+ // Receipts already grade themselves in fine print ("PASS (desktop-only)",
4
+ // "PASS (on-device: e2eSmoke+androidChecks)"). This module names the rungs so
5
+ // every surface that shows a receipt can say the same thing in one word:
6
+ //
7
+ // L0 "scaffold" — the scaffold profile's checks passed (stamp-time green
8
+ // build: build + unit tests + the pure-Node gates).
9
+ // L1 "desktop" — full static + JVM evidence: everything L0 proves PLUS
10
+ // conformance, golden trees, a11y, and the release COMPILE
11
+ // (releaseBuild) — a green lane with no on-device step run.
12
+ // L2 "device" — L1 plus at least one on-device EXECUTION step PASSed
13
+ // (e2eSmoke, androidChecks, or the live tokenDrift tier).
14
+ // L3 "release" — L2 plus releaseSmoke PASSed (the release APK installed
15
+ // and driven on a device).
16
+ //
17
+ // HONESTY RULES — the rung must be honest to a fault, it is the vocabulary
18
+ // evidence is sold in:
19
+ // - A rung is DERIVED from which steps actually ran and PASSED. It is never
20
+ // declared: the `profile` argument is deliberately NOT part of the
21
+ // derivation — a requested profile can never buy a rung its steps did not
22
+ // earn (it is accepted so callers state what was asked for vs. earned).
23
+ // - A SKIP never upgrades. A SKIPped device step does not count toward L2;
24
+ // a SKIPped releaseSmoke (e.g. unsigned keystore) is NOT L3. The label
25
+ // can never overclaim.
26
+ // - A FAILED lane has no rung: the rung is only computed for a PASS
27
+ // verdict; the receipt of a FAIL records evidenceLevel null.
28
+ // - A FAST-MODE lane has no rung either — not even L0. `verify --fast` is
29
+ // the inner loop, a signal rather than evidence, so a fast receipt must
30
+ // never be silently reused as if it were a full-lane result: pass the
31
+ // run's mode and "fast" derives null, always.
32
+ // - The rung is COARSE by design. The per-step list (and the existing
33
+ // strength string) stays the fine print alongside it — steps that may
34
+ // SKIP for honest configuration absence (approvals unreviewed, no
35
+ // exported schemas) are visible there; only the always-run steps gate
36
+ // the desktop rungs, and only executed PASSes gate the device rungs.
37
+
38
+ /** The scaffold profile's step set (verify.mjs stepsForProfile.scaffold). */
39
+ const SCAFFOLD_CORE = [
40
+ "specCoverage",
41
+ "approvals",
42
+ "componentStories",
43
+ "reachability",
44
+ "archDoc",
45
+ "schemaHistory",
46
+ "build",
47
+ "unitTests",
48
+ ];
49
+
50
+ /** Steps every PASS must carry to claim even L0 — they run in every profile and never SKIP. */
51
+ const L0_REQUIRED = ["build", "unitTests"];
52
+
53
+ /**
54
+ * The steps that distinguish full desktop evidence (L1) from the scaffold
55
+ * checks. None of these can SKIP — they PASS or FAIL — so "PASSed" is exactly
56
+ * "ran green".
57
+ */
58
+ const L1_REQUIRED = ["releaseBuild", "conformance", "goldenTrees", "a11y"];
59
+
60
+ /** On-device EXECUTION steps — the only steps that can earn L2. */
61
+ const DEVICE_EXECUTION = ["e2eSmoke", "tokenDrift", "androidChecks"];
62
+
63
+ /** The one step that can lift L2 to L3. */
64
+ const RELEASE_EXECUTION = "releaseSmoke";
65
+
66
+ const RUNG_NAMES = { L0: "scaffold", L1: "desktop", L2: "device", L3: "release" };
67
+
68
+ /**
69
+ * Derive the receipt's evidence rung from the lane's step results.
70
+ *
71
+ * @param {Array<{name: string, verdict: string}>} stepResults the lane's steps
72
+ * as recorded on the receipt (verdict PASS | FAIL | SKIP per step)
73
+ * @param {string} [profile] the profile that was REQUESTED — recorded context
74
+ * only, never part of the derivation (see honesty rules above)
75
+ * @param {{mode?: string}} [opts] the run's mode ("full" | "fast"). "fast"
76
+ * derives null unconditionally — the inner loop earns no rung (see honesty
77
+ * rules above). Absent/other values mean full.
78
+ * @returns {{rung: "L0"|"L1"|"L2"|"L3", name: string, satisfiedBy: string[]}|null}
79
+ * null when any step FAILed (a failed lane has no rung), when the run was
80
+ * fast-mode (the inner loop is never evidence), or when even the L0 floor
81
+ * was not earned. `satisfiedBy` lists the PASSed steps the rung counts as
82
+ * its evidence, in lane order.
83
+ */
84
+ export function evidenceLevel(stepResults, profile, { mode } = {}) { // eslint-disable-line no-unused-vars
85
+ if (mode === "fast") return null; // the inner loop derives no rung — ever
86
+ const steps = Array.isArray(stepResults) ? stepResults.filter((s) => s && typeof s.name === "string") : [];
87
+ if (steps.some((s) => s.verdict === "FAIL")) return null; // a failed lane has no rung
88
+ const passed = new Set(steps.filter((s) => s.verdict === "PASS").map((s) => s.name));
89
+
90
+ if (!L0_REQUIRED.every((name) => passed.has(name))) return null; // not even a stamp-time green build
91
+
92
+ const inLaneOrder = (names) => steps.filter((s) => names.has(s.name) && passed.has(s.name)).map((s) => s.name);
93
+
94
+ let rung = "L0";
95
+ const counted = new Set(SCAFFOLD_CORE);
96
+
97
+ if (L1_REQUIRED.every((name) => passed.has(name))) {
98
+ rung = "L1";
99
+ for (const name of L1_REQUIRED) counted.add(name);
100
+
101
+ // Only an EXECUTED (PASSed) device step lifts to L2 — a SKIP never does.
102
+ const deviceRan = DEVICE_EXECUTION.some((name) => passed.has(name));
103
+ if (deviceRan) {
104
+ rung = "L2";
105
+ for (const name of DEVICE_EXECUTION) counted.add(name);
106
+
107
+ // Only a PASSed releaseSmoke lifts to L3 — a SKIP (unsigned keystore,
108
+ // no device) never does.
109
+ if (passed.has(RELEASE_EXECUTION)) {
110
+ rung = "L3";
111
+ counted.add(RELEASE_EXECUTION);
112
+ }
113
+ }
114
+ }
115
+
116
+ return { rung, name: RUNG_NAMES[rung], satisfiedBy: inLaneOrder(counted) };
117
+ }