create-cmp-cli 0.10.1 → 0.11.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.
@@ -27,14 +27,63 @@ import { fileURLToPath } from "node:url";
27
27
  import { computeInputsHash } from "./lib/inputs-hash.mjs";
28
28
  import { compareTokenDrift } from "./lib/token-drift.mjs";
29
29
  import { evaluateApprovalsGate } from "./lib/approvals.mjs";
30
+ import { scanCitations, scanSpecClauses, walkFiles } from "./lib/spec-coverage.mjs";
30
31
  import { evaluateComponentStoryParity } from "./lib/component-stories.mjs";
32
+ import { evaluateReachability } from "./lib/reachability.mjs";
31
33
  import { ARCH_DOC_REL_PATH, SECTION_IDS, regenerateArchDoc } from "./lib/arch-doc.mjs";
32
34
 
33
35
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
34
36
  const EVIDENCE_DIR = path.join(ROOT, "qa", "evidence");
35
37
  const ARTIFACTS_DIR = path.join(ROOT, "qa-artifacts");
36
38
 
37
- const args = process.argv.slice(2);
39
+ // ── Argument parsing — strict, and first thing this file does ──────────────
40
+ // An unrecognized flag used to fall through silently and start the full
41
+ // multi-minute lane (`--help` ran the whole lane for ~2 minutes before being
42
+ // killed). Same refusal-over-fabrication stance as qa/approve.mjs, which
43
+ // refuses an unknown artifact by name rather than guessing: an unknown
44
+ // argument here is refused by name, not swallowed into "run everything".
45
+ const USAGE = `node qa/verify.mjs [--profile scaffold|local|ci] [--json] [--help]
46
+
47
+ The verify lane — this project's single verification gate. Runs every
48
+ verification step this project carries, aggregates a typed PASS/FAIL
49
+ verdict, and writes the evidence receipt to qa/evidence/latest.json (commit
50
+ it with your change — see CLAUDE.md). Exit code: 0 = PASS, 1 = FAIL.
51
+
52
+ Flags:
53
+ --profile <scaffold|local|ci> which step set to run (default: local)
54
+ --json print the receipt as JSON instead of the
55
+ human-readable step-by-step log
56
+ --help, -h print this usage and exit 0 without
57
+ running anything
58
+
59
+ Profiles:
60
+ scaffold spec coverage + build + unit tests (what \`create-cmp --verify\`
61
+ proves at stamp time)
62
+ local everything; device-dependent steps SKIP when no device is
63
+ attached
64
+ ci everything; SKIPs are recorded so the pipeline stays honest
65
+ `;
66
+
67
+ const rawArgs = process.argv.slice(2);
68
+
69
+ if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
70
+ console.log(USAGE);
71
+ process.exit(0);
72
+ }
73
+
74
+ const RECOGNIZED_FLAGS = new Set(["--profile", "--json"]);
75
+ for (let i = 0; i < rawArgs.length; i += 1) {
76
+ const arg = rawArgs[i];
77
+ if (arg === "--profile") {
78
+ i += 1; // consume its value (missing/invalid value keeps the existing exit-2 behavior below)
79
+ continue;
80
+ }
81
+ if (RECOGNIZED_FLAGS.has(arg)) continue;
82
+ console.error(`unknown argument "${arg}" — run node qa/verify.mjs --help`);
83
+ process.exit(2);
84
+ }
85
+
86
+ const args = rawArgs;
38
87
  const profile = args.includes("--profile") ? args[args.indexOf("--profile") + 1] : "local";
39
88
  const asJson = args.includes("--json");
40
89
 
@@ -53,16 +102,52 @@ function sh(cmd, opts = {}) {
53
102
  // The preview daemon (the eyes) and this lane both spawn Gradle against this
54
103
  // project and share composeApp/build/kspCaches, whose KSP incremental storage
55
104
  // is single-owner — two concurrent builds throw "Storage for [...] is already
56
- // registered" and one side dies. Two defenses, both automatic:
57
- // 1. COORDINATE: this lane stamps a marker file for its duration; the preview
58
- // service defers renders while it exists (mtime-bounded, so a crashed lane
59
- // never wedges the eyes for long).
60
- // 2. SELF-HEAL: a Gradle step that still hits the collision clears kspCaches
105
+ // registered" and one side dies. Three defenses, all automatic:
106
+ // 1. COORDINATE (this lane -> the daemon): this lane stamps a marker file
107
+ // for its duration; the preview service defers renders while it exists
108
+ // (mtime-bounded, so a crashed lane never wedges the eyes for long).
109
+ // 2. COORDINATE (the daemon -> this lane), the symmetric half: the daemon
110
+ // stamps its OWN marker for the duration of a render's Gradle build;
111
+ // shGradle waits for it to clear (or go stale) before launching this
112
+ // lane's own Gradle command — same mtime-bounded shape, so a crashed
113
+ // daemon never wedges the lane for long either.
114
+ // 3. SELF-HEAL: a Gradle step that still hits the collision clears kspCaches
61
115
  // and retries once — the manual recovery that always worked, automated.
62
116
  const LANE_MARKER = path.join(ROOT, "composeApp", "build", ".cmp-lane-in-progress");
63
117
  const KSP_COLLISION_RE = /Storage for \[[^\]]*\] is already registered/;
64
118
 
119
+ // The daemon's half of defense 2 above — pid + ISO timestamp, mirroring
120
+ // LANE_MARKER's own content shape (see where LANE_MARKER is stamped, below).
121
+ const RENDER_MARKER = path.join(ROOT, "composeApp", "build", ".cmp-render-in-progress");
122
+ const RENDER_MARKER_FRESH_MS = 5 * 60 * 1000; // older than this = a crashed daemon's stale marker, ignore it
123
+ const RENDER_WAIT_TIMEOUT_MS = 3 * 60 * 1000; // give up waiting after this long regardless
124
+ const RENDER_WAIT_POLL_MS = 2000;
125
+
126
+ /**
127
+ * Defer this lane's next Gradle command while the preview daemon's render
128
+ * marker is present AND fresh (mtime younger than RENDER_MARKER_FRESH_MS).
129
+ * Polls every RENDER_WAIT_POLL_MS; gives up and proceeds anyway after
130
+ * RENDER_WAIT_TIMEOUT_MS, or the moment the marker disappears or goes stale —
131
+ * whichever comes first. A missing/unreadable marker returns immediately:
132
+ * this is a coexistence courtesy, never a hard dependency on the daemon.
133
+ */
134
+ function waitForRenderMarker() {
135
+ const deadline = Date.now() + RENDER_WAIT_TIMEOUT_MS;
136
+ for (;;) {
137
+ let stat;
138
+ try {
139
+ stat = fs.statSync(RENDER_MARKER);
140
+ } catch {
141
+ return; // no render in flight
142
+ }
143
+ if (Date.now() - stat.mtimeMs >= RENDER_MARKER_FRESH_MS) return; // gone stale
144
+ if (Date.now() >= deadline) return; // waited long enough — proceed regardless
145
+ sh(`sleep ${RENDER_WAIT_POLL_MS / 1000}`);
146
+ }
147
+ }
148
+
65
149
  function shGradle(cmd, opts = {}) {
150
+ waitForRenderMarker();
66
151
  const first = sh(cmd, opts);
67
152
  if (first.ok || !KSP_COLLISION_RE.test(first.out)) return first;
68
153
  console.error("· KSP cache collision (concurrent Gradle — the preview daemon?) — clearing kspCaches, retrying once");
@@ -119,27 +204,14 @@ function deviceAttached() {
119
204
  return res.out.split("\n").slice(1).some((l) => /\tdevice$/.test(l.trim().replace(/\s+/g, "\t")));
120
205
  }
121
206
 
122
- // Recursive directory walker (no glob dependency) — returns files under `dir`
123
- // whose name ends with one of `exts`.
124
- function walkFiles(dir, exts) {
125
- const out = [];
126
- if (!fs.existsSync(dir)) return out;
127
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
128
- const p = path.join(dir, entry.name);
129
- if (entry.isDirectory()) out.push(...walkFiles(p, exts));
130
- else if (exts.some((ext) => entry.name.endsWith(ext))) out.push(p);
131
- }
132
- return out;
133
- }
134
-
135
207
  // ── Steps ──────────────────────────────────────────────────────────────────
136
208
  // Each returns { name, verdict, reason?, durationMs, details? }. Failure
137
209
  // reasons are worded for an AI collaborator to act on.
138
210
 
139
- // Spec ↔ test drift gate — pure Node, no Gradle. Parses clause ids out of
140
- // specs/*.spec.md (live: `- **ID**`, withdrawn: `- ~~**ID**~~`, exempt from
141
- // coverage) and `// SPEC:` / `# SPEC:` citation tags out of composeApp/src and
142
- // qa/e2e, then fails on orphans in either direction.
211
+ // Spec ↔ test drift gate — pure Node, no Gradle. The clause/citation scan
212
+ // itself lives in qa/lib/spec-coverage.mjs the SAME scan feature-brief.mjs
213
+ // derives doneness from, so this gate and the Features view can never disagree
214
+ // about a clause. This step owns only the orphan decision + bookkeeping.
143
215
  function stepSpecCoverage() {
144
216
  const started = Date.now();
145
217
  const specsDir = path.join(ROOT, "specs");
@@ -147,32 +219,10 @@ function stepSpecCoverage() {
147
219
  return { name: "specCoverage", verdict: "SKIP", reason: "no specs/ directory in this project", durationMs: Date.now() - started };
148
220
  }
149
221
 
150
- const CLAUSE_LINE_RE = /^-\s+(~~)?\*\*([A-Z][A-Z0-9]*-\d{2,})\*\*/;
151
- const clauses = new Map(); // id -> { file, withdrawn }
152
- const specFiles = fs.readdirSync(specsDir).filter((f) => f.endsWith(".spec.md")).map((f) => path.join(specsDir, f));
153
- for (const f of specFiles) {
154
- for (const line of fs.readFileSync(f, "utf8").split("\n")) {
155
- const m = line.match(CLAUSE_LINE_RE);
156
- if (!m) continue;
157
- clauses.set(m[2], { file: path.relative(ROOT, f), withdrawn: Boolean(m[1]) });
158
- }
159
- }
160
-
161
- const TAG_LINE_RE = /^(?:\/\/|#)\s*SPEC:/;
162
- const TAG_IDS_RE = /SPEC:\s*([A-Z0-9,\s-]+)/;
222
+ const clauses = scanSpecClauses(ROOT);
223
+ const tags = scanCitations(ROOT);
163
224
  const searchDirs = [path.join(ROOT, "composeApp/src"), path.join(ROOT, "qa/e2e")];
164
225
  const files = searchDirs.flatMap((d) => walkFiles(d, [".kt", ".kts", ".yaml", ".yml"]));
165
- const tags = [];
166
- for (const f of files) {
167
- fs.readFileSync(f, "utf8").split("\n").forEach((line, i) => {
168
- const trimmed = line.trim();
169
- if (!TAG_LINE_RE.test(trimmed)) return;
170
- const m = trimmed.match(TAG_IDS_RE);
171
- if (!m) return;
172
- const ids = m[1].split(/[,\s]+/).map((s) => s.trim()).filter((s) => /^[A-Z][A-Z0-9]*-\d{2,}$/.test(s));
173
- for (const id of ids) tags.push({ id, file: path.relative(ROOT, f), line: i + 1 });
174
- });
175
- }
176
226
 
177
227
  const citedIds = new Set(tags.map((t) => t.id));
178
228
  const orphanClauses = [...clauses.entries()].filter(([, c]) => !c.withdrawn).filter(([id]) => !citedIds.has(id));
@@ -235,6 +285,12 @@ function stepApprovals() {
235
285
  };
236
286
  }
237
287
 
288
+ // There is deliberately NO feature-doneness step here (CHANGE-FLOW-DESIGN.md
289
+ // §7): a feature's doneness is DERIVED from gates this lane already runs —
290
+ // specCoverage fails an uncited clause, the test steps fail a broken promise,
291
+ // and the receipt's inputs.hash attests the tree. A second mechanism would be
292
+ // a second truth.
293
+
238
294
  // Component ↔ story parity gate (STUDIO-REDESIGN.md §3.3) — pure Node, no
239
295
  // Gradle, same grouping as specCoverage/approvals. The decision itself lives
240
296
  // in qa/lib/component-stories.mjs (evaluateComponentStoryParity); this step
@@ -245,6 +301,18 @@ function stepComponentStories() {
245
301
  return { name: "componentStories", verdict, reason, durationMs: Date.now() - started, details };
246
302
  }
247
303
 
304
+ // Navigation-reachability gate (task FI-7, docs/AUTONOMY-GAPS.md §3) — pure
305
+ // Node, no Gradle, same grouping as specCoverage/approvals/componentStories.
306
+ // The decision itself lives in qa/lib/reachability.mjs (evaluateReachability);
307
+ // this step only adds the name/duration bookkeeping every step in this file
308
+ // carries. Closes the exact hole a real feature slipped through: every other
309
+ // gate PASSed while its screen was wired into nothing.
310
+ function stepReachability() {
311
+ const started = Date.now();
312
+ const { verdict, reason, details } = evaluateReachability(ROOT);
313
+ return { name: "reachability", verdict, reason, durationMs: Date.now() - started, details };
314
+ }
315
+
248
316
  // Architecture-doc freshness gate (Wave B, docs/proposals/architecture-document-
249
317
  // standard.md §6) — pure Node, no Gradle, same grouping as specCoverage/
250
318
  // approvals. The decision itself lives in qa/lib/arch-doc.mjs
@@ -300,6 +368,33 @@ function stepBuild() {
300
368
  };
301
369
  }
302
370
 
371
+ // The build nobody runs until the day they need it.
372
+ //
373
+ // assembleDebug passing says nothing about assembleRelease: R8 and `lintVital` only run on
374
+ // the release variant, and BuildConfig is generated PER BUILD TYPE, so a constant declared
375
+ // in one and not the other is a compile error that only release ever sees. All three of
376
+ // those bit this template at once, and none of them were visible from a green debug lane —
377
+ // the first release build ever attempted (2026-07-29) failed three times over.
378
+ //
379
+ // So release is proven at the checkpoint, not discovered at launch. Unsigned: signing needs
380
+ // a keystore, which belongs to whoever ships the app, and this step is about the shrinker
381
+ // and the build graph rather than the signature.
382
+ function stepReleaseBuild() {
383
+ const res = shGradle(`${GRADLEW} :composeApp:assembleRelease --console=plain`);
384
+ return {
385
+ name: "releaseBuild",
386
+ verdict: res.ok ? "PASS" : "FAIL",
387
+ reason: res.ok
388
+ ? undefined
389
+ : `assembleRelease failed — the shippable build is broken even though the debug one is fine:\n${res.out
390
+ .split("\n")
391
+ .filter((l) => /error|FAILURE|Missing class|Unresolved/i.test(l))
392
+ .slice(0, 12)
393
+ .join("\n")}`,
394
+ durationMs: res.durationMs,
395
+ };
396
+ }
397
+
303
398
  // Runs a filtered slice of the JVM test tier and names the verdict after the gate it proves.
304
399
  // The full suite already ran in unitTests; the filtered slices stay cheap (compilation is
305
400
  // cached) while `--rerun` forces the tests themselves to EXECUTE — see stepUnitTests.
@@ -531,13 +626,18 @@ function stepE2eSmoke() {
531
626
  const stepsForProfile = {
532
627
  // scaffold: what `create-cmp --verify` proves at stamp time — specCoverage,
533
628
  // the full JVM tier (unit + conformance + golden + UI tests) plus the Android build.
534
- scaffold: [stepSpecCoverage, stepApprovals, stepComponentStories, stepArchDoc, stepBuild, stepUnitTests],
629
+ scaffold: [stepSpecCoverage, stepApprovals, stepComponentStories, stepReachability, stepArchDoc, stepBuild, stepUnitTests],
535
630
  local: [
536
631
  stepSpecCoverage,
537
632
  stepApprovals,
538
633
  stepComponentStories,
634
+ stepReachability,
539
635
  stepArchDoc,
540
636
  stepBuild,
637
+ // Release stays OUT of `scaffold`: stamp-time --verify promises a green first build, and
638
+ // an R8 pass would add minutes to every scaffold to re-prove what this step proves here.
639
+ // local + ci is where release rot gets caught before it reaches anyone.
640
+ stepReleaseBuild,
541
641
  stepUnitTests,
542
642
  stepConformance,
543
643
  stepGoldenTrees,