create-cmp-cli 0.18.0 → 0.20.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 (42) hide show
  1. package/README.md +3 -3
  2. package/llms.txt +1 -1
  3. package/package.json +1 -1
  4. package/packages/harness/src/approve.mjs +30 -2
  5. package/packages/harness/src/lib/approvals.mjs +74 -10
  6. package/packages/harness/src/lib/evidence-level.mjs +3 -1
  7. package/packages/harness/src/lib/flight-recorder.mjs +47 -2
  8. package/packages/harness/src/lib/inputs-hash.mjs +71 -3
  9. package/packages/harness/src/lib/lane-narrator.mjs +97 -0
  10. package/packages/harness/src/lib/lane-runner.mjs +173 -0
  11. package/packages/harness/src/lib/plan.mjs +286 -20
  12. package/packages/harness/src/lib/receipt-validate.mjs +56 -1
  13. package/packages/harness/src/lib/spec-coverage.mjs +111 -3
  14. package/packages/harness/src/lib/step-cache.mjs +1 -1
  15. package/packages/harness/src/lib/step-outcomes.mjs +123 -0
  16. package/packages/harness/src/lib/steps-cmp.mjs +1284 -0
  17. package/packages/harness/src/lib/walk.mjs +67 -17
  18. package/packages/harness/src/receipt-check.mjs +80 -4
  19. package/packages/harness/src/verify.mjs +119 -1197
  20. package/packages/receipts/src/index.mjs +1 -0
  21. package/packages/receipts/src/inputs-hash.mjs +71 -3
  22. package/packages/receipts/src/receipt-validate.mjs +56 -1
  23. package/template/CLAUDE.md +52 -6
  24. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +1 -1
  25. package/template/gitignore +3 -0
  26. package/template/qa/approve.mjs +30 -2
  27. package/template/qa/lib/approvals.mjs +74 -10
  28. package/template/qa/lib/evidence-level.mjs +3 -1
  29. package/template/qa/lib/flight-recorder.mjs +47 -2
  30. package/template/qa/lib/inputs-hash.mjs +71 -3
  31. package/template/qa/lib/lane-narrator.mjs +97 -0
  32. package/template/qa/lib/lane-runner.mjs +173 -0
  33. package/template/qa/lib/plan.mjs +286 -20
  34. package/template/qa/lib/receipt-validate.mjs +56 -1
  35. package/template/qa/lib/spec-coverage.mjs +111 -3
  36. package/template/qa/lib/step-cache.mjs +1 -1
  37. package/template/qa/lib/step-outcomes.mjs +123 -0
  38. package/template/qa/lib/steps-cmp.mjs +1284 -0
  39. package/template/qa/lib/walk.mjs +67 -17
  40. package/template/qa/receipt-check.mjs +80 -4
  41. package/template/qa/verify.mjs +119 -1197
  42. package/template/specs/README.md +26 -0
@@ -17,8 +17,19 @@ import { createHash } from "node:crypto";
17
17
  import fs from "node:fs";
18
18
  import path from "node:path";
19
19
 
20
- // Directories / files INCLUDED in the verified surface (relative to project ROOT).
20
+ // Directories / files included in the verified surface (relative to project ROOT).
21
21
  // Principle: every tracked file whose content can change the lane's verdict.
22
+ //
23
+ // THIS IS A DEFAULT, NOT A LAW (evidence-economics S8, 2026-09-03). It is the
24
+ // surface of a Compose Multiplatform app, and it used to be hardcoded inside
25
+ // this module — which is the SPINE, shared by every adopter. A repo whose code
26
+ // lives in services/ or src/ that vendored this file had its verified surface
27
+ // silently shrink to whatever happened to match: no error, no failed step, a
28
+ // receipt that still validated and still looked identical, and a hash that had
29
+ // quietly stopped covering the application. A gate that attests less while
30
+ // looking the same is the worst failure this harness can have, so the surface
31
+ // is now resolved per project (see resolveVerifiedSurface) and an empty one is
32
+ // refused rather than hashed.
22
33
  export const VERIFIED_SURFACE = [
23
34
  "composeApp",
24
35
  "specs",
@@ -57,6 +68,7 @@ export const VERIFIED_SURFACE = [
57
68
  const EXCLUDED_PREFIXES = [
58
69
  "qa/.plan.json",
59
70
  "qa/.request.json",
71
+ "qa/.plan-history.jsonl",
60
72
  "qa/evidence",
61
73
  "qa-artifacts",
62
74
  "qa/comments.json",
@@ -146,9 +158,52 @@ function walkAllFiles(dir) {
146
158
  return out;
147
159
  }
148
160
 
161
+ /** Where a project may declare its own verified surface (see resolveVerifiedSurface). */
162
+ export const SURFACE_CONFIG_REL = "qa/verified-surface.json";
163
+
164
+ /**
165
+ * The surface THIS project attests — its own declaration when it has one, the
166
+ * Compose Multiplatform default otherwise.
167
+ *
168
+ * Read from a file rather than passed as an argument on purpose: qa/verify.mjs
169
+ * (which writes inputs.hash) and qa/receipt-check.mjs (which recomputes it)
170
+ * must never disagree about what was hashed, and two call sites taking a
171
+ * parameter is two places to get it wrong. The file lives under qa/, so it is
172
+ * itself inside the surface — changing the definition invalidates receipts,
173
+ * which is correct: the tree's coverage changed.
174
+ *
175
+ * Shape: {"surface": ["services", "docs", "build-logic", ".github", "qa"]}.
176
+ * Malformed or empty content is REFUSED, never silently defaulted — a project
177
+ * that tried to declare a surface and failed must not fall back to a smaller
178
+ * one behind the operator's back.
179
+ *
180
+ * @param {string} root project root
181
+ * @returns {string[]} surface entries, relative to root
182
+ */
183
+ export function resolveVerifiedSurface(root) {
184
+ const p = path.join(root, SURFACE_CONFIG_REL);
185
+ let raw;
186
+ try {
187
+ raw = fs.readFileSync(p, "utf8");
188
+ } catch {
189
+ return VERIFIED_SURFACE; // no declaration — the CMP default, unchanged
190
+ }
191
+ let parsed;
192
+ try {
193
+ parsed = JSON.parse(raw);
194
+ } catch (err) {
195
+ throw new Error(`${SURFACE_CONFIG_REL} is not valid JSON (${err.message}) — refusing to hash a surface this project failed to declare.`);
196
+ }
197
+ const list = parsed && Array.isArray(parsed.surface) ? parsed.surface.filter((x) => typeof x === "string" && x.trim() !== "") : null;
198
+ if (!list || list.length === 0) {
199
+ throw new Error(`${SURFACE_CONFIG_REL} declares no surface — expected {"surface": ["dir", …]}. Refusing to hash nothing.`);
200
+ }
201
+ return list;
202
+ }
203
+
149
204
  // Resolve the verified surface to a flat, sorted list of paths (relative to
150
205
  // root, POSIX-style `/` separators) that currently exist on disk.
151
- function resolveSurfaceFiles(root) {
206
+ function resolveSurfaceFiles(root, VERIFIED_SURFACE) {
152
207
  const gitFiles = tryGitLsFiles(root);
153
208
 
154
209
  if (gitFiles) {
@@ -188,7 +243,20 @@ export function computeInputsHash(root) {
188
243
  // on iteration order, and ICU collation varies with the machine's locale
189
244
  // (e.g. a da_DK machine orders "aa" after "z"; en orders case-insensitively
190
245
  // where code units do not) — the same tree must hash identically everywhere.
191
- const files = [...new Set(resolveSurfaceFiles(root))].sort();
246
+ const surface = resolveVerifiedSurface(root);
247
+ const files = [...new Set(resolveSurfaceFiles(root, surface))].sort();
248
+
249
+ // A surface that matches NOTHING is a misconfiguration, not a valid hash.
250
+ // Hashing zero files yields a stable, confident-looking digest that attests
251
+ // the empty set — the silent shrink this whole change exists to prevent, in
252
+ // its most extreme form. Refuse, and name what was looked for.
253
+ if (files.length === 0) {
254
+ throw new Error(
255
+ `the verified surface matched no files under ${root} — nothing would be attested. ` +
256
+ `Surface: ${surface.join(", ")}. ` +
257
+ `A project whose code lives elsewhere declares its own in ${SURFACE_CONFIG_REL}: {"surface": ["services", "qa", …]}.`,
258
+ );
259
+ }
192
260
 
193
261
  const overall = createHash("sha256");
194
262
  for (const relPath of files) {
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ // lane-narrator.mjs — the lane's pulse while a step is running.
3
+ //
4
+ // node qa/lib/lane-narrator.mjs <projectRoot>
5
+ //
6
+ // WHY THIS IS A SEPARATE PROCESS, and not a setInterval in verify.mjs. The lane's
7
+ // steps are SYNCHRONOUS: each one blocks on execSync/spawnSync while Gradle works.
8
+ // A timer inside that process cannot fire — the event loop is not running — so the
9
+ // lane could only ever print when a step FINISHED. Observed live: fourteen minutes
10
+ // without a single byte while the release build ran, with no way to tell a grinding
11
+ // step from a wedged one except checking the Gradle daemon's CPU by hand. A step
12
+ // that can take minutes must emit a heartbeat, or the operator's only signal is
13
+ // silence, and silence is exactly what a crash looks like.
14
+ //
15
+ // The marker verify.mjs already rewrites at each step start (.cmp-lane-in-progress,
16
+ // JSON: step, index, total, stepStartedAt, expectedStepMs, expectedLaneMs) carries
17
+ // everything a pulse needs, so this narrator INVENTS NOTHING — it reads what the
18
+ // lane declared about itself and says it out loud on a timer the lane cannot run.
19
+ //
20
+ // It writes to STDERR and never to stdout: --json consumers parse stdout, and a
21
+ // narrator that corrupted machine output would be worse than the silence it fixes.
22
+ // It is spawned only for human runs, and killed with the step loop.
23
+
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+
27
+ const ROOT = process.argv[2];
28
+ const MARKER = path.join(ROOT ?? ".", "composeApp", "build", ".cmp-lane-in-progress");
29
+
30
+ // A step under this is not a wait — saying anything about it is noise.
31
+ const FIRST_AFTER_MS = 20_000;
32
+ const EVERY_MS = 30_000;
33
+ const POLL_MS = 1_000;
34
+
35
+ /** "42s" / "4m12s" — short enough to sit inside one line without wrapping. */
36
+ export function shortDuration(ms) {
37
+ if (!(ms > 0)) return "0s";
38
+ const s = Math.round(ms / 1000);
39
+ return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
40
+ }
41
+
42
+ /**
43
+ * The line for one poll, or null when there is nothing worth saying yet.
44
+ * Pure, so the cadence and the wording are testable without a clock or a lane.
45
+ *
46
+ * @param {object|null} marker parsed marker content (null when absent/legacy)
47
+ * @param {number} elapsedMs how long the CURRENT step has been running
48
+ * @param {number|null} lastSaidAtMs elapsed value at the previous line, or null
49
+ * @returns {string|null}
50
+ */
51
+ export function pulseLine(marker, elapsedMs, lastSaidAtMs) {
52
+ if (!marker || typeof marker.step !== "string") return null; // legacy marker: nothing to narrate
53
+ if (elapsedMs < FIRST_AFTER_MS) return null;
54
+ if (lastSaidAtMs !== null && elapsedMs - lastSaidAtMs < EVERY_MS) return null;
55
+ const where = marker.index && marker.total ? ` (${marker.index}/${marker.total})` : "";
56
+ // The expectation is quoted from the flight journal's last full run — measured,
57
+ // never estimated (walk-legibility L4). Absent until one such run exists.
58
+ const usually = marker.expectedStepMs > 0 ? `, usually ~${shortDuration(marker.expectedStepMs)}` : "";
59
+ // Past its usual time is the fact an operator actually wants: it is the
60
+ // difference between "grinding" and "possibly wedged", and it is derived, not
61
+ // guessed — so it is stated plainly rather than dressed up as a warning.
62
+ const over = marker.expectedStepMs > 0 && elapsedMs > marker.expectedStepMs * 1.5 ? " — longer than usual" : "";
63
+ return `⋯ ${marker.step}${where} — ${shortDuration(elapsedMs)} elapsed${usually}${over}`;
64
+ }
65
+
66
+ function readMarker() {
67
+ try {
68
+ return JSON.parse(fs.readFileSync(MARKER, "utf8"));
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ if (ROOT) {
75
+ let currentStep = null;
76
+ let lastSaidAtMs = null;
77
+ const timer = setInterval(() => {
78
+ const m = readMarker();
79
+ if (!m) return; // between steps, or the lane has finished and cleared it
80
+ if (m.step !== currentStep) {
81
+ currentStep = m.step;
82
+ lastSaidAtMs = null; // each step narrates on its own clock
83
+ }
84
+ const started = Date.parse(m.stepStartedAt ?? "");
85
+ if (Number.isNaN(started)) return;
86
+ const elapsed = Date.now() - started;
87
+ const line = pulseLine(m, elapsed, lastSaidAtMs);
88
+ if (line) {
89
+ lastSaidAtMs = elapsed;
90
+ process.stderr.write(`${line}\n`);
91
+ }
92
+ }, POLL_MS);
93
+ timer.unref?.();
94
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, () => process.exit(0));
95
+ // Hold the process open against the unref'd timer: the parent kills us.
96
+ setInterval(() => {}, 1 << 30);
97
+ }
@@ -0,0 +1,173 @@
1
+ // lane-runner.mjs — the lane's step loop, as a function (evidence-economics S8a).
2
+ //
3
+ // The SPINE, separated from the STEPS. Everything a lane does around its steps —
4
+ // stamp the in-flight marker with the step's own narration, set the step's
5
+ // deadline from its measured history, keep a pulse alive while a synchronous
6
+ // step blocks, turn a throw or a timeout into one ERROR row and keep going,
7
+ // print the mark, derive the verdict — is the same for a Compose app and for
8
+ // a Kotlin backend. Only the steps differ. payment-blueprint re-implemented
9
+ // all of this by hand (2,769 lines) because it lived inside verify.mjs next
10
+ // to Gradle calls it could not use; this file is what it should have been
11
+ // able to import.
12
+ //
13
+ // PURE OF PROJECT KNOWLEDGE. No ROOT, no Gradle, no composeApp path: every
14
+ // project-specific fact arrives through `ctx`. The runner never reads argv.
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { spawn } from "node:child_process";
19
+
20
+ import { stepDeadlineMs, stepErrorResult } from "./step-outcomes.mjs";
21
+
22
+ /**
23
+ * Per-step expected durations from the journal's LAST FULL run — the source
24
+ * of the marker's "usually ~Ns" and of each step's deadline. Measured, never
25
+ * estimated; empty until one new-format full run exists.
26
+ * @param {object[]} entries parsed flight-journal entries, oldest first
27
+ * @returns {{byName: Map<string, number>, laneMs: (number|null)}}
28
+ */
29
+ export function expectedDurations(entries) {
30
+ const list = Array.isArray(entries) ? entries : [];
31
+ for (let i = list.length - 1; i >= 0; i--) {
32
+ const e = list[i];
33
+ if (!e || e.mode === "fast" || !Array.isArray(e.steps)) continue;
34
+ const byName = new Map();
35
+ for (const s of e.steps) {
36
+ if (s && typeof s.name === "string" && typeof s.durationMs === "number" && s.durationMs > 0) byName.set(s.name, s.durationMs);
37
+ }
38
+ return { byName, laneMs: typeof e.durationMs === "number" && e.durationMs > 0 ? e.durationMs : null };
39
+ }
40
+ return { byName: new Map(), laneMs: null };
41
+ }
42
+
43
+ /**
44
+ * "stepUnitTests" → "unitTests", "stepSpecCoverageMemo" → "specCoverage" — the
45
+ * name the result will carry. An anonymous step narrates as null rather than
46
+ * guessing. Exported so a step pack can name its steps the same way.
47
+ * @param {Function} fn
48
+ * @returns {string|null}
49
+ */
50
+ export function stepDisplayName(fn) {
51
+ const raw = typeof fn?.name === "string" ? fn.name.replace(/^step/, "").replace(/Memo$/, "") : "";
52
+ return raw === "" ? null : raw.charAt(0).toLowerCase() + raw.slice(1);
53
+ }
54
+
55
+ /** The lane's verdict over its rows: FAIL on any FAIL or ERROR, else PASS. CACHED counts as PASS (it IS a prior PASS). */
56
+ export function laneVerdict(steps) {
57
+ return steps.some((s) => s && (s.verdict === "FAIL" || s.verdict === "ERROR")) ? "FAIL" : "PASS";
58
+ }
59
+
60
+ /** The mark a row wears on the console: ✓ PASS · ⚡ CACHED · → SKIP · ⊘ ERROR (could not run) · ✗ FAIL. */
61
+ export function verdictMark(verdict) {
62
+ return verdict === "PASS" ? "✓" : verdict === "CACHED" ? "⚡" : verdict === "SKIP" ? "→" : verdict === "ERROR" ? "⊘" : "✗";
63
+ }
64
+
65
+ /**
66
+ * Run the steps, in order, under the lane's own discipline.
67
+ *
68
+ * @param {object} ctx
69
+ * @param {Function[]} ctx.steps the step functions, each returning a result row
70
+ * @param {string} ctx.markerPath the in-flight marker (.cmp-lane-in-progress) — stamped
71
+ * before every step with {pid, at, step, index, total, stepStartedAt, expectedStepMs,
72
+ * expectedLaneMs}, removed when the loop ends however it ends
73
+ * @param {{byName: Map<string, number>, laneMs: (number|null)}} [ctx.expected] from expectedDurations
74
+ * @param {(ms: number) => void} [ctx.setDeadline] receives each step's deadline before it
75
+ * runs — the project's subprocess helper reads it (verify.mjs's sh())
76
+ * @param {(line: string) => void|null} [ctx.print] one line per finished step; null = silent
77
+ * (--json). Also gates the narrator: no print, no pulse.
78
+ * @param {{entry: string, root: string}|null} [ctx.narrator] the pulse process to spawn
79
+ * beside the loop (lane-narrator.mjs) — a separate process because the steps are
80
+ * synchronous and no timer in this process can fire while one runs
81
+ * @param {(result: object) => boolean} [ctx.stopAfter] short-circuit predicate; default:
82
+ * stop after a FAILed "build" — nothing downstream is meaningful
83
+ * @param {() => void} [ctx.onFinally] runs in the finally (the project releases its device lease here)
84
+ * @param {number} [ctx.startedAt] the lane's start, for the marker's `at`
85
+ * @returns {{steps: object[], verdict: "PASS"|"FAIL", durationMs: number}}
86
+ */
87
+ export function runLane(ctx) {
88
+ const {
89
+ steps: stepFns,
90
+ markerPath,
91
+ expected = { byName: new Map(), laneMs: null },
92
+ setDeadline = () => {},
93
+ print = null,
94
+ narrator = null,
95
+ stopAfter = (r) => r.name === "build" && r.verdict === "FAIL",
96
+ onFinally = () => {},
97
+ startedAt = Date.now(),
98
+ } = ctx;
99
+
100
+ const stamp = (stepFn, index, total) => {
101
+ try {
102
+ const name = stepFn ? stepDisplayName(stepFn) : null;
103
+ const narration = {
104
+ pid: process.pid,
105
+ at: new Date(startedAt).toISOString(),
106
+ step: name,
107
+ index,
108
+ total,
109
+ stepStartedAt: new Date().toISOString(),
110
+ expectedStepMs: name !== null ? (expected.byName.get(name) ?? null) : null,
111
+ expectedLaneMs: expected.laneMs,
112
+ };
113
+ fs.writeFileSync(markerPath, `${JSON.stringify(narration)}\n`);
114
+ } catch {
115
+ /* the narration must never break the lane it narrates */
116
+ }
117
+ };
118
+
119
+ fs.mkdirSync(path.dirname(markerPath), { recursive: true });
120
+ stamp(null, 0, stepFns.length);
121
+
122
+ let pulse = null;
123
+ if (print && narrator) {
124
+ try {
125
+ pulse = spawn(process.execPath, [narrator.entry, narrator.root], { stdio: ["ignore", "ignore", "inherit"] });
126
+ pulse.on("error", () => {});
127
+ } catch {
128
+ /* a missing pulse is a quieter lane, never a failed one */
129
+ }
130
+ }
131
+
132
+ const results = [];
133
+ try {
134
+ for (const [i, step] of stepFns.entries()) {
135
+ stamp(step, i + 1, stepFns.length);
136
+ const name = stepDisplayName(step) ?? `step${i + 1}`;
137
+ // S4: every step under a deadline from its own history (×3, floor 5 min,
138
+ // ceiling 30). A deadline or a throw is ONE ERROR row — the lane keeps
139
+ // going, because the other verdicts are still worth having.
140
+ setDeadline(stepDeadlineMs(expected.byName.get(name)));
141
+ const stepStarted = Date.now();
142
+ let result;
143
+ try {
144
+ result = step();
145
+ } catch (err) {
146
+ result = stepErrorResult(name, err, Date.now() - stepStarted);
147
+ }
148
+ results.push(result);
149
+ if (print) {
150
+ print(
151
+ `${verdictMark(result.verdict)} ${result.name}: ${result.verdict}${result.note ? ` (${result.note})` : ""}${result.reason ? ` — ${String(result.reason).split("\n")[0]}` : ""}`,
152
+ );
153
+ }
154
+ if (stopAfter(result)) break;
155
+ }
156
+ } finally {
157
+ if (pulse) {
158
+ try {
159
+ pulse.kill();
160
+ } catch {
161
+ /* the narrator holds nothing; a failed kill must not fail the lane */
162
+ }
163
+ }
164
+ fs.rmSync(markerPath, { force: true });
165
+ try {
166
+ onFinally();
167
+ } catch {
168
+ /* a finalizer that throws must not hide the rows already earned */
169
+ }
170
+ }
171
+
172
+ return { steps: results, verdict: laneVerdict(results), durationMs: Date.now() - startedAt };
173
+ }