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.
- package/README.md +11 -9
- package/bin/create-cmp.mjs +3 -0
- package/package.json +1 -1
- package/src/commands/upgrade.mjs +287 -0
- package/src/lib/harness-upgrade.mjs +364 -0
- package/src/lib/package-name.mjs +72 -0
- package/src/scaffold.mjs +7 -2
- package/template/.claude/settings.json +30 -0
- package/template/CLAUDE.md +51 -6
- package/template/README.md +4 -0
- package/template/composeApp/build.gradle.kts +44 -0
- package/template/composeApp/src/androidDebug/AndroidManifest.xml +9 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/PlatformBehaviorSeamTest.kt +277 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/RuntimeStateSeamTest.kt +308 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/AlarmAsserts.kt +152 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ConfigControl.kt +124 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/DozeControl.kt +113 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NetworkControl.kt +137 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NotificationAsserts.kt +163 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/PermissionControl.kt +132 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ProcessControl.kt +217 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/Shell.kt +79 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/SystemState.kt +113 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt +114 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +5 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +1 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +1 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +1 -1
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +58 -0
- package/template/docs/ARCHITECTURE.md +41 -2
- package/template/docs/TESTING.md +165 -0
- package/template/gradle/libs.versions.toml +15 -0
- package/template/manifest.json +1 -0
- package/template/qa/evidence/schema.json +20 -2
- package/template/qa/lib/affected-tests.mjs +147 -0
- package/template/qa/lib/audit-cadence.mjs +290 -0
- package/template/qa/lib/determinism.mjs +179 -0
- package/template/qa/lib/device-lease.mjs +249 -0
- package/template/qa/lib/evidence-badge.mjs +158 -0
- package/template/qa/lib/evidence-level.mjs +117 -0
- package/template/qa/lib/flight-recorder.mjs +332 -0
- package/template/qa/lib/inputs-hash.mjs +16 -1
- package/template/qa/lib/spec-coverage.mjs +54 -3
- package/template/qa/lib/step-cache.mjs +221 -0
- package/template/qa/receipt-check.mjs +22 -2
- package/template/qa/record-audit.mjs +83 -0
- package/template/qa/retrospective.mjs +51 -0
- package/template/qa/scaffold-feature.mjs +20 -1
- package/template/qa/verify.mjs +934 -57
- package/template/qa/watch.mjs +622 -0
- 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
|
-
|
|
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:
|
|
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
|
+
}
|