create-cmp-cli 0.8.0 → 0.10.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 +83 -9
- package/llms.txt +5 -1
- package/package.json +1 -1
- package/src/lib/adr-seed.mjs +178 -0
- package/src/lib/tabs.mjs +97 -4
- package/src/scaffold.mjs +52 -1
- package/template/.claude/skills/add-feature/SKILL.md +35 -10
- package/template/.claude/skills/add-repository/SKILL.md +1 -1
- package/template/.claude/skills/add-screen/SKILL.md +13 -7
- package/template/.githooks/pre-push +24 -0
- package/template/CLAUDE.md +213 -47
- package/template/README.md +32 -27
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/CrashRecorder.kt +99 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/DbInspector.kt +144 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorCatalog.kt +19 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +177 -21
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +10 -0
- package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +62 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/brand/BrandMark.kt +75 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +63 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +48 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +22 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +31 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +70 -6
- package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt +52 -0
- package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +29 -4
- package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +8 -6
- package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +39 -27
- package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +10 -6
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +300 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt +5 -0
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt +91 -1
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
- package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt +14 -1
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +250 -16
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
- package/template/docs/ARCHITECTURE.md +372 -34
- package/template/docs/TESTING.md +13 -5
- package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
- package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
- package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
- package/template/qa/approvals.json +42 -0
- package/template/qa/approve.mjs +139 -0
- package/template/qa/arch-doc.mjs +69 -0
- package/template/qa/comment.mjs +76 -0
- package/template/qa/comments.json +4 -0
- package/template/qa/e2e/smoke.yaml +6 -0
- package/template/qa/golden/home.json +3 -3
- package/template/qa/lib/a11y.mjs +17 -8
- package/template/qa/lib/approvals.mjs +822 -0
- package/template/qa/lib/arch-doc.mjs +451 -0
- package/template/qa/lib/comments.mjs +252 -0
- package/template/qa/lib/component-stories.mjs +183 -0
- package/template/qa/lib/inputs-hash.mjs +5 -1
- package/template/qa/scaffold-feature.mjs +184 -67
- package/template/qa/setup-hooks.mjs +33 -0
- package/template/qa/verify.mjs +181 -15
- package/template/qa/walkthrough.mjs +499 -0
- package/template/specs/app-base.spec.md +49 -7
- package/template/specs/home.spec.md +7 -4
- package/template/specs/intent.md +50 -0
package/template/qa/verify.mjs
CHANGED
|
@@ -26,6 +26,9 @@ import { fileURLToPath } from "node:url";
|
|
|
26
26
|
|
|
27
27
|
import { computeInputsHash } from "./lib/inputs-hash.mjs";
|
|
28
28
|
import { compareTokenDrift } from "./lib/token-drift.mjs";
|
|
29
|
+
import { evaluateApprovalsGate } from "./lib/approvals.mjs";
|
|
30
|
+
import { evaluateComponentStoryParity } from "./lib/component-stories.mjs";
|
|
31
|
+
import { ARCH_DOC_REL_PATH, SECTION_IDS, regenerateArchDoc } from "./lib/arch-doc.mjs";
|
|
29
32
|
|
|
30
33
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
31
34
|
const EVIDENCE_DIR = path.join(ROOT, "qa", "evidence");
|
|
@@ -46,6 +49,30 @@ function sh(cmd, opts = {}) {
|
|
|
46
49
|
return { ok, status: res.status, error: res.error?.message, out: `${res.stdout ?? ""}${res.stderr ?? ""}`, durationMs: Date.now() - started };
|
|
47
50
|
}
|
|
48
51
|
|
|
52
|
+
// ── Preview-daemon coexistence ──────────────────────────────────────────────
|
|
53
|
+
// The preview daemon (the eyes) and this lane both spawn Gradle against this
|
|
54
|
+
// project and share composeApp/build/kspCaches, whose KSP incremental storage
|
|
55
|
+
// 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
|
|
61
|
+
// and retries once — the manual recovery that always worked, automated.
|
|
62
|
+
const LANE_MARKER = path.join(ROOT, "composeApp", "build", ".cmp-lane-in-progress");
|
|
63
|
+
const KSP_COLLISION_RE = /Storage for \[[^\]]*\] is already registered/;
|
|
64
|
+
|
|
65
|
+
function shGradle(cmd, opts = {}) {
|
|
66
|
+
const first = sh(cmd, opts);
|
|
67
|
+
if (first.ok || !KSP_COLLISION_RE.test(first.out)) return first;
|
|
68
|
+
console.error("· KSP cache collision (concurrent Gradle — the preview daemon?) — clearing kspCaches, retrying once");
|
|
69
|
+
fs.rmSync(path.join(ROOT, "composeApp", "build", "kspCaches"), { recursive: true, force: true });
|
|
70
|
+
const retry = sh(cmd, opts);
|
|
71
|
+
retry.durationMs += first.durationMs;
|
|
72
|
+
retry.selfHealed = "ksp-cache-collision";
|
|
73
|
+
return retry;
|
|
74
|
+
}
|
|
75
|
+
|
|
49
76
|
function tryGit(cmd) {
|
|
50
77
|
try {
|
|
51
78
|
return execSync(`git ${cmd}`, { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
@@ -54,6 +81,22 @@ function tryGit(cmd) {
|
|
|
54
81
|
}
|
|
55
82
|
}
|
|
56
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Line-oriented git output, WITHOUT [tryGit]'s trim. `git status --porcelain`
|
|
86
|
+
* has significant leading whitespace: an unstaged modification is `" M path"`,
|
|
87
|
+
* so trimming the whole blob eats the first line's leading space — and a fixed
|
|
88
|
+
* `slice(3)` then swallows that path's first character. The receipt would name
|
|
89
|
+
* a file that does not exist. Only trailing newlines are dropped here.
|
|
90
|
+
*/
|
|
91
|
+
function tryGitLines(cmd) {
|
|
92
|
+
try {
|
|
93
|
+
const out = execSync(`git ${cmd}`, { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
94
|
+
return out.replace(/\n+$/, "").split("\n").filter(Boolean);
|
|
95
|
+
} catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
57
100
|
function junitSummary(dir) {
|
|
58
101
|
if (!fs.existsSync(dir)) return null;
|
|
59
102
|
let tests = 0, failures = 0, errors = 0, skipped = 0;
|
|
@@ -176,8 +219,79 @@ function stepSpecCoverage() {
|
|
|
176
219
|
};
|
|
177
220
|
}
|
|
178
221
|
|
|
222
|
+
// Human-approval gate (VERIFICATION-LAYER-DESIGN.md §2) — pure Node, no Gradle,
|
|
223
|
+
// same grouping as specCoverage. The decision itself lives in
|
|
224
|
+
// qa/lib/approvals.mjs (evaluateApprovalsGate); this step only adds the
|
|
225
|
+
// name/duration bookkeeping every step in this file carries.
|
|
226
|
+
function stepApprovals() {
|
|
227
|
+
const started = Date.now();
|
|
228
|
+
const { verdict, reason, statuses } = evaluateApprovalsGate(ROOT);
|
|
229
|
+
return {
|
|
230
|
+
name: "approvals",
|
|
231
|
+
verdict,
|
|
232
|
+
reason,
|
|
233
|
+
durationMs: Date.now() - started,
|
|
234
|
+
details: { artifacts: statuses.map((s) => ({ id: s.id, status: s.status, hash: s.hash })) },
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Component ↔ story parity gate (STUDIO-REDESIGN.md §3.3) — pure Node, no
|
|
239
|
+
// Gradle, same grouping as specCoverage/approvals. The decision itself lives
|
|
240
|
+
// in qa/lib/component-stories.mjs (evaluateComponentStoryParity); this step
|
|
241
|
+
// only adds the name/duration bookkeeping every step in this file carries.
|
|
242
|
+
function stepComponentStories() {
|
|
243
|
+
const started = Date.now();
|
|
244
|
+
const { verdict, reason, details } = evaluateComponentStoryParity(ROOT);
|
|
245
|
+
return { name: "componentStories", verdict, reason, durationMs: Date.now() - started, details };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Architecture-doc freshness gate (Wave B, docs/proposals/architecture-document-
|
|
249
|
+
// standard.md §6) — pure Node, no Gradle, same grouping as specCoverage/
|
|
250
|
+
// approvals. The decision itself lives in qa/lib/arch-doc.mjs
|
|
251
|
+
// (regenerateArchDoc); this step only adds the name/duration bookkeeping every
|
|
252
|
+
// step in this file carries, plus wording the FAIL reason for an AI
|
|
253
|
+
// collaborator (name the stale/missing section, name the fix command).
|
|
254
|
+
function stepArchDoc() {
|
|
255
|
+
const started = Date.now();
|
|
256
|
+
const elapsed = () => Date.now() - started;
|
|
257
|
+
|
|
258
|
+
const result = regenerateArchDoc(ROOT);
|
|
259
|
+
if (!result.ok) {
|
|
260
|
+
return { name: "archDoc", verdict: "SKIP", reason: `${result.reason} — nothing to check`, durationMs: elapsed() };
|
|
261
|
+
}
|
|
262
|
+
if (result.unknownSections.length > 0) {
|
|
263
|
+
return {
|
|
264
|
+
name: "archDoc",
|
|
265
|
+
verdict: "FAIL",
|
|
266
|
+
reason: `${ARCH_DOC_REL_PATH} has cmp:generated marker(s) with no registered generator: ${result.unknownSections.join(", ")} — add a generator in qa/lib/arch-doc.mjs or remove the marker.`,
|
|
267
|
+
durationMs: elapsed(),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const stale = result.changed || result.missingSections.length > 0;
|
|
272
|
+
if (!stale) {
|
|
273
|
+
return { name: "archDoc", verdict: "PASS", durationMs: elapsed(), details: { sectionsChecked: SECTION_IDS.length } };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const lines = [`${ARCH_DOC_REL_PATH} is stale — a generated section no longer matches the tree:`];
|
|
277
|
+
for (const id of result.changedSections) {
|
|
278
|
+
lines.push(` [${id}] regenerating would change this section.`);
|
|
279
|
+
}
|
|
280
|
+
for (const id of result.missingSections) {
|
|
281
|
+
lines.push(` [${id}] marker missing from the doc entirely — never generated.`);
|
|
282
|
+
}
|
|
283
|
+
lines.push("Run: node qa/arch-doc.mjs");
|
|
284
|
+
return {
|
|
285
|
+
name: "archDoc",
|
|
286
|
+
verdict: "FAIL",
|
|
287
|
+
reason: lines.join("\n"),
|
|
288
|
+
durationMs: elapsed(),
|
|
289
|
+
details: { changedSections: result.changedSections, missingSections: result.missingSections },
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
179
293
|
function stepBuild() {
|
|
180
|
-
const res =
|
|
294
|
+
const res = shGradle(`${GRADLEW} :composeApp:assembleDebug --console=plain`);
|
|
181
295
|
return {
|
|
182
296
|
name: "build",
|
|
183
297
|
verdict: res.ok ? "PASS" : "FAIL",
|
|
@@ -191,7 +305,7 @@ function stepBuild() {
|
|
|
191
305
|
// cached) while `--rerun` forces the tests themselves to EXECUTE — see stepUnitTests.
|
|
192
306
|
function gradleTestStep(name, testsFilter, failHint) {
|
|
193
307
|
return () => {
|
|
194
|
-
const res =
|
|
308
|
+
const res = shGradle(`${GRADLEW} :composeApp:desktopTest --rerun --tests "${testsFilter}" --console=plain`);
|
|
195
309
|
return {
|
|
196
310
|
name,
|
|
197
311
|
verdict: res.ok ? "PASS" : "FAIL",
|
|
@@ -209,7 +323,7 @@ function stepUnitTests() {
|
|
|
209
323
|
// produce byte-identical sources, and golden baselines aren't compile inputs), so the
|
|
210
324
|
// receipt would attest tests that never executed. Compilation stays cached — only the
|
|
211
325
|
// test execution is forced.
|
|
212
|
-
const res =
|
|
326
|
+
const res = shGradle(`${GRADLEW} :composeApp:desktopTest --rerun --console=plain`);
|
|
213
327
|
const summary = junitSummary(path.join(ROOT, "composeApp/build/test-results/desktopTest"));
|
|
214
328
|
return {
|
|
215
329
|
name: "unitTests",
|
|
@@ -362,7 +476,7 @@ function stepE2eSmoke() {
|
|
|
362
476
|
if (!maestroAvailable()) {
|
|
363
477
|
return { name: "e2eSmoke", verdict: "SKIP", reason: "maestro CLI not installed — curl -fsSL https://get.maestro.mobile.dev | bash", durationMs: 0 };
|
|
364
478
|
}
|
|
365
|
-
const install =
|
|
479
|
+
const install = shGradle(`${GRADLEW} :composeApp:installDebug --console=plain`);
|
|
366
480
|
if (!install.ok) {
|
|
367
481
|
return { name: "e2eSmoke", verdict: "FAIL", reason: "installDebug failed — the APK could not be installed on the attached device", durationMs: install.durationMs };
|
|
368
482
|
}
|
|
@@ -372,15 +486,44 @@ function stepE2eSmoke() {
|
|
|
372
486
|
// that steal focus over the app — a Maestro assert would then see only the dialog;
|
|
373
487
|
// - MAESTRO_DRIVER_STARTUP_TIMEOUT gives the UiAutomator2 driver a generous budget to come
|
|
374
488
|
// up on a slow emulator (the built-in default gives up too early under load).
|
|
375
|
-
// Both are benign, reversible, and only touch the device while the lane is driving it
|
|
489
|
+
// Both are benign, reversible, and only touch the device while the lane is driving it —
|
|
490
|
+
// hide_error_dialogs is restored to its pre-run value (or deleted, returning the device
|
|
491
|
+
// to its default) in the finally below, on every exit path.
|
|
492
|
+
// hide_error_dialogs suppresses the OS dialog, NEVER the underlying event — so after the
|
|
493
|
+
// run we grep the device log for ANR/crash lines the dialog would have shown, and FAIL on
|
|
494
|
+
// them. The eyes must report what automation stability had to hide.
|
|
495
|
+
const prevHideErrorDialogs = sh("adb shell settings get global hide_error_dialogs").out.trim();
|
|
376
496
|
sh("adb shell settings put global hide_error_dialogs 1");
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
497
|
+
sh("adb logcat -c"); // clear so the post-run dump only reflects this run
|
|
498
|
+
try {
|
|
499
|
+
const res = sh("maestro test qa/e2e/smoke.yaml", { env: { ...process.env, MAESTRO_DRIVER_STARTUP_TIMEOUT: "120000" } });
|
|
500
|
+
if (!res.ok) {
|
|
501
|
+
return {
|
|
502
|
+
name: "e2eSmoke",
|
|
503
|
+
verdict: "FAIL",
|
|
504
|
+
reason: `Maestro smoke failed (flow cites the SHELL spec clauses it proves):\n${res.out.split("\n").slice(-15).join("\n")}`,
|
|
505
|
+
durationMs: install.durationMs + res.durationMs,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
const anrDump = sh("adb logcat -d -b system,crash,main");
|
|
509
|
+
const anrRe = /ANR in |FATAL EXCEPTION/i;
|
|
510
|
+
if (anrDump.ok && anrRe.test(anrDump.out)) {
|
|
511
|
+
const anrLines = anrDump.out.split("\n").filter((l) => anrRe.test(l)).slice(0, 10).join("\n");
|
|
512
|
+
return {
|
|
513
|
+
name: "e2eSmoke",
|
|
514
|
+
verdict: "FAIL",
|
|
515
|
+
reason: `Maestro smoke passed, but the device log shows an ANR/crash during the run (hide_error_dialogs only suppresses the OS dialog, never the underlying event):\n${anrLines}`,
|
|
516
|
+
durationMs: install.durationMs + res.durationMs,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
return { name: "e2eSmoke", verdict: "PASS", durationMs: install.durationMs + res.durationMs };
|
|
520
|
+
} finally {
|
|
521
|
+
if (prevHideErrorDialogs && prevHideErrorDialogs !== "null") {
|
|
522
|
+
sh(`adb shell settings put global hide_error_dialogs ${prevHideErrorDialogs}`);
|
|
523
|
+
} else {
|
|
524
|
+
sh("adb shell settings delete global hide_error_dialogs");
|
|
525
|
+
}
|
|
526
|
+
}
|
|
384
527
|
}
|
|
385
528
|
|
|
386
529
|
// ── Lane ───────────────────────────────────────────────────────────────────
|
|
@@ -388,9 +531,12 @@ function stepE2eSmoke() {
|
|
|
388
531
|
const stepsForProfile = {
|
|
389
532
|
// scaffold: what `create-cmp --verify` proves at stamp time — specCoverage,
|
|
390
533
|
// the full JVM tier (unit + conformance + golden + UI tests) plus the Android build.
|
|
391
|
-
scaffold: [stepSpecCoverage, stepBuild, stepUnitTests],
|
|
534
|
+
scaffold: [stepSpecCoverage, stepApprovals, stepComponentStories, stepArchDoc, stepBuild, stepUnitTests],
|
|
392
535
|
local: [
|
|
393
536
|
stepSpecCoverage,
|
|
537
|
+
stepApprovals,
|
|
538
|
+
stepComponentStories,
|
|
539
|
+
stepArchDoc,
|
|
394
540
|
stepBuild,
|
|
395
541
|
stepUnitTests,
|
|
396
542
|
stepConformance,
|
|
@@ -407,7 +553,12 @@ if (!stepsForProfile[profile]) {
|
|
|
407
553
|
process.exit(2);
|
|
408
554
|
}
|
|
409
555
|
|
|
556
|
+
// Stamp the lane marker for the run's duration (coexistence defense 1 above);
|
|
557
|
+
// always removed, even on a failing step, so the eyes only ever defer briefly.
|
|
558
|
+
fs.mkdirSync(path.dirname(LANE_MARKER), { recursive: true });
|
|
559
|
+
fs.writeFileSync(LANE_MARKER, `${process.pid} ${new Date().toISOString()}\n`);
|
|
410
560
|
const steps = [];
|
|
561
|
+
try {
|
|
411
562
|
for (const step of stepsForProfile[profile]) {
|
|
412
563
|
const result = step();
|
|
413
564
|
steps.push(result);
|
|
@@ -417,9 +568,20 @@ for (const step of stepsForProfile[profile]) {
|
|
|
417
568
|
}
|
|
418
569
|
if (result.name === "build" && result.verdict === "FAIL") break; // nothing downstream is meaningful
|
|
419
570
|
}
|
|
571
|
+
} finally {
|
|
572
|
+
fs.rmSync(LANE_MARKER, { force: true });
|
|
573
|
+
}
|
|
420
574
|
|
|
421
575
|
const verdict = steps.some((s) => s.verdict === "FAIL") ? "FAIL" : "PASS";
|
|
422
576
|
|
|
577
|
+
// Receipt STRENGTH — a desktop-only green and an on-device green are different
|
|
578
|
+
// claims, and the difference should never live only in the SKIP lines. Device-
|
|
579
|
+
// dependent steps that actually RAN (PASSed) are named on the receipt and in the
|
|
580
|
+
// verdict line: "PASS (on-device: e2eSmoke)" vs "PASS (desktop-only)".
|
|
581
|
+
const DEVICE_STEPS = ["e2eSmoke", "tokenDrift"];
|
|
582
|
+
const onDeviceSteps = steps.filter((s) => DEVICE_STEPS.includes(s.name) && s.verdict === "PASS").map((s) => s.name);
|
|
583
|
+
const strengthLabel = onDeviceSteps.length ? `on-device: ${onDeviceSteps.join("+")}` : "desktop-only";
|
|
584
|
+
|
|
423
585
|
// Artifacts: hash whatever the run left under qa-artifacts/ (never committed).
|
|
424
586
|
const artifacts = [];
|
|
425
587
|
if (fs.existsSync(ARTIFACTS_DIR)) {
|
|
@@ -448,13 +610,14 @@ const receipt = {
|
|
|
448
610
|
verdict,
|
|
449
611
|
commit: {
|
|
450
612
|
sha: tryGit("rev-parse HEAD"),
|
|
451
|
-
dirty: (
|
|
613
|
+
dirty: tryGitLines("status --porcelain").map((l) => l.slice(3)).sort(),
|
|
452
614
|
},
|
|
453
615
|
inputs: {
|
|
454
616
|
hash: inputs.hash,
|
|
455
617
|
fileCount: inputs.fileCount,
|
|
456
618
|
},
|
|
457
619
|
steps,
|
|
620
|
+
strength: { onDeviceSteps },
|
|
458
621
|
artifacts,
|
|
459
622
|
toolVersions: {
|
|
460
623
|
node: process.version,
|
|
@@ -465,8 +628,11 @@ const receipt = {
|
|
|
465
628
|
|
|
466
629
|
fs.mkdirSync(EVIDENCE_DIR, { recursive: true });
|
|
467
630
|
fs.writeFileSync(path.join(EVIDENCE_DIR, "latest.json"), `${JSON.stringify(receipt, null, 2)}\n`);
|
|
631
|
+
// latest.json is the single receipt-of-record. Commit it with your change: the
|
|
632
|
+
// studio console's Evidence audit trail reconstructs the full history from the
|
|
633
|
+
// git log of this file — every commit is one verified, attributed state.
|
|
468
634
|
|
|
469
635
|
if (asJson) console.log(JSON.stringify(receipt, null, 2));
|
|
470
|
-
else console.log(`\n${verdict === "PASS" ? "✅" : "❌"} verify lane: ${verdict} — receipt written to qa/evidence/latest.json (commit it with your change)`);
|
|
636
|
+
else console.log(`\n${verdict === "PASS" ? "✅" : "❌"} verify lane: ${verdict} (${strengthLabel}) — receipt written to qa/evidence/latest.json (commit it with your change)`);
|
|
471
637
|
|
|
472
638
|
process.exit(verdict === "PASS" ? 0 : 1);
|