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,332 @@
1
+ // flight-recorder.mjs — the lane's append-only journal of its own runs.
2
+ //
3
+ // The retrospective that reshaped this harness was only possible because
4
+ // session transcripts happened to exist. This module removes the "happened
5
+ // to": every verify-lane run appends one JSON line — profile, mode, verdict,
6
+ // evidence rung, per-step verdicts, every SKIP reason VERBATIM, and every
7
+ // degraded-path activation — so "did this project drift from its tooling?"
8
+ // is answerable mechanically (qa/retrospective.mjs), from the repo alone.
9
+ // The SKIP reasons are the signal that matters: they are where a harness
10
+ // quietly stops being used ("no device attached" forever, "maestro not
11
+ // installed" forever) without any single run ever failing.
12
+ //
13
+ // GROUND RULES, each load-bearing:
14
+ //
15
+ // - IN-REPO ONLY, NO PHONE-HOME. That is a product constraint, not a
16
+ // preference: the journal is the app's own artifact, and nothing here
17
+ // records a machine, a hostname, or a user beyond what git itself
18
+ // already records (the commit sha).
19
+ //
20
+ // - THE RECORDER MUST NEVER BREAK THE LANE. A recorder that fails the
21
+ // thing it observes is worse than no recorder: every write is wrapped,
22
+ // every failure degrades to {ok: false, reason} for the lane to NOTE in
23
+ // its own output — never to throw, never to change a verdict.
24
+ //
25
+ // - THE JOURNAL IS COMMITTED, NOT GITIGNORED — deliberately. The console's
26
+ // Evidence timeline reconstructs history from the git log of committed
27
+ // receipts; a gitignored journal could answer nothing about the past on
28
+ // a fresh clone, which is exactly the question this file exists to
29
+ // answer. It follows qa/evidence/latest.json's precedent: a lane output
30
+ // that is committed with the change and EXCLUDED from the receipt's
31
+ // hashed input surface (qa/lib/inputs-hash.mjs), because a lane output
32
+ // inside the hash would invalidate the very receipt that produced it.
33
+ //
34
+ // - THE READER STATES ONLY WHAT THE JOURNAL RECORDED. Summaries are
35
+ // counts and dates, never extrapolation: a short journal says it is
36
+ // short, a single full run yields no "stretch" arithmetic, and nothing
37
+ // here editorializes about the developer — the journal records lane
38
+ // runs, not people.
39
+ //
40
+ // ONE DELIBERATE GAP, stated because a silent one would be a lie: qa/watch.mjs
41
+ // passes `--no-journal`, so save-triggered fast runs are never journaled. The
42
+ // rule it follows is the same one the README evidence badge follows — THE INNER
43
+ // LOOP DOES NOT WRITE TO COMMITTED FILES. A watcher that appended on every save
44
+ // would add hundreds of lines a day to a committed file, turn the app's history
45
+ // into keystroke noise, and leave a permanently-dirty tree inside the very loop
46
+ // this recorder exists to observe. What survives is every full lane and every
47
+ // deliberate fast run — which is what the retrospective's questions actually
48
+ // rest on (SKIP reasons, degraded paths, the longest stretch with no full
49
+ // lane). renderFlightReport states the gap in its own output.
50
+
51
+ import fs from "node:fs";
52
+ import path from "node:path";
53
+
54
+ export const FLIGHT_JOURNAL_REL_PATH = "qa/flight-recorder.jsonl";
55
+ export const FLIGHT_SCHEMA = "cmp-flight/1";
56
+
57
+ // Below this many entries the report carries an explicit shortness note —
58
+ // two entries are two facts, not a trend, and the report must say so rather
59
+ // than let a reader infer a pattern from a journal that cannot support one.
60
+ const SHORT_JOURNAL_FLOOR = 5;
61
+
62
+ /**
63
+ * Shape one lane run into a journal entry. Pure — verify.mjs passes what it
64
+ * already computed for the receipt, so the journal can never disagree with
65
+ * the receipt about the same run.
66
+ *
67
+ * @param {object} run
68
+ * @param {string} run.profile lane profile (scaffold | local | ci | release)
69
+ * @param {string} run.mode "full" | "fast"
70
+ * @param {string} run.verdict "PASS" | "FAIL"
71
+ * @param {{rung: string}|null} run.evidenceLevel the derived rung (or null —
72
+ * fast runs and FAILed lanes carry none, and the journal records that
73
+ * honestly rather than borrowing a rung from elsewhere)
74
+ * @param {Array<{name: string, verdict: string, reason?: string}>} run.steps
75
+ * the lane's step results, verbatim
76
+ * @param {string|null} run.sha parent HEAD at run time (null before git init)
77
+ * @param {number} run.durationMs wall time of the step loop
78
+ * @param {string[]} run.onDeviceSteps device-tier steps that actually PASSed
79
+ * (verify.mjs's own strength derivation — reused, not recomputed, so the
80
+ * two can never drift)
81
+ * @param {string[]} run.degraded degraded-path activations the lane observed
82
+ * (self-heals, fallbacks) — each a short verbatim description
83
+ * @returns {object} one journal entry (JSON-serializable)
84
+ */
85
+ export function buildFlightEntry({ profile, mode, verdict, evidenceLevel, steps, sha, durationMs, onDeviceSteps, degraded }) {
86
+ const stepList = Array.isArray(steps) ? steps.filter((s) => s && typeof s.name === "string") : [];
87
+ return {
88
+ schema: FLIGHT_SCHEMA,
89
+ at: new Date().toISOString(),
90
+ commit: sha ?? null,
91
+ profile,
92
+ mode,
93
+ verdict,
94
+ evidenceRung: evidenceLevel?.rung ?? null,
95
+ durationMs,
96
+ steps: stepList.map((s) => ({ name: s.name, verdict: s.verdict })),
97
+ // SKIP reasons verbatim — the journal's core signal (see file header).
98
+ skips: stepList.filter((s) => s.verdict === "SKIP").map((s) => ({ step: s.name, reason: s.reason ?? "" })),
99
+ deviceSteps: Array.isArray(onDeviceSteps) ? onDeviceSteps : [],
100
+ degraded: Array.isArray(degraded) ? degraded : [],
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Append one entry to the journal. NEVER throws — a recorder that breaks the
106
+ * lane is worse than no recorder (see ground rules). The caller is expected
107
+ * to surface a failed append in the lane's own output.
108
+ * @param {string} root project root (absolute)
109
+ * @param {object} entry a buildFlightEntry() result
110
+ * @returns {{ok: true}|{ok: false, reason: string}}
111
+ */
112
+ export function appendFlightRecord(root, entry) {
113
+ try {
114
+ const p = path.join(root, FLIGHT_JOURNAL_REL_PATH);
115
+ fs.mkdirSync(path.dirname(p), { recursive: true });
116
+ fs.appendFileSync(p, `${JSON.stringify(entry)}\n`);
117
+ return { ok: true };
118
+ } catch (err) {
119
+ return { ok: false, reason: err?.message ?? String(err) };
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Read the journal. Absent is not an error — it is the honest "no flight
125
+ * data recorded yet" state. Unparsable lines are counted, never silently
126
+ * dropped into the totals: the report must be able to say "N lines could
127
+ * not be read" instead of quietly under-counting.
128
+ * @param {string} root project root (absolute)
129
+ * @returns {{exists: boolean, entries: object[], malformed: number, error?: string}}
130
+ */
131
+ export function readFlightJournal(root) {
132
+ const p = path.join(root, FLIGHT_JOURNAL_REL_PATH);
133
+ if (!fs.existsSync(p)) return { exists: false, entries: [], malformed: 0 };
134
+ let raw;
135
+ try {
136
+ raw = fs.readFileSync(p, "utf8");
137
+ } catch (err) {
138
+ return { exists: true, entries: [], malformed: 0, error: err?.message ?? String(err) };
139
+ }
140
+ const entries = [];
141
+ let malformed = 0;
142
+ for (const line of raw.split("\n")) {
143
+ if (!line.trim()) continue;
144
+ try {
145
+ const parsed = JSON.parse(line);
146
+ if (parsed && typeof parsed === "object") entries.push(parsed);
147
+ else malformed += 1;
148
+ } catch {
149
+ malformed += 1;
150
+ }
151
+ }
152
+ return { exists: true, entries, malformed };
153
+ }
154
+
155
+ function countBy(entries, keyFn) {
156
+ const out = new Map();
157
+ for (const e of entries) {
158
+ const k = keyFn(e);
159
+ if (k === undefined || k === null) continue;
160
+ out.set(k, (out.get(k) ?? 0) + 1);
161
+ }
162
+ return out;
163
+ }
164
+
165
+ function fmtDay(iso) {
166
+ return typeof iso === "string" ? iso.slice(0, 10) : "unknown";
167
+ }
168
+
169
+ function fmtGap(ms) {
170
+ const days = Math.floor(ms / 86_400_000);
171
+ const hours = Math.round((ms % 86_400_000) / 3_600_000);
172
+ if (days > 0) return `${days}d ${hours}h`;
173
+ const mins = Math.round((ms % 3_600_000) / 60_000);
174
+ return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
175
+ }
176
+
177
+ /**
178
+ * Summarize journal entries into the facts the retrospective report prints.
179
+ * Pure arithmetic over recorded entries — no clock reads except the caller-
180
+ * supplied `now` (used only for the clearly-labeled "as of" distance to the
181
+ * last full run), no filesystem, no extrapolation.
182
+ * @param {object[]} entries parsed journal entries, in append (chronological) order
183
+ * @param {{now?: Date}} [opts]
184
+ * @returns {object} summary — see the field-by-field construction below
185
+ */
186
+ export function summarizeFlightJournal(entries, { now = new Date() } = {}) {
187
+ const runs = entries.filter((e) => e && typeof e === "object");
188
+ const byMode = countBy(runs, (e) => e.mode ?? "unknown");
189
+ const byProfile = countBy(runs, (e) => e.profile ?? "unknown");
190
+ const byVerdict = countBy(runs, (e) => e.verdict ?? "unknown");
191
+
192
+ // SKIP reasons, grouped VERBATIM — the reason string is the key on purpose:
193
+ // paraphrasing or normalizing would erase exactly the signal the journal
194
+ // exists to keep (two different reasons are two different problems).
195
+ const skipGroups = new Map();
196
+ for (const e of runs) {
197
+ for (const s of Array.isArray(e.skips) ? e.skips : []) {
198
+ // JSON-array key: reasons are arbitrary text, so a delimiter-joined
199
+ // string key would be ambiguous — and ambiguity here merges two
200
+ // different problems into one count.
201
+ const key = JSON.stringify([s.step ?? "?", s.reason ?? ""]);
202
+ skipGroups.set(key, (skipGroups.get(key) ?? 0) + 1);
203
+ }
204
+ }
205
+ const skipReasons = [...skipGroups.entries()]
206
+ .map(([key, count]) => {
207
+ const [step, reason] = JSON.parse(key);
208
+ return { step, reason, count };
209
+ })
210
+ .sort((a, b) => b.count - a.count || a.step.localeCompare(b.step));
211
+
212
+ const degradedGroups = countBy(
213
+ runs.flatMap((e) => (Array.isArray(e.degraded) ? e.degraded : [])).map((d) => ({ d })),
214
+ (x) => x.d,
215
+ );
216
+
217
+ const fullRuns = runs.filter((e) => e.mode === "full");
218
+ const deviceReached = runs.filter((e) => Array.isArray(e.deviceSteps) && e.deviceSteps.length > 0);
219
+ const rungOrder = { L0: 0, L1: 1, L2: 2, L3: 3 };
220
+ const highestRung = runs
221
+ .map((e) => e.evidenceRung)
222
+ .filter((r) => typeof r === "string" && r in rungOrder)
223
+ .sort((a, b) => rungOrder[b] - rungOrder[a])[0] ?? null;
224
+
225
+ // Longest stretch with no full lane — only computable BETWEEN two recorded
226
+ // full runs. One full run is a date, not a stretch; the report says so
227
+ // instead of inventing a gap against "now" or the journal's edges.
228
+ let longestFullGap = null;
229
+ for (let i = 1; i < fullRuns.length; i += 1) {
230
+ const a = Date.parse(fullRuns[i - 1].at);
231
+ const b = Date.parse(fullRuns[i].at);
232
+ if (Number.isNaN(a) || Number.isNaN(b)) continue;
233
+ const gap = b - a;
234
+ if (!longestFullGap || gap > longestFullGap.ms) {
235
+ longestFullGap = { ms: gap, from: fullRuns[i - 1].at, to: fullRuns[i].at };
236
+ }
237
+ }
238
+
239
+ const lastFull = fullRuns.length ? fullRuns[fullRuns.length - 1].at : null;
240
+ const lastFullAgoMs = lastFull && !Number.isNaN(Date.parse(lastFull)) ? now.getTime() - Date.parse(lastFull) : null;
241
+
242
+ return {
243
+ total: runs.length,
244
+ span: runs.length ? { from: runs[0].at, to: runs[runs.length - 1].at } : null,
245
+ short: runs.length > 0 && runs.length < SHORT_JOURNAL_FLOOR,
246
+ byMode: Object.fromEntries(byMode),
247
+ byProfile: Object.fromEntries(byProfile),
248
+ byVerdict: Object.fromEntries(byVerdict),
249
+ skipReasons,
250
+ degraded: [...degradedGroups.entries()].map(([what, count]) => ({ what, count })),
251
+ fullRuns: {
252
+ count: fullRuns.length,
253
+ last: lastFull,
254
+ lastAgoMs: lastFullAgoMs,
255
+ longestGap: longestFullGap,
256
+ },
257
+ device: { reachedRuns: deviceReached.length, highestRung },
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Render the summary as the plain-text report a human reads in ten seconds.
263
+ * Every line is a recorded fact; the honesty notes (short journal, single
264
+ * full run, malformed lines) are part of the report, not caveats around it.
265
+ * @param {object} summary a summarizeFlightJournal() result
266
+ * @param {{malformed?: number}} [opts]
267
+ * @returns {string[]} report lines
268
+ */
269
+ export function renderFlightReport(summary, { malformed = 0 } = {}) {
270
+ const lines = [];
271
+ if (summary.total === 0) {
272
+ lines.push("flight recorder: journal exists but holds no readable entries");
273
+ if (malformed > 0) lines.push(` ${malformed} line(s) could not be parsed`);
274
+ return lines;
275
+ }
276
+
277
+ lines.push(`flight recorder — ${summary.total} lane run(s) recorded (${fmtDay(summary.span.from)} → ${fmtDay(summary.span.to)})`);
278
+ if (malformed > 0) lines.push(` ${malformed} line(s) could not be parsed and are not counted`);
279
+ if (summary.short) {
280
+ lines.push(` only ${summary.total} run(s) recorded — the counts below are individual facts, not a trend`);
281
+ }
282
+
283
+ const modeBits = ["full", "fast"].map((m) => `${summary.byMode[m] ?? 0} ${m}`).join(" · ");
284
+ lines.push(`modes: ${modeBits}`);
285
+ // Disclosure, not a footnote: qa/watch.mjs passes --no-journal, so
286
+ // save-triggered fast runs are deliberately absent (a committed journal must
287
+ // not grow by hundreds of lines a day, and the inner loop must not leave the
288
+ // tree dirty). The fast count is therefore DELIBERATE fast runs only, and
289
+ // saying so here keeps the ratio from being read as a complete census.
290
+ lines.push(" (fast = deliberate runs only; qa/watch.mjs save-triggered runs are not journaled)");
291
+ lines.push(`verdicts: ${Object.entries(summary.byVerdict).map(([v, n]) => `${n} ${v}`).join(" · ")}`);
292
+ lines.push(`profiles: ${Object.entries(summary.byProfile).map(([p, n]) => `${p} ${n}`).join(" · ")}`);
293
+
294
+ if (summary.device.reachedRuns > 0) {
295
+ lines.push(
296
+ `device tier: reached in ${summary.device.reachedRuns} of ${summary.total} run(s)${summary.device.highestRung ? ` (highest evidence rung recorded: ${summary.device.highestRung})` : ""}`,
297
+ );
298
+ } else {
299
+ lines.push("device tier: never reached in any recorded run (no device-tier step ever PASSed)");
300
+ }
301
+
302
+ if (summary.fullRuns.count === 0) {
303
+ lines.push("full lane: never recorded — every recorded run was --fast (the inner loop; no run earned evidence)");
304
+ } else {
305
+ if (summary.fullRuns.longestGap) {
306
+ lines.push(
307
+ `full lane: longest recorded stretch with no full run: ${fmtGap(summary.fullRuns.longestGap.ms)} (${fmtDay(summary.fullRuns.longestGap.from)} → ${fmtDay(summary.fullRuns.longestGap.to)})`,
308
+ );
309
+ } else {
310
+ lines.push(`full lane: one full run recorded (${fmtDay(summary.fullRuns.last)}) — no stretch to measure between full runs`);
311
+ }
312
+ if (summary.fullRuns.lastAgoMs !== null) {
313
+ lines.push(` last full lane: ${fmtDay(summary.fullRuns.last)} (${fmtGap(summary.fullRuns.lastAgoMs)} before this report)`);
314
+ }
315
+ }
316
+
317
+ if (summary.skipReasons.length > 0) {
318
+ lines.push("skip reasons (verbatim, grouped):");
319
+ for (const s of summary.skipReasons) {
320
+ lines.push(` ${s.count}× [${s.step}] ${s.reason.split("\n")[0]}`);
321
+ }
322
+ } else {
323
+ lines.push("skip reasons: none recorded");
324
+ }
325
+
326
+ if (summary.degraded.length > 0) {
327
+ lines.push("degraded paths activated:");
328
+ for (const d of summary.degraded) lines.push(` ${d.count}× ${d.what}`);
329
+ }
330
+
331
+ return lines;
332
+ }
@@ -40,7 +40,22 @@ export const VERIFIED_SURFACE = [
40
40
  // snapshot (qa/approvals.json) already carries as state — no lane step reads
41
41
  // it, so recording who/why must never invalidate a receipt for a tree whose
42
42
  // code did not change (the exact failure FI-8 killed for acceptance).
43
- const EXCLUDED_PREFIXES = ["qa/evidence", "qa-artifacts", "qa/comments.json", "qa/approvals.log.jsonl"];
43
+ // qa/flight-recorder.jsonl is a lane OUTPUT in the strictest sense: the lane
44
+ // appends one line to it on every run, after the receipt is written — hashing
45
+ // it would make every run invalidate its own receipt. qa/audits.jsonl (the
46
+ // cmp-audit ledger) is read by exactly one lane step (auditCadence), which is
47
+ // a REPORT and can never change the verdict — and recording an audit is
48
+ // bookkeeping about a commit that already happened, so appending a record
49
+ // must never invalidate a receipt for a tree whose code did not change
50
+ // (approvals.log.jsonl's principle, applied to audits).
51
+ const EXCLUDED_PREFIXES = [
52
+ "qa/evidence",
53
+ "qa-artifacts",
54
+ "qa/comments.json",
55
+ "qa/approvals.log.jsonl",
56
+ "qa/flight-recorder.jsonl",
57
+ "qa/audits.jsonl",
58
+ ];
44
59
 
45
60
  // qa/approvals.json is hashed by PROJECTION, not raw bytes. The approvals gate's
46
61
  // verdict depends on exactly three row fields (artifact, status, hash) plus the
@@ -51,17 +51,39 @@ export function scanSpecClauses(root) {
51
51
  return clauses;
52
52
  }
53
53
 
54
+ /**
55
+ * Which test tier a citing file belongs to, derived from its path. Tiers are
56
+ * the source-set/harness boundaries that decide what a citation can actually
57
+ * SEE: commonTest and desktopTest run on the JVM (blind to androidMain code),
58
+ * androidInstrumentedTest runs on a device, e2e flows drive the installed app.
59
+ * @param {string} relFile path relative to the project root
60
+ * @returns {"commonTest"|"desktopTest"|"androidInstrumentedTest"|"e2e"|"other"}
61
+ */
62
+ export function tierForFile(relFile) {
63
+ const p = relFile.split(path.sep).join("/");
64
+ if (p.includes("/androidInstrumentedTest/")) return "androidInstrumentedTest";
65
+ if (p.includes("/commonTest/")) return "commonTest";
66
+ if (p.includes("/desktopTest/")) return "desktopTest";
67
+ if (p.startsWith("qa/e2e/")) return "e2e";
68
+ return "other";
69
+ }
70
+
71
+ /** Tiers whose tests run entirely on the host JVM — blind to platform source sets. */
72
+ export const DESKTOP_TIERS = Object.freeze(["commonTest", "desktopTest"]);
73
+
54
74
  /**
55
75
  * Every `// SPEC: ID[, ID…]` / `# SPEC: …` citation tag under composeApp/src
56
- * and qa/e2e.
76
+ * and qa/e2e. Each entry carries the citing file's `tier` (see tierForFile).
57
77
  * @param {string} root
58
- * @returns {Array<{id: string, file: string, line: number}>}
78
+ * @returns {Array<{id: string, file: string, line: number, tier: string}>}
59
79
  */
60
80
  export function scanCitations(root) {
61
81
  const tags = [];
62
82
  const searchDirs = [path.join(root, "composeApp/src"), path.join(root, "qa/e2e")];
63
83
  const files = searchDirs.flatMap((d) => walkFiles(d, [".kt", ".kts", ".yaml", ".yml"]));
64
84
  for (const f of files) {
85
+ const rel = path.relative(root, f);
86
+ const tier = tierForFile(rel);
65
87
  fs.readFileSync(f, "utf8")
66
88
  .split("\n")
67
89
  .forEach((line, i) => {
@@ -73,8 +95,37 @@ export function scanCitations(root) {
73
95
  .split(/[,\s]+/)
74
96
  .map((s) => s.trim())
75
97
  .filter((s) => CLAUSE_ID_RE.test(s));
76
- for (const id of ids) tags.push({ id, file: path.relative(root, f), line: i + 1 });
98
+ for (const id of ids) tags.push({ id, file: rel, line: i + 1, tier });
77
99
  });
78
100
  }
79
101
  return tags;
80
102
  }
103
+
104
+ /**
105
+ * Per-clause tier visibility — report data only, never a pass/fail input
106
+ * (instrument before you police). For each cited clause: which tiers cite it.
107
+ * `desktopOnly` lists live clauses whose every citation is desktop-tier
108
+ * (commonTest/desktopTest) — behavior claims no device-tier evidence backs.
109
+ * `summaryLine` is the one line the lane's specCoverage step (and any other
110
+ * consumer) can print verbatim; null when nothing is desktop-only.
111
+ * @param {Map<string, {file: string, withdrawn: boolean}>} clauses from scanSpecClauses
112
+ * @param {Array<{id: string, tier: string}>} tags from scanCitations
113
+ * @returns {{tiersByClause: Record<string, string[]>, desktopOnly: string[], summaryLine: string|null}}
114
+ */
115
+ export function clauseTierCoverage(clauses, tags) {
116
+ const tiersByClause = {};
117
+ for (const t of tags) {
118
+ (tiersByClause[t.id] ??= []).includes(t.tier) || tiersByClause[t.id].push(t.tier);
119
+ }
120
+ const desktopOnly = [...clauses.entries()]
121
+ .filter(([, c]) => !c.withdrawn)
122
+ .map(([id]) => id)
123
+ .filter((id) => {
124
+ const tiers = tiersByClause[id];
125
+ return tiers && tiers.every((t) => DESKTOP_TIERS.includes(t));
126
+ });
127
+ const summaryLine = desktopOnly.length
128
+ ? `${desktopOnly.length} clause${desktopOnly.length === 1 ? "" : "s"} cited only from desktop-tier tests (${desktopOnly.join(", ")})`
129
+ : null;
130
+ return { tiersByClause, desktopOnly, summaryLine };
131
+ }
@@ -0,0 +1,221 @@
1
+ // step-cache.mjs — fast-mode memoization for the lane's pure-Node steps.
2
+ //
3
+ // The steps this serves (specCoverage, approvals, componentStories,
4
+ // reachability, archDoc) are pure functions of files on disk: no Gradle, no
5
+ // git, no network, no clock in the verdict. For those — and ONLY those — a
6
+ // verdict can be safely reused when a content hash of the step's declared
7
+ // input set is unchanged since the last run that produced it.
8
+ //
9
+ // GROUND RULES, each load-bearing:
10
+ //
11
+ // - THE CACHE IS A CACHE, NEVER EVIDENCE. It lives in the gitignored build
12
+ // dir (composeApp/build/.cmp-step-cache.json), must never be committed,
13
+ // and must never be read by qa/receipt-check.mjs or any other receipt
14
+ // consumer. Deleting it can only cost time, never correctness.
15
+ //
16
+ // - THE FULL LANE NEVER READS IT. This is deliberate: it keeps the full
17
+ // lane's integrity property absolute rather than "absolute unless a cache
18
+ // says otherwise". A full run WRITES entries (so the next fast run
19
+ // benefits) but always executes every step itself.
20
+ //
21
+ // - ONLY A CACHED PASS IS EVER REUSED. A cached FAIL is always re-run so
22
+ // the failure detail is fresh; a cached SKIP is re-derived (these steps
23
+ // are cheap enough that only the PASS case is worth reusing, and a SKIP's
24
+ // reason — e.g. which approvals are pending — must stay current).
25
+ //
26
+ // - THE DECLARED INPUT SET IS THE WHOLE SAFETY ARGUMENT. A step's inputs
27
+ // must cover everything it reads; a wrong input set is a silently-stale
28
+ // gate — the worst possible bug this file could host. Callers over-declare
29
+ // on purpose (a too-broad set only costs cache misses; a too-narrow one
30
+ // costs truth). The declarations live next to the steps in qa/verify.mjs.
31
+ //
32
+ // - THE CACHE MUST NEVER BREAK THE LANE. Missing, corrupt, unreadable,
33
+ // unwritable — every failure mode degrades to "execute the step", never
34
+ // to an error and never to a reused verdict.
35
+
36
+ import { createHash } from "node:crypto";
37
+ import fs from "node:fs";
38
+ import path from "node:path";
39
+
40
+ export const STEP_CACHE_REL_PATH = "composeApp/build/.cmp-step-cache.json";
41
+ export const STEP_CACHE_SCHEMA = "cmp-step-cache/1";
42
+
43
+ function toPosix(p) {
44
+ return p.split(path.sep).join("/");
45
+ }
46
+
47
+ /** Every file under `dir` (recursive), absolute paths. Missing dir → []. */
48
+ function walkAllFiles(dir) {
49
+ const out = [];
50
+ let entries;
51
+ try {
52
+ entries = fs.readdirSync(dir, { withFileTypes: true });
53
+ } catch {
54
+ return out;
55
+ }
56
+ for (const e of entries) {
57
+ const p = path.join(dir, e.name);
58
+ if (e.isDirectory()) out.push(...walkAllFiles(p));
59
+ else if (e.isFile()) out.push(p);
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * Content-hash one step's declared input set: sha256 over the sorted list of
66
+ * `relpath\0bytes` entries. Deterministic: same paths + same bytes → same
67
+ * hash, independent of declaration order, walk order, and platform separators.
68
+ * A file appearing, disappearing, moving, or changing content all change the
69
+ * hash — which is exactly the set of events that can change a pure-Node
70
+ * step's verdict.
71
+ *
72
+ * @param {string} root project root (absolute)
73
+ * @param {string[]} inputs paths relative to root — each a file or a
74
+ * directory (walked recursively). Missing entries contribute nothing (their
75
+ * later appearance changes the hash).
76
+ * @returns {{hash: string, fileCount: number}}
77
+ */
78
+ export function computeStepInputsHash(root, inputs) {
79
+ const relFiles = new Set();
80
+ for (const rel of inputs ?? []) {
81
+ const abs = path.join(root, rel);
82
+ let stat;
83
+ try {
84
+ stat = fs.statSync(abs);
85
+ } catch {
86
+ continue; // absent input — contributes nothing until it exists
87
+ }
88
+ if (stat.isFile()) {
89
+ relFiles.add(toPosix(rel));
90
+ } else if (stat.isDirectory()) {
91
+ for (const f of walkAllFiles(abs)) {
92
+ relFiles.add(toPosix(path.relative(root, f)));
93
+ }
94
+ }
95
+ }
96
+ // Code-unit sort (default String sort), same stance as inputs-hash.mjs: the
97
+ // hash depends on iteration order and must be identical on every machine.
98
+ const sorted = [...relFiles].sort();
99
+ const overall = createHash("sha256");
100
+ for (const rel of sorted) {
101
+ overall.update(rel);
102
+ overall.update("\0");
103
+ overall.update(fs.readFileSync(path.join(root, rel)));
104
+ overall.update("\n");
105
+ }
106
+ return { hash: overall.digest("hex"), fileCount: sorted.length };
107
+ }
108
+
109
+ /**
110
+ * Load the cache file. Absent, corrupt, or wrong-schema is TOLERATED and
111
+ * returns an empty cache — a cache that cannot be read is a cache miss,
112
+ * never an error (see ground rules).
113
+ * @param {string} root
114
+ * @returns {{schema: string, steps: Record<string, {inputsHash: string, verdict: string, at: string}>}}
115
+ */
116
+ export function loadStepCache(root) {
117
+ try {
118
+ const parsed = JSON.parse(fs.readFileSync(path.join(root, STEP_CACHE_REL_PATH), "utf8"));
119
+ if (!parsed || parsed.schema !== STEP_CACHE_SCHEMA || typeof parsed.steps !== "object" || parsed.steps === null || Array.isArray(parsed.steps)) {
120
+ return { schema: STEP_CACHE_SCHEMA, steps: {} };
121
+ }
122
+ return { schema: STEP_CACHE_SCHEMA, steps: parsed.steps };
123
+ } catch {
124
+ return { schema: STEP_CACHE_SCHEMA, steps: {} };
125
+ }
126
+ }
127
+
128
+ /**
129
+ * The reuse decision: return the cached entry iff the step's last EXECUTED
130
+ * verdict was PASS and its inputs hash exactly matches `inputsHash`. A cached
131
+ * FAIL or SKIP is never reused (re-run so the detail is fresh); a hash
132
+ * mismatch is a miss; a malformed entry is a miss.
133
+ * @param {string} root
134
+ * @param {string} stepName
135
+ * @param {string} inputsHash
136
+ * @returns {{inputsHash: string, verdict: string, at: string}|null}
137
+ */
138
+ export function lookupCachedPass(root, stepName, inputsHash) {
139
+ const entry = loadStepCache(root).steps[stepName];
140
+ if (!entry || typeof entry !== "object") return null;
141
+ if (entry.verdict !== "PASS") return null; // FAIL/SKIP are never reused
142
+ if (typeof inputsHash !== "string" || entry.inputsHash !== inputsHash) return null;
143
+ if (typeof entry.at !== "string") return null;
144
+ return entry;
145
+ }
146
+
147
+ /**
148
+ * Record a step's EXECUTED result (any verdict — the entry always reflects
149
+ * the last real execution; only lookupCachedPass decides reusability).
150
+ * Write failures are swallowed: an unwritable cache costs the next run time,
151
+ * never correctness.
152
+ * @param {string} root
153
+ * @param {string} stepName
154
+ * @param {{inputsHash: string, verdict: string, at?: string}} entry
155
+ */
156
+ export function writeStepCacheEntry(root, stepName, { inputsHash, verdict, at = new Date().toISOString() }) {
157
+ try {
158
+ const cache = loadStepCache(root);
159
+ cache.steps[stepName] = { inputsHash, verdict, at };
160
+ const p = path.join(root, STEP_CACHE_REL_PATH);
161
+ fs.mkdirSync(path.dirname(p), { recursive: true });
162
+ fs.writeFileSync(p, `${JSON.stringify(cache, null, 2)}\n`);
163
+ } catch {
164
+ // never a lane failure — see ground rules
165
+ }
166
+ }
167
+
168
+ /**
169
+ * The one memoization flow, shared by every memoized step so the mode rules
170
+ * cannot drift per step:
171
+ *
172
+ * fast mode: hash inputs → cached PASS with matching hash → return a
173
+ * CACHED result (verdict "CACHED", distinct from PASS so a fast
174
+ * receipt can never be mistaken for a fully-executed one);
175
+ * otherwise execute, record, return the real result.
176
+ * full mode: ALWAYS execute — the cache is never consulted (the full
177
+ * lane's integrity property stays absolute; see ground rules) —
178
+ * then record, so the next fast run benefits.
179
+ *
180
+ * Any cache-machinery error (hashing, read, write) degrades to plain
181
+ * execution.
182
+ *
183
+ * @param {object} args
184
+ * @param {boolean} args.fast whether this is a --fast run
185
+ * @param {string} args.root project root
186
+ * @param {string} args.stepName the lane step's name (the cache key)
187
+ * @param {string[]} args.inputs the step's declared input set (see ground rules)
188
+ * @param {() => object} args.run the real step function
189
+ * @returns {object} the step result — either `run()`'s verbatim, or a
190
+ * `{name, verdict: "CACHED", note, durationMs, details}` reuse marker
191
+ */
192
+ export function memoizeStep({ fast, root, stepName, inputs, run }) {
193
+ const started = Date.now();
194
+ let inputsHash = null;
195
+ try {
196
+ // Hashed BEFORE execution so the recorded entry binds the verdict to the
197
+ // tree the step actually saw, not to edits made while it ran.
198
+ inputsHash = computeStepInputsHash(root, inputs).hash;
199
+ } catch {
200
+ inputsHash = null;
201
+ }
202
+
203
+ if (fast && inputsHash) {
204
+ const hit = lookupCachedPass(root, stepName, inputsHash);
205
+ if (hit) {
206
+ return {
207
+ name: stepName,
208
+ verdict: "CACHED",
209
+ note: `unchanged since ${hit.at}`,
210
+ durationMs: Date.now() - started,
211
+ details: { inputsHash },
212
+ };
213
+ }
214
+ }
215
+
216
+ const result = run();
217
+ if (inputsHash && result && typeof result.verdict === "string") {
218
+ writeStepCacheEntry(root, stepName, { inputsHash, verdict: result.verdict });
219
+ }
220
+ return result;
221
+ }