create-cmp-cli 0.10.0 → 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.
- package/package.json +1 -1
- package/src/lib/tabs.mjs +26 -0
- package/template/.claude/skills/add-feature/SKILL.md +20 -0
- package/template/.claude/skills/add-repository/SKILL.md +6 -0
- package/template/.claude/skills/add-screen/SKILL.md +6 -0
- package/template/CLAUDE.md +81 -2
- package/template/composeApp/build.gradle.kts +25 -0
- package/template/composeApp/proguard-rules.pro +12 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +16 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +2 -5
- package/template/qa/approve.mjs +119 -11
- package/template/qa/lib/approvals.mjs +602 -21
- package/template/qa/lib/feature-brief.mjs +324 -0
- package/template/qa/lib/inputs-hash.mjs +43 -6
- package/template/qa/lib/reachability.mjs +211 -0
- package/template/qa/lib/spec-coverage.mjs +80 -0
- package/template/qa/verify.mjs +148 -48
package/template/qa/verify.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
57
|
-
// 1. COORDINATE: this lane stamps a marker file
|
|
58
|
-
// service defers renders while it exists
|
|
59
|
-
// never wedges the eyes for long).
|
|
60
|
-
// 2.
|
|
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.
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
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
|
|
151
|
-
const
|
|
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,
|