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.
- package/README.md +3 -3
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/packages/harness/src/approve.mjs +30 -2
- package/packages/harness/src/lib/approvals.mjs +74 -10
- package/packages/harness/src/lib/evidence-level.mjs +3 -1
- package/packages/harness/src/lib/flight-recorder.mjs +47 -2
- package/packages/harness/src/lib/inputs-hash.mjs +71 -3
- package/packages/harness/src/lib/lane-narrator.mjs +97 -0
- package/packages/harness/src/lib/lane-runner.mjs +173 -0
- package/packages/harness/src/lib/plan.mjs +286 -20
- package/packages/harness/src/lib/receipt-validate.mjs +56 -1
- package/packages/harness/src/lib/spec-coverage.mjs +111 -3
- package/packages/harness/src/lib/step-cache.mjs +1 -1
- package/packages/harness/src/lib/step-outcomes.mjs +123 -0
- package/packages/harness/src/lib/steps-cmp.mjs +1284 -0
- package/packages/harness/src/lib/walk.mjs +67 -17
- package/packages/harness/src/receipt-check.mjs +80 -4
- package/packages/harness/src/verify.mjs +119 -1197
- package/packages/receipts/src/index.mjs +1 -0
- package/packages/receipts/src/inputs-hash.mjs +71 -3
- package/packages/receipts/src/receipt-validate.mjs +56 -1
- package/template/CLAUDE.md +52 -6
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +1 -1
- package/template/gitignore +3 -0
- package/template/qa/approve.mjs +30 -2
- package/template/qa/lib/approvals.mjs +74 -10
- package/template/qa/lib/evidence-level.mjs +3 -1
- package/template/qa/lib/flight-recorder.mjs +47 -2
- package/template/qa/lib/inputs-hash.mjs +71 -3
- package/template/qa/lib/lane-narrator.mjs +97 -0
- package/template/qa/lib/lane-runner.mjs +173 -0
- package/template/qa/lib/plan.mjs +286 -20
- package/template/qa/lib/receipt-validate.mjs +56 -1
- package/template/qa/lib/spec-coverage.mjs +111 -3
- package/template/qa/lib/step-cache.mjs +1 -1
- package/template/qa/lib/step-outcomes.mjs +123 -0
- package/template/qa/lib/steps-cmp.mjs +1284 -0
- package/template/qa/lib/walk.mjs +67 -17
- package/template/qa/receipt-check.mjs +80 -4
- package/template/qa/verify.mjs +119 -1197
- package/template/specs/README.md +26 -0
package/template/qa/verify.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// The verify lane — this project's single verification gate.
|
|
3
3
|
//
|
|
4
|
-
// node qa/verify.mjs [--profile scaffold|local|ci|release] [--fast] [--json]
|
|
4
|
+
// node qa/verify.mjs [--profile smoke|scaffold|local|ci|nightly|release] [--fast] [--json]
|
|
5
5
|
//
|
|
6
6
|
// Runs every verification step this project carries, aggregates a typed
|
|
7
7
|
// PASS/FAIL verdict, and writes the evidence receipt to qa/evidence/latest.json.
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
// done without it). Binary artifacts under qa-artifacts/ are never committed;
|
|
14
14
|
// the receipt references them by path + sha256.
|
|
15
15
|
//
|
|
16
|
-
// Verdicts per step: PASS | FAIL | SKIP
|
|
16
|
+
// Verdicts per step: PASS | FAIL | SKIP | ERROR (could not run — a deadline, zero
|
|
17
|
+
// tests, a throw; never an accusation, never evidence). The lane verdict is PASS iff no step
|
|
17
18
|
// FAILed. SKIPs are recorded with reasons — green-with-gaps is visible, never
|
|
18
19
|
// silent. Exit code: 0 = PASS, 1 = FAIL.
|
|
19
20
|
//
|
|
@@ -31,23 +32,16 @@ import path from "node:path";
|
|
|
31
32
|
import { fileURLToPath } from "node:url";
|
|
32
33
|
|
|
33
34
|
import { computeInputsHash } from "./lib/inputs-hash.mjs";
|
|
34
|
-
import { compareTokenDrift } from "./lib/token-drift.mjs";
|
|
35
|
-
import { evaluateApprovalsGate } from "./lib/approvals.mjs";
|
|
36
|
-
import { clauseTierCoverage, scanCitations, scanSpecClauses, walkFiles } from "./lib/spec-coverage.mjs";
|
|
37
|
-
import { evaluateComponentStoryParity } from "./lib/component-stories.mjs";
|
|
38
|
-
import { evaluateReachability } from "./lib/reachability.mjs";
|
|
39
35
|
import { evidenceLevel } from "./lib/evidence-level.mjs";
|
|
40
36
|
import { updateReadmeBadge, README_REL_PATH } from "./lib/evidence-badge.mjs";
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
43
|
-
import {
|
|
44
|
-
import {
|
|
45
|
-
import { DETERMINISM_TIMEZONES, compareOutcomes, parseJUnitOutcomes } from "./lib/determinism.mjs";
|
|
46
|
-
import { evaluateAuditCadence } from "./lib/audit-cadence.mjs";
|
|
47
|
-
import { appendFlightRecord, buildFlightEntry } from "./lib/flight-recorder.mjs";
|
|
37
|
+
import { appendFlightRecord, buildFlightEntry, neverRunTiers, readFlightJournal } from "./lib/flight-recorder.mjs";
|
|
38
|
+
import { StepTimeout, androidChecksOutcome, spawnTimedOut } from "./lib/step-outcomes.mjs";
|
|
39
|
+
import { expectedDurations, runLane } from "./lib/lane-runner.mjs";
|
|
40
|
+
import { createCmpSteps } from "./lib/steps-cmp.mjs";
|
|
48
41
|
import { checkHarnessIntegrity, describeIntegrity, LOCK_PATH } from "./lib/harness-lock.mjs";
|
|
49
42
|
|
|
50
|
-
const
|
|
43
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
44
|
+
const ROOT = path.resolve(HERE, "..");
|
|
51
45
|
const EVIDENCE_DIR = path.join(ROOT, "qa", "evidence");
|
|
52
46
|
const ARTIFACTS_DIR = path.join(ROOT, "qa-artifacts");
|
|
53
47
|
|
|
@@ -57,7 +51,7 @@ const ARTIFACTS_DIR = path.join(ROOT, "qa-artifacts");
|
|
|
57
51
|
// killed). Same refusal-over-fabrication stance as qa/approve.mjs, which
|
|
58
52
|
// refuses an unknown artifact by name rather than guessing: an unknown
|
|
59
53
|
// argument here is refused by name, not swallowed into "run everything".
|
|
60
|
-
const USAGE = `node qa/verify.mjs [--profile scaffold|local|ci|release] [--fast] [--json] [--help]
|
|
54
|
+
const USAGE = `node qa/verify.mjs [--profile smoke|scaffold|local|ci|nightly|release] [--fast] [--json] [--help]
|
|
61
55
|
|
|
62
56
|
The verify lane — this project's single verification gate. Runs every
|
|
63
57
|
verification step this project carries, aggregates a typed PASS/FAIL
|
|
@@ -65,7 +59,7 @@ verdict, and writes the evidence receipt to qa/evidence/latest.json (commit
|
|
|
65
59
|
it with your change — see CLAUDE.md). Exit code: 0 = PASS, 1 = FAIL.
|
|
66
60
|
|
|
67
61
|
Flags:
|
|
68
|
-
--profile <scaffold|local|ci|release>
|
|
62
|
+
--profile <smoke|scaffold|local|ci|nightly|release>
|
|
69
63
|
which step set to run (default: local)
|
|
70
64
|
--fast INNER LOOP ONLY — run the resolved profile
|
|
71
65
|
minus the device/release tier (releaseBuild,
|
|
@@ -107,11 +101,19 @@ Flags:
|
|
|
107
101
|
running anything
|
|
108
102
|
|
|
109
103
|
Profiles:
|
|
104
|
+
smoke the smallest end-to-end lane: every pure-Node gate through the real
|
|
105
|
+
runner, receipt and journal — no Gradle, no device. Seconds. Proves the
|
|
106
|
+
FRAMEWORK returns, both ways; never the change (its receipt is refused
|
|
107
|
+
as done-evidence). Driven by scripts/framework-check.mjs.
|
|
110
108
|
scaffold spec coverage + build + unit tests (what \`create-cmp --verify\`
|
|
111
109
|
proves at stamp time)
|
|
112
110
|
local everything; device-dependent steps SKIP when no device is
|
|
113
111
|
attached
|
|
114
112
|
ci everything; SKIPs are recorded so the pipeline stays honest
|
|
113
|
+
nightly everything ci proves with the determinism probe FORCED ON (it doubles
|
|
114
|
+
the JVM test tier — the budget a scheduled run has and a per-change run
|
|
115
|
+
does not). Proves the HARNESS, not a change: its receipt is refused as
|
|
116
|
+
done-evidence by qa/receipt-check.mjs. Schedule it; never wait on it.
|
|
115
117
|
release everything ci proves PLUS the release-APK smoke (releaseSmoke) —
|
|
116
118
|
the ship-time profile; run it before cutting a release, never
|
|
117
119
|
per-change
|
|
@@ -161,7 +163,9 @@ const mode = fast ? "fast" : "full";
|
|
|
161
163
|
// lane row; asking for it in local/scaffold is refused with the two ways
|
|
162
164
|
// that DO work, instead of silently running a step the requested profile
|
|
163
165
|
// does not own.
|
|
164
|
-
|
|
166
|
+
// evidence-economics S6: the nightly stage carries the probe unconditionally —
|
|
167
|
+
// a scheduled run is exactly where a deliberate double-run belongs.
|
|
168
|
+
const determinism = args.includes("--determinism") || profile === "nightly";
|
|
165
169
|
const profileExplicit = args.includes("--profile");
|
|
166
170
|
if (determinism && fast) {
|
|
167
171
|
console.error(
|
|
@@ -190,11 +194,31 @@ const GRADLEW = process.platform === "win32" ? "gradlew.bat" : "./gradlew";
|
|
|
190
194
|
// do its job; full mode keeps it, byte-identical to before.
|
|
191
195
|
const RERUN = fast ? "" : " --rerun";
|
|
192
196
|
|
|
197
|
+
// The running step's deadline (evidence-economics S4). Set by the step loop
|
|
198
|
+
// before each step from the journal's measured duration for it; every
|
|
199
|
+
// subprocess the step spawns inherits it. A step with no deadline is a hang
|
|
200
|
+
// waiting to happen: androidChecks sat at 0.5% CPU waiting on a device with
|
|
201
|
+
// no bound at all, and the only signal was silence. Module-level because the
|
|
202
|
+
// lane is sequential and single-threaded — one step runs at a time.
|
|
203
|
+
let CURRENT_STEP_DEADLINE_MS = 30 * 60_000;
|
|
204
|
+
|
|
193
205
|
function sh(cmd, opts = {}) {
|
|
194
206
|
const started = Date.now();
|
|
195
207
|
// maxBuffer: first-run Gradle output easily exceeds spawnSync's 1MB default,
|
|
196
208
|
// which would surface as a bogus FAIL (status null / ENOBUFS).
|
|
197
|
-
const res = spawnSync(cmd, {
|
|
209
|
+
const res = spawnSync(cmd, {
|
|
210
|
+
shell: true,
|
|
211
|
+
cwd: ROOT,
|
|
212
|
+
encoding: "utf8",
|
|
213
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
214
|
+
timeout: CURRENT_STEP_DEADLINE_MS,
|
|
215
|
+
killSignal: "SIGTERM",
|
|
216
|
+
...opts,
|
|
217
|
+
});
|
|
218
|
+
// A deadline is not a failure of the thing under test — it is a failure to
|
|
219
|
+
// test. Thrown, so the step loop records ERROR instead of the step reading
|
|
220
|
+
// a null exit status as "the behaviour is broken".
|
|
221
|
+
if (spawnTimedOut(res)) throw new StepTimeout(cmd, opts.timeout ?? CURRENT_STEP_DEADLINE_MS);
|
|
198
222
|
const ok = res.status === 0 && !res.error;
|
|
199
223
|
return { ok, status: res.status, error: res.error?.message, out: `${res.stdout ?? ""}${res.stderr ?? ""}`, durationMs: Date.now() - started };
|
|
200
224
|
}
|
|
@@ -292,1147 +316,16 @@ function tryGitLines(cmd) {
|
|
|
292
316
|
}
|
|
293
317
|
}
|
|
294
318
|
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const walk = (d) => {
|
|
302
|
-
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
|
303
|
-
const p = path.join(d, entry.name);
|
|
304
|
-
if (entry.isDirectory()) {
|
|
305
|
-
walk(p);
|
|
306
|
-
continue;
|
|
307
|
-
}
|
|
308
|
-
if (!entry.name.startsWith("TEST-") || !entry.name.endsWith(".xml")) continue;
|
|
309
|
-
const xml = fs.readFileSync(p, "utf8");
|
|
310
|
-
const m = xml.match(/<testsuite[^>]*tests="(\d+)"[^>]*skipped="(\d+)"[^>]*failures="(\d+)"[^>]*errors="(\d+)"/);
|
|
311
|
-
if (m) {
|
|
312
|
-
tests += Number(m[1]);
|
|
313
|
-
skipped += Number(m[2]);
|
|
314
|
-
failures += Number(m[3]);
|
|
315
|
-
errors += Number(m[4]);
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
};
|
|
319
|
-
walk(dir);
|
|
320
|
-
return { tests, failures, errors, skipped };
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function deviceAttached() {
|
|
324
|
-
const res = sh("adb devices", { timeout: 10_000 });
|
|
325
|
-
if (!res.ok) return false;
|
|
326
|
-
return res.out.split("\n").slice(1).some((l) => /\tdevice$/.test(l.trim().replace(/\s+/g, "\t")));
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// ── The machine-global device lease (qa/lib/device-lease.mjs) ───────────────
|
|
330
|
-
// LANE_MARKER above is per-PROJECT; the device is machine-GLOBAL. A scratch app
|
|
331
|
-
// in /tmp and the real app each stamp their own marker and still share the one
|
|
332
|
-
// emulator — nothing stopped two lanes (or a lane and a live console session)
|
|
333
|
-
// driving it at once, which is the observed wedged-adbd / `device offline` /
|
|
334
|
-
// crossed-app-state failure class. Every device-touching step below takes the
|
|
335
|
-
// lease before touching the device.
|
|
336
|
-
//
|
|
337
|
-
// SCOPE DECISION — once per run, not per step: the lease is acquired lazily by
|
|
338
|
-
// the FIRST device step that actually reaches the device and held until the
|
|
339
|
-
// lane exits (released in the same `finally` as LANE_MARKER). A single run must
|
|
340
|
-
// not thrash acquire/release between adjacent device steps, and holding through
|
|
341
|
-
// the desktop steps interleaved among them (a11y sits between tokenDrift and
|
|
342
|
-
// e2eSmoke) costs nothing — nothing else should drive the device mid-lane
|
|
343
|
-
// anyway, which is the whole point.
|
|
344
|
-
//
|
|
345
|
-
// ON CONTENTION THE STEP RETURNS SKIP — NEVER FAIL: nothing is broken; another
|
|
346
|
-
// run legitimately holds the device. This composes with the evidence ladder
|
|
347
|
-
// (qa/lib/evidence-level.mjs): a SKIPped device step simply does not buy its
|
|
348
|
-
// rung, so contention visibly DEGRADES the evidence level (L2 falls back to L1)
|
|
349
|
-
// instead of corrupting the run with a false red — that degradation being
|
|
350
|
-
// honest and visible is exactly why SKIP is the right verdict.
|
|
351
|
-
let laneDeviceLease = null;
|
|
352
|
-
|
|
353
|
-
/** Serials of devices currently in `device` state (same parse as deviceAttached). */
|
|
354
|
-
function attachedDeviceSerials() {
|
|
355
|
-
const res = sh("adb devices", { timeout: 10_000 });
|
|
356
|
-
if (!res.ok) return [];
|
|
357
|
-
return res.out
|
|
358
|
-
.split("\n")
|
|
359
|
-
.slice(1)
|
|
360
|
-
.map((l) => l.trim())
|
|
361
|
-
.filter(Boolean)
|
|
362
|
-
.map((l) => l.split(/\s+/))
|
|
363
|
-
.filter(([, state]) => state === "device")
|
|
364
|
-
.map(([serial]) => serial);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
/**
|
|
368
|
-
* Acquire (or confirm) the lane's device lease for a device step.
|
|
369
|
-
* Returns null when the lane holds the device; otherwise the SKIP result the
|
|
370
|
-
* step should return verbatim. The serial leased is the one the lane will
|
|
371
|
-
* actually drive: the single attached device, or ANDROID_SERIAL when several
|
|
372
|
-
* are attached (adb/Gradle/Maestro honor the same variable). Ambiguity is
|
|
373
|
-
* SKIPped by name — leasing a guess would protect the wrong device.
|
|
374
|
-
*/
|
|
375
|
-
function leaseDeviceForStep(stepName) {
|
|
376
|
-
if (laneDeviceLease) return null; // already held for this run
|
|
377
|
-
const serials = attachedDeviceSerials();
|
|
378
|
-
if (serials.length === 0) return null; // each step's own guard SKIPs "no device" with its precise reason
|
|
379
|
-
let serial = serials[0];
|
|
380
|
-
if (serials.length > 1) {
|
|
381
|
-
const chosen = process.env.ANDROID_SERIAL;
|
|
382
|
-
if (chosen && serials.includes(chosen)) {
|
|
383
|
-
serial = chosen;
|
|
384
|
-
} else {
|
|
385
|
-
return {
|
|
386
|
-
name: stepName,
|
|
387
|
-
verdict: "SKIP",
|
|
388
|
-
reason: `${serials.length} devices attached (${serials.join(", ")}) — the lane cannot tell which one it would drive, so it leases none rather than guessing. Set ANDROID_SERIAL to the device this lane should own, or detach the extras.`,
|
|
389
|
-
durationMs: 0,
|
|
390
|
-
};
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
const res = acquireDeviceLease({ serial, holder: `verify lane ${stepName}`, root: ROOT });
|
|
394
|
-
if (!res.ok) {
|
|
395
|
-
return {
|
|
396
|
-
name: stepName,
|
|
397
|
-
verdict: "SKIP",
|
|
398
|
-
reason: `device ${serial} is held by ${formatHolder(res.heldBy)} — device evidence is batched, not concurrent; wait for it or run once when it finishes`,
|
|
399
|
-
durationMs: 0,
|
|
400
|
-
};
|
|
401
|
-
}
|
|
402
|
-
if (res.reclaimed) {
|
|
403
|
-
console.error(`· reclaimed a dead device lease on ${serial} (was ${formatHolder(res.reclaimed)})`);
|
|
404
|
-
}
|
|
405
|
-
laneDeviceLease = res.handle;
|
|
406
|
-
return null;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// Settle adb before handing the device to whatever drives it next (Maestro, the
|
|
410
|
-
// instrumented runner). An install task returning 0 means the package manager accepted
|
|
411
|
-
// the APK — NOT that the device is ready to be driven: a reinstall over a running app
|
|
412
|
-
// briefly drops the emulator's adb transport. `adb devices` still says `device`, but a
|
|
413
|
-
// fresh adb client (Maestro's dadb, Gradle's ddmlib) gets `device offline` and dies
|
|
414
|
-
// before the first assertion (observed 4/4 when the live-inspector tier ran earlier in
|
|
415
|
-
// the lane — its port-forward traffic widens the window — and 0/4 when it was skipped).
|
|
416
|
-
// wait-for-device blocks only while the transport is actually down; the kill/start pair
|
|
417
|
-
// ahead of it clears a stale server-side transport entry that survives the device coming
|
|
418
|
-
// back. Neither weakens any assertion — every downstream check still passes on its own
|
|
419
|
-
// merits.
|
|
420
|
-
function settleAdb() {
|
|
421
|
-
sh("adb kill-server");
|
|
422
|
-
sh("adb start-server");
|
|
423
|
-
sh("adb wait-for-device");
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// ── Steps ──────────────────────────────────────────────────────────────────
|
|
427
|
-
// Each returns { name, verdict, reason?, durationMs, details? }. Failure
|
|
428
|
-
// reasons are worded for an AI collaborator to act on.
|
|
429
|
-
|
|
430
|
-
// Spec ↔ test drift gate — pure Node, no Gradle. The clause/citation scan
|
|
431
|
-
// itself lives in qa/lib/spec-coverage.mjs — the SAME scan feature-brief.mjs
|
|
432
|
-
// derives doneness from, so this gate and the Features view can never disagree
|
|
433
|
-
// about a clause. This step owns only the orphan decision + bookkeeping.
|
|
434
|
-
// The first question any verdict depends on: is the lane that is about to
|
|
435
|
-
// issue it the lane this app was given?
|
|
436
|
-
//
|
|
437
|
-
// Without this the receipt is unfalsifiable in one specific way — edit
|
|
438
|
-
// qa/verify.mjs to force every step PASS and the receipt still validates,
|
|
439
|
-
// because the edited file is simply part of the hashed input surface. Hashing
|
|
440
|
-
// the machine-owned region against qa/harness.lock.json closes that: the lane
|
|
441
|
-
// cannot vouch for itself while modified.
|
|
442
|
-
//
|
|
443
|
-
// DELIBERATELY NOT MEMOIZED. Every other pure-Node step can serve a cached
|
|
444
|
-
// PASS when its inputs are unchanged; a cached PASS on an integrity check is
|
|
445
|
-
// precisely the failure it exists to prevent, and 34 file reads are too cheap
|
|
446
|
-
// to be worth the risk.
|
|
447
|
-
//
|
|
448
|
-
// Three states, three verdicts:
|
|
449
|
-
// intact PASS
|
|
450
|
-
// modified FAIL — named files, with the command that restores them
|
|
451
|
-
// unlocked SKIP — an app stamped before locks existed. Nothing is known to
|
|
452
|
-
// be wrong, but nothing is proven either; recording the gap keeps
|
|
453
|
-
// the pipeline honest instead of quietly passing.
|
|
454
|
-
function stepHarnessIntegrity() {
|
|
455
|
-
const started = Date.now();
|
|
456
|
-
const r = checkHarnessIntegrity(ROOT);
|
|
457
|
-
const base = { name: "harnessIntegrity", durationMs: Date.now() - started, harness: r };
|
|
458
|
-
|
|
459
|
-
if (r.status === "intact") {
|
|
460
|
-
return { ...base, verdict: "PASS", note: describeIntegrity(r) };
|
|
461
|
-
}
|
|
462
|
-
if (r.status === "unlocked") {
|
|
463
|
-
return {
|
|
464
|
-
...base,
|
|
465
|
-
verdict: "SKIP",
|
|
466
|
-
reason: `no ${LOCK_PATH} — this app was stamped before harness locks existed. ` +
|
|
467
|
-
"`npx create-cmp-cli upgrade --harness` records one.",
|
|
468
|
-
};
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
const named = [
|
|
472
|
-
...r.modified.map((f) => `modified ${f}`),
|
|
473
|
-
...r.missing.map((f) => `missing ${f}`),
|
|
474
|
-
...r.extra.map((f) => `unrecorded ${f}`),
|
|
475
|
-
];
|
|
476
|
-
return {
|
|
477
|
-
...base,
|
|
478
|
-
verdict: "FAIL",
|
|
479
|
-
reason:
|
|
480
|
-
`the verify lane has been modified since it was installed — ${describeIntegrity(r)}. ` +
|
|
481
|
-
"Lane code is machine-owned: it is byte-identical in every create-cmp app and carries " +
|
|
482
|
-
"no app content, so a local edit is either an accident, a half-applied upgrade, or an " +
|
|
483
|
-
"attempt to make this receipt say something the lane would not. Restore it with " +
|
|
484
|
-
"`npx create-cmp-cli upgrade --harness`, which also reports any genuine local patch " +
|
|
485
|
-
"instead of discarding it.",
|
|
486
|
-
files: named,
|
|
487
|
-
};
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
function stepSpecCoverage() {
|
|
491
|
-
const started = Date.now();
|
|
492
|
-
const specsDir = path.join(ROOT, "specs");
|
|
493
|
-
if (!fs.existsSync(specsDir)) {
|
|
494
|
-
return { name: "specCoverage", verdict: "SKIP", reason: "no specs/ directory in this project", durationMs: Date.now() - started };
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
const clauses = scanSpecClauses(ROOT);
|
|
498
|
-
const tags = scanCitations(ROOT);
|
|
499
|
-
const searchDirs = [path.join(ROOT, "composeApp/src"), path.join(ROOT, "qa/e2e")];
|
|
500
|
-
const files = searchDirs.flatMap((d) => walkFiles(d, [".kt", ".kts", ".yaml", ".yml"]));
|
|
501
|
-
|
|
502
|
-
const citedIds = new Set(tags.map((t) => t.id));
|
|
503
|
-
const orphanClauses = [...clauses.entries()].filter(([, c]) => !c.withdrawn).filter(([id]) => !citedIds.has(id));
|
|
504
|
-
const orphanTags = tags.filter((t) => !clauses.has(t.id) || clauses.get(t.id).withdrawn);
|
|
505
|
-
|
|
506
|
-
if (orphanClauses.length === 0 && orphanTags.length === 0) {
|
|
507
|
-
// Tier visibility, not a gate (industry rule: instrument before you police). A clause
|
|
508
|
-
// cited only from desktop-tier tests can still hide a platform-behavior bug — both
|
|
509
|
-
// production apps shipped alarm/notification defects behind clauses that were
|
|
510
|
-
// "covered" by JVM tests androidMain never ran under. The line names them; the
|
|
511
|
-
// instrumented seam (androidChecks) is where such clauses earn a citation.
|
|
512
|
-
const tiers = clauseTierCoverage(clauses, tags);
|
|
513
|
-
return {
|
|
514
|
-
name: "specCoverage",
|
|
515
|
-
verdict: "PASS",
|
|
516
|
-
durationMs: Date.now() - started,
|
|
517
|
-
details: {
|
|
518
|
-
clauses: [...clauses.values()].filter((c) => !c.withdrawn).length,
|
|
519
|
-
withdrawn: [...clauses.values()].filter((c) => c.withdrawn).length,
|
|
520
|
-
tags: tags.length,
|
|
521
|
-
files: files.length,
|
|
522
|
-
tierNote: tiers.summaryLine,
|
|
523
|
-
},
|
|
524
|
-
};
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
const lines = ["Spec coverage broken — the spec and the tests have drifted apart:"];
|
|
528
|
-
for (const [id, c] of orphanClauses) {
|
|
529
|
-
lines.push(` [${id}] ${c.file} — no durable test cites this clause. Write the test (tag it '// SPEC: ${id}') or withdraw the clause (strike it through).`);
|
|
530
|
-
}
|
|
531
|
-
for (const t of orphanTags) {
|
|
532
|
-
const known = clauses.get(t.id);
|
|
533
|
-
if (known?.withdrawn) {
|
|
534
|
-
lines.push(` // SPEC: ${t.id} at ${t.file}:${t.line} — the test verifies withdrawn behavior (clause ${t.id} in ${known.file} is struck through). Remove the test or un-withdraw the clause.`);
|
|
535
|
-
} else {
|
|
536
|
-
lines.push(` // SPEC: ${t.id} at ${t.file}:${t.line} — no such clause in specs/. Add the clause (AI proposes, human confirms) or fix the id.`);
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
return {
|
|
541
|
-
name: "specCoverage",
|
|
542
|
-
verdict: "FAIL",
|
|
543
|
-
reason: lines.join("\n"),
|
|
544
|
-
durationMs: Date.now() - started,
|
|
545
|
-
details: {
|
|
546
|
-
clauses: [...clauses.values()].filter((c) => !c.withdrawn).length,
|
|
547
|
-
withdrawn: [...clauses.values()].filter((c) => c.withdrawn).length,
|
|
548
|
-
tags: tags.length,
|
|
549
|
-
files: files.length,
|
|
550
|
-
},
|
|
551
|
-
};
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
// Human-approval gate (VERIFICATION-LAYER-DESIGN.md §2) — pure Node, no Gradle,
|
|
555
|
-
// same grouping as specCoverage. The decision itself lives in
|
|
556
|
-
// qa/lib/approvals.mjs (evaluateApprovalsGate); this step only adds the
|
|
557
|
-
// name/duration bookkeeping every step in this file carries.
|
|
558
|
-
function stepApprovals() {
|
|
559
|
-
const started = Date.now();
|
|
560
|
-
const { verdict, reason, statuses } = evaluateApprovalsGate(ROOT);
|
|
561
|
-
return {
|
|
562
|
-
name: "approvals",
|
|
563
|
-
verdict,
|
|
564
|
-
reason,
|
|
565
|
-
durationMs: Date.now() - started,
|
|
566
|
-
details: { artifacts: statuses.map((s) => ({ id: s.id, status: s.status, hash: s.hash })) },
|
|
567
|
-
};
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
// There is deliberately NO feature-doneness step here (CHANGE-FLOW-DESIGN.md
|
|
571
|
-
// §7): a feature's doneness is DERIVED from gates this lane already runs —
|
|
572
|
-
// specCoverage fails an uncited clause, the test steps fail a broken promise,
|
|
573
|
-
// and the receipt's inputs.hash attests the tree. A second mechanism would be
|
|
574
|
-
// a second truth.
|
|
575
|
-
|
|
576
|
-
// Component ↔ story parity gate (STUDIO-REDESIGN.md §3.3) — pure Node, no
|
|
577
|
-
// Gradle, same grouping as specCoverage/approvals. The decision itself lives
|
|
578
|
-
// in qa/lib/component-stories.mjs (evaluateComponentStoryParity); this step
|
|
579
|
-
// only adds the name/duration bookkeeping every step in this file carries.
|
|
580
|
-
function stepComponentStories() {
|
|
581
|
-
const started = Date.now();
|
|
582
|
-
const { verdict, reason, details } = evaluateComponentStoryParity(ROOT);
|
|
583
|
-
return { name: "componentStories", verdict, reason, durationMs: Date.now() - started, details };
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
// Navigation-reachability gate (task FI-7, docs/AUTONOMY-GAPS.md §3) — pure
|
|
587
|
-
// Node, no Gradle, same grouping as specCoverage/approvals/componentStories.
|
|
588
|
-
// The decision itself lives in qa/lib/reachability.mjs (evaluateReachability);
|
|
589
|
-
// this step only adds the name/duration bookkeeping every step in this file
|
|
590
|
-
// carries. Closes the exact hole a real feature slipped through: every other
|
|
591
|
-
// gate PASSed while its screen was wired into nothing.
|
|
592
|
-
function stepReachability() {
|
|
593
|
-
const started = Date.now();
|
|
594
|
-
const { verdict, reason, details } = evaluateReachability(ROOT);
|
|
595
|
-
return { name: "reachability", verdict, reason, durationMs: Date.now() - started, details };
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
// Architecture-doc freshness gate (Wave B, docs/proposals/architecture-document-
|
|
599
|
-
// standard.md §6) — pure Node, no Gradle, same grouping as specCoverage/
|
|
600
|
-
// approvals. The decision itself lives in qa/lib/arch-doc.mjs
|
|
601
|
-
// (regenerateArchDoc); this step only adds the name/duration bookkeeping every
|
|
602
|
-
// step in this file carries, plus wording the FAIL reason for an AI
|
|
603
|
-
// collaborator (name the stale/missing section, name the fix command).
|
|
604
|
-
function stepArchDoc() {
|
|
605
|
-
const started = Date.now();
|
|
606
|
-
const elapsed = () => Date.now() - started;
|
|
607
|
-
|
|
608
|
-
const result = regenerateArchDoc(ROOT);
|
|
609
|
-
if (!result.ok) {
|
|
610
|
-
return { name: "archDoc", verdict: "SKIP", reason: `${result.reason} — nothing to check`, durationMs: elapsed() };
|
|
611
|
-
}
|
|
612
|
-
if (result.unknownSections.length > 0) {
|
|
613
|
-
return {
|
|
614
|
-
name: "archDoc",
|
|
615
|
-
verdict: "FAIL",
|
|
616
|
-
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.`,
|
|
617
|
-
durationMs: elapsed(),
|
|
618
|
-
};
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
const stale = result.changed || result.missingSections.length > 0;
|
|
622
|
-
if (!stale) {
|
|
623
|
-
return { name: "archDoc", verdict: "PASS", durationMs: elapsed(), details: { sectionsChecked: SECTION_IDS.length } };
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
const lines = [`${ARCH_DOC_REL_PATH} is stale — a generated section no longer matches the tree:`];
|
|
627
|
-
for (const id of result.changedSections) {
|
|
628
|
-
lines.push(` [${id}] regenerating would change this section.`);
|
|
629
|
-
}
|
|
630
|
-
for (const id of result.missingSections) {
|
|
631
|
-
lines.push(` [${id}] marker missing from the doc entirely — never generated.`);
|
|
632
|
-
}
|
|
633
|
-
lines.push("Run: node qa/arch-doc.mjs");
|
|
634
|
-
return {
|
|
635
|
-
name: "archDoc",
|
|
636
|
-
verdict: "FAIL",
|
|
637
|
-
reason: lines.join("\n"),
|
|
638
|
-
durationMs: elapsed(),
|
|
639
|
-
details: { changedSections: result.changedSections, missingSections: result.missingSections },
|
|
640
|
-
};
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
// Schema-history gate — pure Node + git, no Gradle, same grouping as the other
|
|
644
|
-
// evidence checks. Room's exportSchema writes one <version>.json per database per
|
|
645
|
-
// target under composeApp/schemas/. Every version EXCEPT the current highest is a
|
|
646
|
-
// frozen historical record of a database that shipped: migrations are written and
|
|
647
|
-
// validated against those exact bytes, so a regeneration that rewrites them
|
|
648
|
-
// silently corrupts the baseline every future migration is proven against. Only
|
|
649
|
-
// the highest version is the live, in-progress schema — free to change or appear
|
|
650
|
-
// (that IS the current change). This gate exists because schema regeneration
|
|
651
|
-
// looks like harmless build output right up until a shipped user's upgrade fails.
|
|
652
|
-
function stepSchemaHistory() {
|
|
653
|
-
const started = Date.now();
|
|
654
|
-
const elapsed = () => Date.now() - started;
|
|
655
|
-
const schemasRel = path.join("composeApp", "schemas");
|
|
656
|
-
const schemasRoot = path.join(ROOT, schemasRel);
|
|
657
|
-
|
|
658
|
-
if (!fs.existsSync(schemasRoot)) {
|
|
659
|
-
return { name: "schemaHistory", verdict: "SKIP", reason: "no exported Room schemas (composeApp/schemas/ absent) — nothing frozen to guard", durationMs: elapsed() };
|
|
660
|
-
}
|
|
661
|
-
const gitTop = tryGit("rev-parse --show-toplevel");
|
|
662
|
-
if (!gitTop || !tryGit("rev-parse HEAD")) {
|
|
663
|
-
return { name: "schemaHistory", verdict: "SKIP", reason: "no git history yet — schema versions have no committed baseline to be frozen against", durationMs: elapsed() };
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
// Every directory holding versioned schema JSONs, with its highest version on disk.
|
|
667
|
-
const versionFile = /^(\d+)\.json$/;
|
|
668
|
-
const maxVersionByDir = new Map(); // absolute dir path -> highest N among its N.json files
|
|
669
|
-
const walkSchemas = (dir) => {
|
|
670
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
671
|
-
const p = path.join(dir, entry.name);
|
|
672
|
-
if (entry.isDirectory()) walkSchemas(p);
|
|
673
|
-
else {
|
|
674
|
-
const m = entry.name.match(versionFile);
|
|
675
|
-
if (m) maxVersionByDir.set(dir, Math.max(maxVersionByDir.get(dir) ?? 0, Number(m[1])));
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
};
|
|
679
|
-
walkSchemas(schemasRoot);
|
|
680
|
-
|
|
681
|
-
// Tracked schema files whose committed bytes no longer match the tree (staged or
|
|
682
|
-
// unstaged; deletions included). Paths come back relative to the git toplevel.
|
|
683
|
-
// Untracked files never appear here — a brand-new version file is by definition
|
|
684
|
-
// not yet frozen history.
|
|
685
|
-
const dirtyFiles = tryGitLines(`diff --name-only HEAD -- "${schemasRel.replace(/\\/g, "/")}"`);
|
|
319
|
+
// ── The step pack (qa/lib/steps-cmp.mjs, evidence-economics S8b) ─────────────
|
|
320
|
+
// Every step this lane runs, behind one factory that borrows the spine's
|
|
321
|
+
// helpers explicitly. Swap the pack and the same spine verifies a different
|
|
322
|
+
// kind of project.
|
|
323
|
+
const pack = createCmpSteps({ ROOT, HERE, GRADLEW, RERUN, fast, determinism, profile, mode, sh, shGradle, tryGit, tryGitLines, DEGRADED_PATHS });
|
|
324
|
+
const { stepsForProfile, DEVICE_STEPS, FAST_EXCLUDED_NAMES, STEP_FN_BY_NAME } = pack;
|
|
686
325
|
|
|
687
|
-
const violations = [];
|
|
688
|
-
for (const rel of dirtyFiles) {
|
|
689
|
-
const abs = path.resolve(gitTop, rel);
|
|
690
|
-
const m = path.basename(abs).match(versionFile);
|
|
691
|
-
if (!m) continue; // not a versioned schema JSON
|
|
692
|
-
const version = Number(m[1]);
|
|
693
|
-
const dirMax = maxVersionByDir.get(path.dirname(abs));
|
|
694
|
-
// The highest version currently on disk is the live schema — dirty is fine.
|
|
695
|
-
// Anything else (a lower version, or a file whose whole directory is gone)
|
|
696
|
-
// is rewritten/deleted history.
|
|
697
|
-
if (dirMax !== undefined && version === dirMax) continue;
|
|
698
|
-
violations.push(rel);
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
if (violations.length === 0) {
|
|
702
|
-
return { name: "schemaHistory", verdict: "PASS", durationMs: elapsed(), details: { schemaDirs: maxVersionByDir.size } };
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
const lines = [
|
|
706
|
-
"Historical Room schema files were modified or deleted — these are frozen records of shipped databases, and regeneration must never rewrite them (migrations are validated against these exact bytes). Only the current highest version may change. Restore each file:",
|
|
707
|
-
];
|
|
708
|
-
for (const rel of violations) lines.push(` git checkout -- ${rel}`);
|
|
709
|
-
lines.push("If you intended a schema change, bump the database version so a NEW <version>.json is exported instead of overwriting history.");
|
|
710
|
-
return {
|
|
711
|
-
name: "schemaHistory",
|
|
712
|
-
verdict: "FAIL",
|
|
713
|
-
reason: lines.join("\n"),
|
|
714
|
-
durationMs: elapsed(),
|
|
715
|
-
details: { schemaDirs: maxVersionByDir.size, violations },
|
|
716
|
-
};
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
function stepBuild() {
|
|
720
|
-
const res = shGradle(`${GRADLEW} :composeApp:assembleDebug --console=plain`);
|
|
721
|
-
return {
|
|
722
|
-
name: "build",
|
|
723
|
-
verdict: res.ok ? "PASS" : "FAIL",
|
|
724
|
-
reason: res.ok ? undefined : `assembleDebug failed — fix the build before anything else:\n${res.out.split("\n").filter((l) => /error|FAILURE/i.test(l)).slice(0, 12).join("\n")}`,
|
|
725
|
-
durationMs: res.durationMs,
|
|
726
|
-
};
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
// The build nobody runs until the day they need it.
|
|
730
|
-
//
|
|
731
|
-
// assembleDebug passing says nothing about assembleRelease: R8 and `lintVital` only run on
|
|
732
|
-
// the release variant, and BuildConfig is generated PER BUILD TYPE, so a constant declared
|
|
733
|
-
// in one and not the other is a compile error that only release ever sees. All three of
|
|
734
|
-
// those bit this template at once, and none of them were visible from a green debug lane —
|
|
735
|
-
// the first release build ever attempted (2026-07-29) failed three times over.
|
|
736
|
-
//
|
|
737
|
-
// So release is proven at the checkpoint, not discovered at launch. Unsigned: signing needs
|
|
738
|
-
// a keystore, which belongs to whoever ships the app, and this step is about the shrinker
|
|
739
|
-
// and the build graph rather than the signature.
|
|
740
|
-
function stepReleaseBuild() {
|
|
741
|
-
const res = shGradle(`${GRADLEW} :composeApp:assembleRelease --console=plain`);
|
|
742
|
-
return {
|
|
743
|
-
name: "releaseBuild",
|
|
744
|
-
verdict: res.ok ? "PASS" : "FAIL",
|
|
745
|
-
reason: res.ok
|
|
746
|
-
? undefined
|
|
747
|
-
: `assembleRelease failed — the shippable build is broken even though the debug one is fine:\n${res.out
|
|
748
|
-
.split("\n")
|
|
749
|
-
.filter((l) => /error|FAILURE|Missing class|Unresolved/i.test(l))
|
|
750
|
-
.slice(0, 12)
|
|
751
|
-
.join("\n")}`,
|
|
752
|
-
durationMs: res.durationMs,
|
|
753
|
-
};
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
// Runs a filtered slice of the JVM test tier and names the verdict after the gate it proves.
|
|
757
|
-
// The full suite already ran in unitTests; the filtered slices stay cheap (compilation is
|
|
758
|
-
// cached) while `--rerun` forces the tests themselves to EXECUTE — see stepUnitTests.
|
|
759
|
-
// In fast mode the flag is omitted (RERUN, defined with the mode flags above): the
|
|
760
|
-
// integrity mechanism belongs to the runs that produce integrity-bearing artifacts, and a
|
|
761
|
-
// fast receipt has already declared itself non-evidence.
|
|
762
|
-
function gradleTestStep(name, testsFilter, failHint) {
|
|
763
|
-
return () => {
|
|
764
|
-
const res = shGradle(`${GRADLEW} :composeApp:desktopTest${RERUN} --tests "${testsFilter}" --console=plain`);
|
|
765
|
-
return {
|
|
766
|
-
name,
|
|
767
|
-
verdict: res.ok ? "PASS" : "FAIL",
|
|
768
|
-
reason: res.ok
|
|
769
|
-
? undefined
|
|
770
|
-
: `${failHint}\n${res.out.split("\n").filter((l) => /FAILED|\[(ARCH|SHELL|HOME)-\d+\]|error:/i.test(l)).slice(0, 15).join("\n")}`,
|
|
771
|
-
durationMs: res.durationMs,
|
|
772
|
-
};
|
|
773
|
-
};
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
function stepUnitTests() {
|
|
777
|
-
// `--rerun` is EVIDENCE INTEGRITY, not pedantry: without it, Gradle's build cache can
|
|
778
|
-
// restore a PASS recorded against a *different* tree state (deterministic re-scaffolds
|
|
779
|
-
// produce byte-identical sources, and golden baselines aren't compile inputs), so the
|
|
780
|
-
// receipt would attest tests that never executed. Compilation stays cached — only the
|
|
781
|
-
// test execution is forced. Scoped to FULL mode (see RERUN above): the integrity
|
|
782
|
-
// mechanism belongs to the runs that produce integrity-bearing artifacts, and a fast
|
|
783
|
-
// receipt is already declared non-evidence.
|
|
784
|
-
//
|
|
785
|
-
// Fast mode additionally scopes the suite to tests plausibly affected by the
|
|
786
|
-
// working-tree change (qa/lib/affected-tests.mjs): changed .kt files map to
|
|
787
|
-
// `--tests "*<segment>*"` patterns, with a mandatory blast-radius escape hatch (build
|
|
788
|
-
// files, DI, theme, shared components, qa/, anything outside composeApp/src → full
|
|
789
|
-
// suite) and fail-open on every uncertain case (no git, unmappable change). FALSE
|
|
790
|
-
// NEGATIVES ARE ACCEPTABLE HERE AND ONLY HERE: the full, unfiltered suite runs at the
|
|
791
|
-
// checkpoint (the full lane), where done is actually decided. The filter that ran is
|
|
792
|
-
// reported in the step's note and recorded in the (fast-only) receipt, so a filtered
|
|
793
|
-
// run can never be mistaken for the full suite.
|
|
794
|
-
let note;
|
|
795
|
-
let testsArgs = "";
|
|
796
|
-
let affected = null;
|
|
797
|
-
if (fast) {
|
|
798
|
-
const changed = changedWorkingTreePaths(ROOT);
|
|
799
|
-
if (changed === null) {
|
|
800
|
-
note = "full suite — git unavailable, cannot derive the change (fail open)";
|
|
801
|
-
} else {
|
|
802
|
-
const filter = deriveAffectedFilter(changed);
|
|
803
|
-
if (filter.mode === "filtered") {
|
|
804
|
-
testsArgs = filter.patterns.map((p) => ` --tests "${p}"`).join("");
|
|
805
|
-
note = `affected: ${filter.patterns.join(", ")} — ${filter.sourcePaths.length} changed source file(s)`;
|
|
806
|
-
affected = { patterns: filter.patterns, changedFiles: filter.sourcePaths.length };
|
|
807
|
-
} else {
|
|
808
|
-
note = `full suite — ${filter.reason}`;
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
let res = shGradle(`${GRADLEW} :composeApp:desktopTest${RERUN}${testsArgs} --console=plain`);
|
|
813
|
-
if (fast && testsArgs && !res.ok && /No tests found for given includes/.test(res.out)) {
|
|
814
|
-
// The heuristic filter matched no test class at all (e.g. a feature with no tests
|
|
815
|
-
// yet). That is the harness's guess being wrong, not the app — fall back to the
|
|
816
|
-
// full suite in-lane rather than false-redding on our own filter. (RERUN is empty
|
|
817
|
-
// here by construction — this branch only exists in fast mode.)
|
|
818
|
-
const retry = shGradle(`${GRADLEW} :composeApp:desktopTest${RERUN} --console=plain`);
|
|
819
|
-
retry.durationMs += res.durationMs;
|
|
820
|
-
res = retry;
|
|
821
|
-
note = "full suite — the affected-test filter matched no tests (fell back)";
|
|
822
|
-
affected = null;
|
|
823
|
-
DEGRADED_PATHS.push("affected-test filter matched no tests — fell back to the full desktopTest suite");
|
|
824
|
-
}
|
|
825
|
-
const summary = junitSummary(path.join(ROOT, "composeApp/build/test-results/desktopTest"));
|
|
826
|
-
let details = summary ?? undefined;
|
|
827
|
-
if (affected) details = { ...(summary ?? {}), affected };
|
|
828
|
-
return {
|
|
829
|
-
name: "unitTests",
|
|
830
|
-
verdict: res.ok ? "PASS" : "FAIL",
|
|
831
|
-
reason: res.ok
|
|
832
|
-
? undefined
|
|
833
|
-
: `desktopTest failed (${summary ? `${summary.failures + summary.errors} of ${summary.tests} tests` : "see output"}). Fix the failing behavior — do not delete or weaken tests to pass:\n${res.out.split("\n").filter((l) => /FAILED|error:/i.test(l)).slice(0, 12).join("\n")}`,
|
|
834
|
-
note,
|
|
835
|
-
durationMs: res.durationMs,
|
|
836
|
-
details,
|
|
837
|
-
};
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
const stepConformance = gradleTestStep(
|
|
841
|
-
"conformance",
|
|
842
|
-
"*ArchitectureConformanceTest",
|
|
843
|
-
"Architecture conformance violated (specs/app-base.spec.md ARCH clauses). The failing rule names the clause, files, and fix:",
|
|
844
|
-
);
|
|
845
|
-
const stepGoldenTrees = gradleTestStep(
|
|
846
|
-
"goldenTrees",
|
|
847
|
-
"*GoldenTreeTest",
|
|
848
|
-
"Golden-tree drift: a screen's rendered STRUCTURE no longer matches qa/golden/. Unintended → fix your change; intended → regenerate with UPDATE_GOLDEN=1 and declare it:",
|
|
849
|
-
);
|
|
850
|
-
const stepA11y = gradleTestStep(
|
|
851
|
-
"a11y",
|
|
852
|
-
"*A11yConformanceTest",
|
|
853
|
-
"A11y gate failed (SHELL-04): interactive nodes must expose a testTag, text, or contentDescription:",
|
|
854
|
-
);
|
|
855
|
-
|
|
856
|
-
// ── Determinism probe (roadmap §10 item 8) — opt-in, ci-profile ────────────
|
|
857
|
-
// ARCH-13 statically bans ambient time reads — in APP code. A library the
|
|
858
|
-
// app calls can still read the wall clock, and a golden can still depend on
|
|
859
|
-
// the machine's timezone through a seam the static net cannot see (a
|
|
860
|
-
// ViewModel constructed without its injected clock already caused one
|
|
861
|
-
// overnight golden-tree drift). This probe closes that gap DYNAMICALLY: it
|
|
862
|
-
// runs the JVM test tier twice, under two timezones whose local calendar
|
|
863
|
-
// dates never agree — the offsets are 26 hours apart, so any date-derived
|
|
864
|
-
// value differs between the legs at every instant (see DETERMINISM_TIMEZONES
|
|
865
|
-
// in qa/lib/determinism.mjs for why UTC-12/UTC+14 and not UTC/UTC+14) — and
|
|
866
|
-
// FAILs naming every test whose outcome differs between the legs.
|
|
867
|
-
//
|
|
868
|
-
// Mechanics that carry the honesty:
|
|
869
|
-
// - TZ reaches the test JVM through the environment: Gradle forwards the
|
|
870
|
-
// client's environment to the daemon on every build, and test workers
|
|
871
|
-
// fork from the daemon — so the child env below is inherited all the way
|
|
872
|
-
// down to the JVM whose default timezone the tests see.
|
|
873
|
-
// - BOTH legs force --rerun. Without it Gradle would mark the second leg
|
|
874
|
-
// up-to-date (TZ is not a declared build input) and replay the first
|
|
875
|
-
// leg's results — the probe would then compare a run against its own
|
|
876
|
-
// echo and certify a determinism it never tested (the build-cache-replay
|
|
877
|
-
// lesson, again). The legs use the mode-scoped RERUN like every other
|
|
878
|
-
// desktopTest invocation — and because --determinism is refused alongside
|
|
879
|
-
// --fast up front, RERUN is always " --rerun" by the time a leg runs.
|
|
880
|
-
// - Only verdicts and failure output are compared; durations are never even
|
|
881
|
-
// parsed (qa/lib/determinism.mjs), so a timing wobble is structurally
|
|
882
|
-
// unable to trip the probe.
|
|
883
|
-
function stepDeterminism() {
|
|
884
|
-
const started = Date.now();
|
|
885
|
-
const elapsed = () => Date.now() - started;
|
|
886
|
-
if (!determinism) {
|
|
887
|
-
return {
|
|
888
|
-
name: "determinism",
|
|
889
|
-
verdict: "SKIP",
|
|
890
|
-
reason: "determinism probe is opt-in (it runs the JVM test tier twice) — add --determinism to this lane, or run the probe alone: node qa/verify.mjs --determinism",
|
|
891
|
-
durationMs: elapsed(),
|
|
892
|
-
};
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
const resultsDir = path.join(ROOT, "composeApp/build/test-results/desktopTest");
|
|
896
|
-
const legs = [];
|
|
897
|
-
for (const { tz, label } of DETERMINISM_TIMEZONES) {
|
|
898
|
-
const res = shGradle(`${GRADLEW} :composeApp:desktopTest${RERUN} --console=plain`, { env: { ...process.env, TZ: tz } });
|
|
899
|
-
// Parsed NOW, before the next leg overwrites the same results directory.
|
|
900
|
-
const outcomes = parseJUnitOutcomes(resultsDir);
|
|
901
|
-
legs.push({ tz, label, ok: res.ok, outcomes, tail: res.out.split("\n").slice(-8).join("\n") });
|
|
902
|
-
}
|
|
903
|
-
const [a, b] = legs;
|
|
904
|
-
const labelA = `TZ=${a.tz} (${a.label})`;
|
|
905
|
-
const labelB = `TZ=${b.tz} (${b.label})`;
|
|
906
|
-
const countA = Object.keys(a.outcomes).length;
|
|
907
|
-
const countB = Object.keys(b.outcomes).length;
|
|
908
|
-
|
|
909
|
-
if (countA === 0 && countB === 0) {
|
|
910
|
-
// Neither leg produced a single test result: the suite failed before
|
|
911
|
-
// running anything (build error). The probe measured nothing — that is
|
|
912
|
-
// a FAIL that says so, never a PASS by absence of differences.
|
|
913
|
-
return {
|
|
914
|
-
name: "determinism",
|
|
915
|
-
verdict: "FAIL",
|
|
916
|
-
reason: `determinism probe could not execute: desktopTest produced no test results under either timezone — the suite fails before running (fix the build first; this is not a timezone difference):\n${a.tail}`,
|
|
917
|
-
durationMs: elapsed(),
|
|
918
|
-
};
|
|
919
|
-
}
|
|
920
|
-
if (countA === 0 || countB === 0) {
|
|
921
|
-
const ran = countA > 0 ? { label: labelA, n: countA } : { label: labelB, n: countB };
|
|
922
|
-
const empty = countA > 0 ? b : a;
|
|
923
|
-
return {
|
|
924
|
-
name: "determinism",
|
|
925
|
-
verdict: "FAIL",
|
|
926
|
-
reason: `Nondeterminism under timezone shift: desktopTest ran ${ran.n} test(s) under ${ran.label} but produced no results at all under TZ=${empty.tz} (${empty.label}) — the suite itself dies under that zone:\n${empty.tail}`,
|
|
927
|
-
durationMs: elapsed(),
|
|
928
|
-
details: { timezones: DETERMINISM_TIMEZONES.map((t) => t.tz) },
|
|
929
|
-
};
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
const diffs = compareOutcomes(a.outcomes, b.outcomes, labelA, labelB);
|
|
933
|
-
if (diffs.length > 0) {
|
|
934
|
-
const lines = [
|
|
935
|
-
`Nondeterminism under timezone shift — the same tree produced different outcomes under ${labelA} vs ${labelB}. Something reads ambient time or zone past the ARCH-13 net (a library default, an uninjected clock, a golden that captures "today"):`,
|
|
936
|
-
];
|
|
937
|
-
for (const d of diffs.slice(0, 20)) lines.push(` [${d.step}] ${d.test} — ${d.detail}`);
|
|
938
|
-
if (diffs.length > 20) lines.push(` … and ${diffs.length - 20} more differing test(s)`);
|
|
939
|
-
return {
|
|
940
|
-
name: "determinism",
|
|
941
|
-
verdict: "FAIL",
|
|
942
|
-
reason: lines.join("\n"),
|
|
943
|
-
durationMs: elapsed(),
|
|
944
|
-
details: { timezones: DETERMINISM_TIMEZONES.map((t) => t.tz), diffs: diffs.slice(0, 50) },
|
|
945
|
-
};
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
const failedIdentically = Object.values(a.outcomes).filter((o) => o.status !== "pass" && o.status !== "skip").length;
|
|
949
|
-
return {
|
|
950
|
-
name: "determinism",
|
|
951
|
-
verdict: "PASS",
|
|
952
|
-
// Identical red is DETERMINISTIC red: the probe's claim ("no timezone
|
|
953
|
-
// dependence") holds, and the failing tests already belong to
|
|
954
|
-
// unitTests/goldenTrees, which fail the lane on their own merits — a
|
|
955
|
-
// second FAIL here would report the same defect twice under a wrong name.
|
|
956
|
-
note:
|
|
957
|
-
failedIdentically > 0
|
|
958
|
-
? `${failedIdentically} test(s) failed identically under both timezones — deterministic, but red (the owning test steps report it)`
|
|
959
|
-
: undefined,
|
|
960
|
-
durationMs: elapsed(),
|
|
961
|
-
details: { timezones: DETERMINISM_TIMEZONES.map((t) => t.tz), testsCompared: countA },
|
|
962
|
-
};
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
// Live tokenDrift tier (harness M4-D): when a debug app + device are available,
|
|
966
|
-
// fetches the declared catalog and the live semantics tree off the debug-only
|
|
967
|
-
// inspector server (127.0.0.1:9500, see composeApp/src/androidDebug/.../
|
|
968
|
-
// InspectorHttpServer.kt) and runs compareTokenDrift() over them — real runtime
|
|
969
|
-
// drift detection, embedded in the evidence receipt.
|
|
970
|
-
//
|
|
971
|
-
// Infrastructure absence (no device, app not running) is NEVER a FAIL — only
|
|
972
|
-
// actual drift is. curl (via the existing synchronous sh() helper) stands in for
|
|
973
|
-
// an HTTP client here because every step in this lane runs synchronously; a
|
|
974
|
-
// couple of short retries cover the debug app's cold start.
|
|
975
|
-
const INSPECTOR_PORT = 9500;
|
|
976
|
-
|
|
977
|
-
function curlJson(url, timeoutSec = 5) {
|
|
978
|
-
const res = sh(`curl -s -m ${timeoutSec} -w "\\n%{http_code}" "${url}"`);
|
|
979
|
-
if (!res.ok) return { ok: false };
|
|
980
|
-
const out = res.out;
|
|
981
|
-
const idx = out.lastIndexOf("\n");
|
|
982
|
-
const code = (idx >= 0 ? out.slice(idx + 1) : "").trim();
|
|
983
|
-
const bodyText = idx >= 0 ? out.slice(0, idx) : "";
|
|
984
|
-
if (code !== "200") return { ok: false };
|
|
985
|
-
try {
|
|
986
|
-
return { ok: true, body: JSON.parse(bodyText) };
|
|
987
|
-
} catch {
|
|
988
|
-
return { ok: false };
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
function pollHealth(port, attempts, delaySec) {
|
|
993
|
-
let health = curlJson(`http://127.0.0.1:${port}/inspect/health`);
|
|
994
|
-
for (let tries = 1; !health.ok && tries < attempts; tries += 1) {
|
|
995
|
-
sh(`sleep ${delaySec}`);
|
|
996
|
-
health = curlJson(`http://127.0.0.1:${port}/inspect/health`);
|
|
997
|
-
}
|
|
998
|
-
return health;
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
function stepTokenDrift() {
|
|
1002
|
-
const started = Date.now();
|
|
1003
|
-
const elapsed = () => Date.now() - started;
|
|
1004
|
-
|
|
1005
|
-
if (!deviceAttached()) {
|
|
1006
|
-
return {
|
|
1007
|
-
name: "tokenDrift",
|
|
1008
|
-
verdict: "SKIP",
|
|
1009
|
-
reason: "no Android device/emulator attached (adb) — runtime token drift needs the live inspector tier",
|
|
1010
|
-
durationMs: elapsed(),
|
|
1011
|
-
};
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
const unreachable = () => ({
|
|
1015
|
-
name: "tokenDrift",
|
|
1016
|
-
verdict: "SKIP",
|
|
1017
|
-
reason: "inspector endpoint not reachable on :9500 (debug app not running?) — launch the debug build to enable the live tier",
|
|
1018
|
-
durationMs: elapsed(),
|
|
1019
|
-
});
|
|
1020
|
-
|
|
1021
|
-
// Machine-global lease before the first device touch (contention = SKIP).
|
|
1022
|
-
const leaseSkip = leaseDeviceForStep("tokenDrift");
|
|
1023
|
-
if (leaseSkip) return { ...leaseSkip, durationMs: elapsed() };
|
|
1024
|
-
|
|
1025
|
-
sh(`adb forward tcp:${INSPECTOR_PORT} tcp:${INSPECTOR_PORT}`);
|
|
1026
|
-
try {
|
|
1027
|
-
let health = curlJson(`http://127.0.0.1:${INSPECTOR_PORT}/inspect/health`);
|
|
1028
|
-
if (!health.ok) {
|
|
1029
|
-
// Debug app may not be running — try to launch it (best-effort: parse the
|
|
1030
|
-
// applicationId out of the Android build config), then give it a moment
|
|
1031
|
-
// to cold-start before giving up.
|
|
1032
|
-
let applicationId = null;
|
|
1033
|
-
try {
|
|
1034
|
-
const gradle = fs.readFileSync(path.join(ROOT, "composeApp/build.gradle.kts"), "utf8");
|
|
1035
|
-
applicationId = gradle.match(/applicationId\s*=\s*"([^"]+)"/)?.[1] ?? null;
|
|
1036
|
-
} catch {
|
|
1037
|
-
applicationId = null;
|
|
1038
|
-
}
|
|
1039
|
-
if (applicationId) {
|
|
1040
|
-
sh(`adb shell am start -n ${applicationId}/.MainActivity`);
|
|
1041
|
-
}
|
|
1042
|
-
health = pollHealth(INSPECTOR_PORT, 5, 2);
|
|
1043
|
-
}
|
|
1044
|
-
if (!health.ok) return unreachable();
|
|
1045
|
-
|
|
1046
|
-
const designSystem = curlJson(`http://127.0.0.1:${INSPECTOR_PORT}/inspect/design-system`);
|
|
1047
|
-
const tree = curlJson(`http://127.0.0.1:${INSPECTOR_PORT}/inspect/tree`);
|
|
1048
|
-
if (!designSystem.ok || !tree.ok) return unreachable();
|
|
1049
|
-
|
|
1050
|
-
const { checked, drifted } = compareTokenDrift(designSystem.body, tree.body);
|
|
1051
|
-
|
|
1052
|
-
if (drifted.length === 0) {
|
|
1053
|
-
return {
|
|
1054
|
-
name: "tokenDrift",
|
|
1055
|
-
verdict: "PASS",
|
|
1056
|
-
durationMs: elapsed(),
|
|
1057
|
-
details: { checked, drifted: 0 },
|
|
1058
|
-
};
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
const lines = ["Runtime token drift — a component's resolved value contradicts the declared design-system catalog:"];
|
|
1062
|
-
for (const d of drifted) {
|
|
1063
|
-
lines.push(
|
|
1064
|
-
` [${d.node}] token '${d.token}' (${d.facet}) — expected ${d.expected}, resolved ${d.actual}. Update the component to use the token, or update the catalog if the token itself changed.`,
|
|
1065
|
-
);
|
|
1066
|
-
}
|
|
1067
|
-
return {
|
|
1068
|
-
name: "tokenDrift",
|
|
1069
|
-
verdict: "FAIL",
|
|
1070
|
-
reason: lines.join("\n"),
|
|
1071
|
-
durationMs: elapsed(),
|
|
1072
|
-
details: { checked, drifted },
|
|
1073
|
-
};
|
|
1074
|
-
} finally {
|
|
1075
|
-
sh(`adb forward --remove tcp:${INSPECTOR_PORT}`);
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
function maestroAvailable() {
|
|
1080
|
-
return sh("maestro --version", { timeout: 15_000 }).ok;
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
// The e2e guard trio, shared by every step that drives the smoke flow on a device.
|
|
1084
|
-
// Returns null when the harness is fully available, else the SKIP result for [name].
|
|
1085
|
-
function maestroGuards(name) {
|
|
1086
|
-
if (!fs.existsSync(path.join(ROOT, "qa/e2e"))) {
|
|
1087
|
-
return { name, verdict: "SKIP", reason: "e2e harness not included in this project (--no-e2e)", durationMs: 0 };
|
|
1088
|
-
}
|
|
1089
|
-
if (!deviceAttached()) {
|
|
1090
|
-
return { name, verdict: "SKIP", reason: "no Android device/emulator attached (adb)", durationMs: 0 };
|
|
1091
|
-
}
|
|
1092
|
-
if (!maestroAvailable()) {
|
|
1093
|
-
return { name, verdict: "SKIP", reason: "maestro CLI not installed — curl -fsSL https://get.maestro.mobile.dev | bash", durationMs: 0 };
|
|
1094
|
-
}
|
|
1095
|
-
return null;
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
// Drives qa/e2e/smoke.yaml against whatever build is installed, with the device hardened
|
|
1099
|
-
// for headless/CI automation. Shared by e2eSmoke (debug APK) and releaseSmoke (release
|
|
1100
|
-
// APK) so the hardening and the honesty sweep can never drift apart between variants.
|
|
1101
|
-
// Without the hardening, a slow or loaded emulator produces false reds that have nothing
|
|
1102
|
-
// to do with the app:
|
|
1103
|
-
// - hide_error_dialogs=1 stops Android popping ANR/crash dialogs (e.g. SystemUI under load)
|
|
1104
|
-
// that steal focus over the app — a Maestro assert would then see only the dialog;
|
|
1105
|
-
// - MAESTRO_DRIVER_STARTUP_TIMEOUT gives the UiAutomator2 driver a generous budget to come
|
|
1106
|
-
// up on a slow emulator (the built-in default gives up too early under load).
|
|
1107
|
-
// Both are benign, reversible, and only touch the device while the lane is driving it —
|
|
1108
|
-
// hide_error_dialogs is restored to its pre-run value (or deleted, returning the device
|
|
1109
|
-
// to its default) in the finally below, on every exit path.
|
|
1110
|
-
// hide_error_dialogs suppresses the OS dialog, NEVER the underlying event — so after the
|
|
1111
|
-
// run we grep the device log for ANR/crash lines the dialog would have shown, and FAIL on
|
|
1112
|
-
// them. The eyes must report what automation stability had to hide.
|
|
1113
|
-
function runMaestroSmoke(name, priorDurationMs) {
|
|
1114
|
-
const prevHideErrorDialogs = sh("adb shell settings get global hide_error_dialogs").out.trim();
|
|
1115
|
-
sh("adb shell settings put global hide_error_dialogs 1");
|
|
1116
|
-
sh("adb logcat -c"); // clear so the post-run dump only reflects this run
|
|
1117
|
-
try {
|
|
1118
|
-
const res = sh("maestro test qa/e2e/smoke.yaml", { env: { ...process.env, MAESTRO_DRIVER_STARTUP_TIMEOUT: "120000" } });
|
|
1119
|
-
if (!res.ok) {
|
|
1120
|
-
return {
|
|
1121
|
-
name,
|
|
1122
|
-
verdict: "FAIL",
|
|
1123
|
-
reason: `Maestro smoke failed (flow cites the SHELL spec clauses it proves):\n${res.out.split("\n").slice(-15).join("\n")}`,
|
|
1124
|
-
durationMs: priorDurationMs + res.durationMs,
|
|
1125
|
-
};
|
|
1126
|
-
}
|
|
1127
|
-
const anrDump = sh("adb logcat -d -b system,crash,main");
|
|
1128
|
-
const anrRe = /ANR in |FATAL EXCEPTION/i;
|
|
1129
|
-
if (anrDump.ok && anrRe.test(anrDump.out)) {
|
|
1130
|
-
const anrLines = anrDump.out.split("\n").filter((l) => anrRe.test(l)).slice(0, 10).join("\n");
|
|
1131
|
-
return {
|
|
1132
|
-
name,
|
|
1133
|
-
verdict: "FAIL",
|
|
1134
|
-
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}`,
|
|
1135
|
-
durationMs: priorDurationMs + res.durationMs,
|
|
1136
|
-
};
|
|
1137
|
-
}
|
|
1138
|
-
return { name, verdict: "PASS", durationMs: priorDurationMs + res.durationMs };
|
|
1139
|
-
} finally {
|
|
1140
|
-
if (prevHideErrorDialogs && prevHideErrorDialogs !== "null") {
|
|
1141
|
-
sh(`adb shell settings put global hide_error_dialogs ${prevHideErrorDialogs}`);
|
|
1142
|
-
} else {
|
|
1143
|
-
sh("adb shell settings delete global hide_error_dialogs");
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
function stepE2eSmoke() {
|
|
1149
|
-
const guard = maestroGuards("e2eSmoke");
|
|
1150
|
-
if (guard) return guard;
|
|
1151
|
-
// Machine-global lease before the first device touch (contention = SKIP).
|
|
1152
|
-
const leaseSkip = leaseDeviceForStep("e2eSmoke");
|
|
1153
|
-
if (leaseSkip) return leaseSkip;
|
|
1154
|
-
const install = shGradle(`${GRADLEW} :composeApp:installDebug --console=plain`);
|
|
1155
|
-
if (!install.ok) {
|
|
1156
|
-
return { name: "e2eSmoke", verdict: "FAIL", reason: "installDebug failed — the APK could not be installed on the attached device", durationMs: install.durationMs };
|
|
1157
|
-
}
|
|
1158
|
-
settleAdb();
|
|
1159
|
-
return runMaestroSmoke("e2eSmoke", install.durationMs);
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
// Instrumented behavior tier (composeApp/src/androidInstrumentedTest) — the one step
|
|
1163
|
-
// whose evidence crosses the process boundary. Alarms, notification channels,
|
|
1164
|
-
// full-screen intents, PendingIntent identity, and audio routing are OS facts:
|
|
1165
|
-
// desktopTest is a JVM, golden trees are structure, the conformance suite is static,
|
|
1166
|
-
// and the Maestro smoke taps UI without asserting anything about the shade or the
|
|
1167
|
-
// alarm table. Nine escaped platform-semantics defects across two real apps trace to
|
|
1168
|
-
// exactly this blind spot; the hand-built precursor of this step caught two bugs the
|
|
1169
|
-
// week it landed. `connectedDebugAndroidTest` builds, installs, and runs the
|
|
1170
|
-
// instrumented suite in the app's real process on the attached device.
|
|
1171
|
-
//
|
|
1172
|
-
// SKIP (never FAIL) on missing infrastructure — no device, or no instrumented sources
|
|
1173
|
-
// yet — mirroring e2eSmoke's stance: absence of the tier is recorded honestly, only
|
|
1174
|
-
// broken behavior fails.
|
|
1175
|
-
function stepAndroidChecks() {
|
|
1176
|
-
const started = Date.now();
|
|
1177
|
-
const instrumentedDir = path.join(ROOT, "composeApp/src/androidInstrumentedTest");
|
|
1178
|
-
const hasSources = fs.existsSync(instrumentedDir) &&
|
|
1179
|
-
walkFiles(instrumentedDir, [".kt"]).length > 0;
|
|
1180
|
-
if (!hasSources) {
|
|
1181
|
-
return {
|
|
1182
|
-
name: "androidChecks",
|
|
1183
|
-
verdict: "SKIP",
|
|
1184
|
-
reason: "no instrumented tests (composeApp/src/androidInstrumentedTest has no Kotlin sources)",
|
|
1185
|
-
durationMs: Date.now() - started,
|
|
1186
|
-
};
|
|
1187
|
-
}
|
|
1188
|
-
if (!deviceAttached()) {
|
|
1189
|
-
return {
|
|
1190
|
-
name: "androidChecks",
|
|
1191
|
-
verdict: "SKIP",
|
|
1192
|
-
reason: "no Android device/emulator attached (adb) — instrumented behavior needs the real process boundary",
|
|
1193
|
-
durationMs: Date.now() - started,
|
|
1194
|
-
};
|
|
1195
|
-
}
|
|
1196
|
-
// Machine-global lease before the first device touch (contention = SKIP).
|
|
1197
|
-
const leaseSkip = leaseDeviceForStep("androidChecks");
|
|
1198
|
-
if (leaseSkip) return { ...leaseSkip, durationMs: Date.now() - started };
|
|
1199
|
-
// Settle before Gradle's own install+drive: earlier lane steps (tokenDrift's
|
|
1200
|
-
// port-forwards, e2eSmoke's reinstall) can leave the transport stale — see settleAdb.
|
|
1201
|
-
settleAdb();
|
|
1202
|
-
// `--rerun` for the same evidence-integrity reason as stepUnitTests: the receipt must
|
|
1203
|
-
// attest tests that EXECUTED on this tree, never a replayed up-to-date verdict.
|
|
1204
|
-
const res = shGradle(`${GRADLEW} :composeApp:connectedDebugAndroidTest --rerun --console=plain`);
|
|
1205
|
-
const summary = junitSummary(path.join(ROOT, "composeApp/build/outputs/androidTest-results/connected"));
|
|
1206
|
-
return {
|
|
1207
|
-
name: "androidChecks",
|
|
1208
|
-
verdict: res.ok ? "PASS" : "FAIL",
|
|
1209
|
-
reason: res.ok
|
|
1210
|
-
? undefined
|
|
1211
|
-
: `connectedDebugAndroidTest failed (${summary ? `${summary.failures + summary.errors} of ${summary.tests} tests` : "see output"}) — an on-device behavior claim is broken. Fix the behavior, not the test:\n${res.out.split("\n").filter((l) => /FAILED|error:|failed/i.test(l)).slice(0, 12).join("\n")}`,
|
|
1212
|
-
durationMs: Date.now() - started,
|
|
1213
|
-
details: summary ?? undefined,
|
|
1214
|
-
};
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
// Release-APK smoke — the behavior half of stepReleaseBuild. assembleRelease proves R8
|
|
1218
|
-
// and the build graph COMPILE; two real bugs were only findable by *running* the release
|
|
1219
|
-
// variant (R8 behavior differs from debug). Installs the release APK and drives the same
|
|
1220
|
-
// Maestro smoke flow against it. Ship-time cost by design: this step exists only in the
|
|
1221
|
-
// `release` profile, never per-change.
|
|
1222
|
-
//
|
|
1223
|
-
// Honesty notes, both deliberate:
|
|
1224
|
-
// - A template-fresh app has NO release signingConfig (the keystore belongs to whoever
|
|
1225
|
-
// ships), and an unsigned APK cannot be installed. That is a SKIP naming what to
|
|
1226
|
-
// configure, never a FAIL — a fresh scaffold must not red-bar on a keystore it was
|
|
1227
|
-
// never given.
|
|
1228
|
-
// - This step reinstalls NOTHING afterwards: the release build stays on the device,
|
|
1229
|
-
// which is the honest state ("what is installed is what was last proven"). The next
|
|
1230
|
-
// debug install over it will hit INSTALL_FAILED_UPDATE_INCOMPATIBLE (release and debug
|
|
1231
|
-
// signatures differ) — run `adb uninstall <applicationId>` first; the same applies in
|
|
1232
|
-
// reverse here, so that raw Gradle error is translated into the actionable message.
|
|
1233
|
-
function stepReleaseSmoke() {
|
|
1234
|
-
const guard = maestroGuards("releaseSmoke");
|
|
1235
|
-
if (guard) return guard;
|
|
1236
|
-
|
|
1237
|
-
let gradleText = "";
|
|
1238
|
-
try {
|
|
1239
|
-
gradleText = fs.readFileSync(path.join(ROOT, "composeApp/build.gradle.kts"), "utf8");
|
|
1240
|
-
} catch {
|
|
1241
|
-
gradleText = "";
|
|
1242
|
-
}
|
|
1243
|
-
const applicationId = gradleText.match(/applicationId\s*=\s*"([^"]+)"/)?.[1] ?? "<applicationId>";
|
|
1244
|
-
if (!/signingConfig/.test(gradleText)) {
|
|
1245
|
-
return {
|
|
1246
|
-
name: "releaseSmoke",
|
|
1247
|
-
verdict: "SKIP",
|
|
1248
|
-
reason:
|
|
1249
|
-
"release APK is unsigned — no signingConfig in composeApp/build.gradle.kts. To enable the release smoke: create a keystore (keytool -genkeypair), declare android.signingConfigs { create(\"release\") { … } } from a gitignored keystore.properties, and set buildTypes.release.signingConfig. The keystore is yours to keep out of the repo.",
|
|
1250
|
-
durationMs: 0,
|
|
1251
|
-
};
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
// Machine-global lease before the first device touch (contention = SKIP).
|
|
1255
|
-
// After the signing check on purpose: an unsigned template SKIPs on the
|
|
1256
|
-
// keystore without ever needing the device.
|
|
1257
|
-
const leaseSkip = leaseDeviceForStep("releaseSmoke");
|
|
1258
|
-
if (leaseSkip) return leaseSkip;
|
|
1259
|
-
|
|
1260
|
-
const install = shGradle(`${GRADLEW} :composeApp:installRelease --console=plain`);
|
|
1261
|
-
if (!install.ok) {
|
|
1262
|
-
if (/INSTALL_FAILED_UPDATE_INCOMPATIBLE/.test(install.out)) {
|
|
1263
|
-
return {
|
|
1264
|
-
name: "releaseSmoke",
|
|
1265
|
-
verdict: "FAIL",
|
|
1266
|
-
reason: `installRelease refused: the device holds a build with a different signature (usually the debug build from an earlier lane step). Android never installs across signatures — run \`adb uninstall ${applicationId}\` and re-run the release profile. This is a device-state conflict, not a build defect.`,
|
|
1267
|
-
durationMs: install.durationMs,
|
|
1268
|
-
};
|
|
1269
|
-
}
|
|
1270
|
-
if (/SigningConfig|not signed|INSTALL_PARSE_FAILED_NO_CERTIFICATES/i.test(install.out)) {
|
|
1271
|
-
return {
|
|
1272
|
-
name: "releaseSmoke",
|
|
1273
|
-
verdict: "SKIP",
|
|
1274
|
-
reason: "release APK is not installable — signing is not fully configured (see composeApp/build.gradle.kts signingConfigs). Configure a release keystore to enable the release smoke.",
|
|
1275
|
-
durationMs: install.durationMs,
|
|
1276
|
-
};
|
|
1277
|
-
}
|
|
1278
|
-
return {
|
|
1279
|
-
name: "releaseSmoke",
|
|
1280
|
-
verdict: "FAIL",
|
|
1281
|
-
reason: `installRelease failed — the shippable APK could not be installed:\n${install.out.split("\n").filter((l) => /error|FAILURE|INSTALL_/i.test(l)).slice(0, 12).join("\n")}`,
|
|
1282
|
-
durationMs: install.durationMs,
|
|
1283
|
-
};
|
|
1284
|
-
}
|
|
1285
|
-
settleAdb();
|
|
1286
|
-
return runMaestroSmoke("releaseSmoke", install.durationMs);
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
// ── Audit cadence (roadmap §10 item 9) — a REPORT, never a gate ────────────
|
|
1290
|
-
// cmp-audit (the adversarial platform-semantics audit) found six latent
|
|
1291
|
-
// defects the first time a human happened to ask for it — which is exactly
|
|
1292
|
-
// why it must not depend on someone remembering to ask. This step is the
|
|
1293
|
-
// cheapest honest replacement for that memory: at ship time (release
|
|
1294
|
-
// profile) the receipt lists which androidMain subsystems changed since
|
|
1295
|
-
// their last RECORDED audit (qa/audits.jsonl, appended by
|
|
1296
|
-
// node qa/record-audit.mjs). The derivation lives in
|
|
1297
|
-
// qa/lib/audit-cadence.mjs; this step adds only the bookkeeping every step
|
|
1298
|
-
// carries — and by construction it maps every outcome to PASS or SKIP,
|
|
1299
|
-
// never FAIL: audit debt is a judgment call (a rename is not six latent
|
|
1300
|
-
// defects), and a gate here would teach people to game the ledger, which
|
|
1301
|
-
// would destroy the only value it has.
|
|
1302
|
-
function stepAuditCadence() {
|
|
1303
|
-
const started = Date.now();
|
|
1304
|
-
const report = evaluateAuditCadence(ROOT);
|
|
1305
|
-
if (!report.ok) {
|
|
1306
|
-
return { name: "auditCadence", verdict: "SKIP", reason: report.reason, durationMs: Date.now() - started };
|
|
1307
|
-
}
|
|
1308
|
-
return {
|
|
1309
|
-
name: "auditCadence",
|
|
1310
|
-
verdict: "PASS",
|
|
1311
|
-
note: report.summary,
|
|
1312
|
-
durationMs: Date.now() - started,
|
|
1313
|
-
details: {
|
|
1314
|
-
packageRoot: report.packageRoot,
|
|
1315
|
-
subsystems: report.subsystems.map((s) => ({
|
|
1316
|
-
name: s.name,
|
|
1317
|
-
status: s.status,
|
|
1318
|
-
changedFiles: s.changedFiles,
|
|
1319
|
-
lastAudit: s.audit ? { sha: s.audit.sha, at: s.audit.at, by: s.audit.by } : null,
|
|
1320
|
-
})),
|
|
1321
|
-
lines: report.lines,
|
|
1322
|
-
},
|
|
1323
|
-
};
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1326
|
-
// ── Lane ───────────────────────────────────────────────────────────────────
|
|
1327
|
-
|
|
1328
|
-
// Device-dependent steps, in lane order. Used twice: receipt STRENGTH (which
|
|
1329
|
-
// on-device steps actually PASSed — see below, where the receipt is built) and
|
|
1330
|
-
// the --fast exclusion (with releaseBuild added), so the "device/slow tier"
|
|
1331
|
-
// can never mean two different lists.
|
|
1332
|
-
const DEVICE_STEPS = ["e2eSmoke", "tokenDrift", "androidChecks", "releaseSmoke"];
|
|
1333
|
-
|
|
1334
|
-
// ── Fast-mode memoization of the pure-Node steps (qa/lib/step-cache.mjs) ────
|
|
1335
|
-
// These five steps run no Gradle, shell out to nothing, and are pure functions
|
|
1336
|
-
// of files on disk — so in FAST mode an unchanged input set reuses the last
|
|
1337
|
-
// PASS as verdict "CACHED" (rendered distinctly; only a PASS is ever reused,
|
|
1338
|
-
// a cached FAIL/SKIP always re-runs). THE FULL LANE NEVER CONSULTS THE CACHE —
|
|
1339
|
-
// deliberately: it keeps the integrity property absolute rather than "absolute
|
|
1340
|
-
// unless a cache says otherwise". A full run still WRITES entries so the next
|
|
1341
|
-
// fast run benefits. schemaHistory is NOT here even though it runs no Gradle:
|
|
1342
|
-
// it shells out to git and its verdict depends on HEAD state, not only file
|
|
1343
|
-
// bytes — memoizing it on a content hash could go silently stale.
|
|
1344
|
-
//
|
|
1345
|
-
// Each input set is the step's ACTUAL read surface, over-declared where cheap
|
|
1346
|
-
// (a too-broad set only costs cache misses; a too-narrow one is a
|
|
1347
|
-
// silently-stale gate — the worst possible bug here):
|
|
1348
|
-
// specCoverage reads specs/*.spec.md + citations under composeApp/src
|
|
1349
|
-
// and qa/e2e (qa/lib/spec-coverage.mjs)
|
|
1350
|
-
// approvals reads qa/approvals.json + every governed artifact file:
|
|
1351
|
-
// specs/, docs/features/, docs/ARCHITECTURE.md, and the
|
|
1352
|
-
// exemplar/theme/components Kotlin under composeApp/src
|
|
1353
|
-
// (qa/lib/approvals.mjs listGovernedArtifacts)
|
|
1354
|
-
// componentStories reads commonMain presentation/components and desktopMain
|
|
1355
|
-
// inspector sources — both under composeApp/src
|
|
1356
|
-
// reachability reads commonMain Kotlin (composeApp/src) + the unrouted
|
|
1357
|
-
// declarations in docs/features/
|
|
1358
|
-
// archDoc reads docs/ARCHITECTURE.md, docs/adr/, specs/intent.md
|
|
1359
|
-
// (over-declared to all of specs/), and every source-set's
|
|
1360
|
-
// Kotlin under composeApp/src (qa/lib/arch-doc.mjs)
|
|
1361
|
-
const MEMOIZED_STEP_INPUTS = {
|
|
1362
|
-
specCoverage: ["specs", "composeApp/src", "qa/e2e"],
|
|
1363
|
-
approvals: ["qa/approvals.json", "specs", "docs/features", "docs/ARCHITECTURE.md", "composeApp/src"],
|
|
1364
|
-
componentStories: ["composeApp/src"],
|
|
1365
|
-
reachability: ["composeApp/src", "docs/features"],
|
|
1366
|
-
archDoc: ["docs/ARCHITECTURE.md", "docs/adr", "specs", "composeApp/src"],
|
|
1367
|
-
};
|
|
1368
|
-
|
|
1369
|
-
const memoized = (stepName, stepFn) => () =>
|
|
1370
|
-
memoizeStep({ fast, root: ROOT, stepName, inputs: MEMOIZED_STEP_INPUTS[stepName], run: stepFn });
|
|
1371
|
-
|
|
1372
|
-
const stepSpecCoverageMemo = memoized("specCoverage", stepSpecCoverage);
|
|
1373
|
-
const stepApprovalsMemo = memoized("approvals", stepApprovals);
|
|
1374
|
-
const stepComponentStoriesMemo = memoized("componentStories", stepComponentStories);
|
|
1375
|
-
const stepReachabilityMemo = memoized("reachability", stepReachability);
|
|
1376
|
-
const stepArchDocMemo = memoized("archDoc", stepArchDoc);
|
|
1377
|
-
|
|
1378
|
-
const stepsForProfile = {
|
|
1379
|
-
// scaffold: what `create-cmp --verify` proves at stamp time — specCoverage,
|
|
1380
|
-
// the full JVM tier (unit + conformance + golden + UI tests) plus the Android build.
|
|
1381
|
-
scaffold: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepArchDocMemo, stepSchemaHistory, stepBuild, stepUnitTests],
|
|
1382
|
-
local: [
|
|
1383
|
-
// First, always: every verdict below is only worth what the lane issuing
|
|
1384
|
-
// it is worth.
|
|
1385
|
-
stepHarnessIntegrity,
|
|
1386
|
-
stepSpecCoverageMemo,
|
|
1387
|
-
stepApprovalsMemo,
|
|
1388
|
-
stepComponentStoriesMemo,
|
|
1389
|
-
stepReachabilityMemo,
|
|
1390
|
-
stepArchDocMemo,
|
|
1391
|
-
stepSchemaHistory,
|
|
1392
|
-
stepBuild,
|
|
1393
|
-
// Release stays OUT of `scaffold`: stamp-time --verify promises a green first build, and
|
|
1394
|
-
// an R8 pass would add minutes to every scaffold to re-prove what this step proves here.
|
|
1395
|
-
// local + ci is where release rot gets caught before it reaches anyone.
|
|
1396
|
-
stepReleaseBuild,
|
|
1397
|
-
stepUnitTests,
|
|
1398
|
-
stepConformance,
|
|
1399
|
-
stepGoldenTrees,
|
|
1400
|
-
stepTokenDrift,
|
|
1401
|
-
stepA11y,
|
|
1402
|
-
stepE2eSmoke,
|
|
1403
|
-
// androidChecks joins local BY the file's own convention, not despite it: local's
|
|
1404
|
-
// contract (see USAGE) is "everything; device-dependent steps SKIP when no device is
|
|
1405
|
-
// attached" — device presence is the opt-in, exactly as e2eSmoke and tokenDrift
|
|
1406
|
-
// already work. A developer with no device attached pays nothing here; one who
|
|
1407
|
-
// attached an emulator has already opted into the device tier's cost. Hiding this
|
|
1408
|
-
// step in ci-only would make local's documented contract a lie and re-open the gap
|
|
1409
|
-
// this tier closes (androidMain test-invisible in the profile people actually run).
|
|
1410
|
-
// Last on purpose: the cheap desktop verdicts and the smoke land first.
|
|
1411
|
-
stepAndroidChecks,
|
|
1412
|
-
],
|
|
1413
|
-
};
|
|
1414
|
-
// ci = local + the determinism probe's row — the first place ci diverges
|
|
1415
|
-
// from local. The probe is OPT-IN (the step SKIPs unless --determinism was
|
|
1416
|
-
// passed: it doubles the JVM test tier's cost), but its row lives in the ci
|
|
1417
|
-
// profile so a ci receipt always records whether the probe ran — an honest,
|
|
1418
|
-
// visible gap beats an invisible one ("SKIPs are recorded so the pipeline
|
|
1419
|
-
// stays honest", per the profile's own contract). local deliberately does
|
|
1420
|
-
// NOT carry the row: the per-change developer profile is not where a
|
|
1421
|
-
// deliberate double-run belongs.
|
|
1422
|
-
stepsForProfile.ci = [...stepsForProfile.local, stepDeterminism];
|
|
1423
|
-
// release = everything ci proves PLUS the audit-cadence report and the
|
|
1424
|
-
// release-APK behavior smoke. The expensive proofs are profile-tiered by
|
|
1425
|
-
// decision: per-change stays fast (local/ci pay for the release COMPILE via
|
|
1426
|
-
// releaseBuild, already in the set), and the release-variant *behavior* cost
|
|
1427
|
-
// lands once, at ship time. auditCadence (a report, never a gate) also
|
|
1428
|
-
// belongs to ship time — "what moved in androidMain since its last
|
|
1429
|
-
// adversarial audit?" is the question asked before shipping, not per edit.
|
|
1430
|
-
// releaseSmoke runs last so the device ends the run holding the exact build
|
|
1431
|
-
// that was proven.
|
|
1432
|
-
stepsForProfile.release = [...stepsForProfile.ci, stepAuditCadence, stepReleaseSmoke];
|
|
1433
326
|
|
|
1434
327
|
if (!stepsForProfile[profile]) {
|
|
1435
|
-
console.error(`Unknown profile "${profile}" — use scaffold | local | ci | release.`);
|
|
328
|
+
console.error(`Unknown profile "${profile}" — use smoke | scaffold | local | ci | nightly | release.`);
|
|
1436
329
|
process.exit(2);
|
|
1437
330
|
}
|
|
1438
331
|
|
|
@@ -1448,7 +341,7 @@ if (determinism && !profileExplicit) {
|
|
|
1448
341
|
fs.writeFileSync(LANE_MARKER, `${process.pid} ${new Date().toISOString()}\n`);
|
|
1449
342
|
let probe;
|
|
1450
343
|
try {
|
|
1451
|
-
probe = stepDeterminism();
|
|
344
|
+
probe = pack.stepDeterminism();
|
|
1452
345
|
} finally {
|
|
1453
346
|
fs.rmSync(LANE_MARKER, { force: true });
|
|
1454
347
|
}
|
|
@@ -1476,21 +369,6 @@ if (determinism && !profileExplicit) {
|
|
|
1476
369
|
// convention: mode "fast" is
|
|
1477
370
|
// recorded, no evidence rung is derived (qa/lib/evidence-level.mjs), and
|
|
1478
371
|
// qa/receipt-check.mjs refuses a fast receipt as done evidence.
|
|
1479
|
-
const FAST_EXCLUDED_NAMES = [...DEVICE_STEPS, "releaseBuild"];
|
|
1480
|
-
const STEP_FN_BY_NAME = {
|
|
1481
|
-
e2eSmoke: stepE2eSmoke,
|
|
1482
|
-
tokenDrift: stepTokenDrift,
|
|
1483
|
-
androidChecks: stepAndroidChecks,
|
|
1484
|
-
releaseSmoke: stepReleaseSmoke,
|
|
1485
|
-
releaseBuild: stepReleaseBuild,
|
|
1486
|
-
};
|
|
1487
|
-
for (const name of FAST_EXCLUDED_NAMES) {
|
|
1488
|
-
if (!STEP_FN_BY_NAME[name]) {
|
|
1489
|
-
// Drift guard: a new device-tier step must be mapped here or --fast would silently run it.
|
|
1490
|
-
console.error(`internal: fast-excluded step "${name}" has no entry in STEP_FN_BY_NAME — fix qa/verify.mjs`);
|
|
1491
|
-
process.exit(2);
|
|
1492
|
-
}
|
|
1493
|
-
}
|
|
1494
372
|
const FAST_EXCLUDED_FNS = new Set(FAST_EXCLUDED_NAMES.map((name) => STEP_FN_BY_NAME[name]));
|
|
1495
373
|
const laneSteps = fast
|
|
1496
374
|
? stepsForProfile[profile].filter((fn) => !FAST_EXCLUDED_FNS.has(fn))
|
|
@@ -1512,36 +390,51 @@ if (fast) {
|
|
|
1512
390
|
|
|
1513
391
|
// Stamp the lane marker for the run's duration (coexistence defense 1 above);
|
|
1514
392
|
// always removed, even on a failing step, so the eyes only ever defer briefly.
|
|
1515
|
-
|
|
1516
|
-
|
|
393
|
+
//
|
|
394
|
+
// N2 (docs/features/drive-narration.md): the marker is REWRITTEN at each step
|
|
395
|
+
// start with the lane's own narration — current step name, position, and the
|
|
396
|
+
// expected durations read from the journal's last full run (never memory;
|
|
397
|
+
// walk-legibility L4's rule, per step). Every other consumer of this marker
|
|
398
|
+
// is mtime-only (qa/watch.mjs, the preview daemon), so the content is free
|
|
399
|
+
// to carry meaning for deriveChain's windshield — and the per-step rewrite
|
|
400
|
+
// also refreshes mtime, so a lane longer than the 5-minute freshness bound
|
|
401
|
+
// no longer reads as stale to its own watchers mid-run.
|
|
1517
402
|
const laneStartedAt = Date.now(); // for the flight-recorder entry's durationMs
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
//
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
403
|
+
// The step loop is the SPINE (qa/lib/lane-runner.mjs, evidence-economics S8a):
|
|
404
|
+
// marker narration, per-step deadlines, the pulse, throw/timeout → one ERROR
|
|
405
|
+
// row, the mark, the verdict. This file supplies only what is this project's:
|
|
406
|
+
// the steps, the marker path, the subprocess deadline hook, the device lease.
|
|
407
|
+
const expectedByStep = (() => {
|
|
408
|
+
try {
|
|
409
|
+
return expectedDurations(readFlightJournal(ROOT).entries);
|
|
410
|
+
} catch {
|
|
411
|
+
return { byName: new Map(), laneMs: null }; // narration is optional; the lane never depends on its own journal
|
|
412
|
+
}
|
|
413
|
+
})();
|
|
414
|
+
const lane = runLane({
|
|
415
|
+
steps: laneSteps,
|
|
416
|
+
markerPath: LANE_MARKER,
|
|
417
|
+
expected: expectedByStep,
|
|
418
|
+
startedAt: laneStartedAt,
|
|
419
|
+
// sh() reads the running step's deadline from this module-level slot.
|
|
420
|
+
setDeadline: (ms) => {
|
|
421
|
+
CURRENT_STEP_DEADLINE_MS = ms;
|
|
422
|
+
},
|
|
423
|
+
// Human runs print a row per step and get the pulse; --json gets neither
|
|
424
|
+
// (a narrator during a machine run is a lane doing something unasked).
|
|
425
|
+
print: asJson ? null : (line) => console.log(line),
|
|
426
|
+
narrator: { entry: path.join(HERE, "lib", "lane-narrator.mjs"), root: ROOT },
|
|
1534
427
|
// The device lease (if a device step took it) is held to the very end of the
|
|
1535
428
|
// run — see the scope decision at leaseDeviceForStep. Release is idempotent
|
|
1536
429
|
// and never deletes a foreign holder's lease.
|
|
1537
|
-
|
|
1538
|
-
}
|
|
1539
|
-
|
|
430
|
+
onFinally: () => pack.releaseLease(),
|
|
431
|
+
});
|
|
432
|
+
const steps = lane.steps;
|
|
1540
433
|
// CACHED counts as PASS for the lane verdict (it IS a prior PASS, reused only
|
|
1541
434
|
// in fast mode on an unchanged input set) — but it stays CACHED on the
|
|
1542
|
-
// receipt, visibly distinct
|
|
1543
|
-
//
|
|
1544
|
-
const verdict =
|
|
435
|
+
// receipt, visibly distinct. ERROR fails the lane: "I could not check this" is
|
|
436
|
+
// not green; only the ACCUSATION is withheld. (laneVerdict, qa/lib/lane-runner.mjs)
|
|
437
|
+
const verdict = lane.verdict;
|
|
1545
438
|
|
|
1546
439
|
// Receipt STRENGTH — a desktop-only green and an on-device green are different
|
|
1547
440
|
// claims, and the difference should never live only in the SKIP lines. Device-
|
|
@@ -1605,9 +498,16 @@ const inputs = computeInputsHash(ROOT);
|
|
|
1605
498
|
// The receipt. Deterministic key order; ONE volatile timestamp field.
|
|
1606
499
|
// commit.sha is the parent HEAD at run time (you cannot know the sha of the
|
|
1607
500
|
// commit the receipt will be part of); commit.dirty lists what was uncommitted.
|
|
501
|
+
// The STAGE a receipt attests (evidence-economics S6): what "done" means at
|
|
502
|
+
// this gate, named on the receipt so an evidence rung can never be read as
|
|
503
|
+
// more than its stage allows. scaffold → scaffold, local → change (per commit),
|
|
504
|
+
// ci → merge, nightly → nightly (proves the harness, never a change), release →
|
|
505
|
+
// release. Receipts predating this field are read as their profile's stage.
|
|
506
|
+
const STAGE_OF_PROFILE = { smoke: "smoke", scaffold: "scaffold", local: "change", ci: "merge", nightly: "nightly", release: "release" };
|
|
1608
507
|
const receipt = {
|
|
1609
508
|
schema: "cmp-evidence/1",
|
|
1610
509
|
profile,
|
|
510
|
+
stage: STAGE_OF_PROFILE[profile] ?? profile,
|
|
1611
511
|
// "full" is the done-gate; "fast" (--fast) excluded the device/release tier
|
|
1612
512
|
// and is REFUSED by qa/receipt-check.mjs — a fast run can never end a session
|
|
1613
513
|
// as "done". Receipts predating this field are treated as full.
|
|
@@ -1708,6 +608,28 @@ if (asJson) {
|
|
|
1708
608
|
console.log(`\n${verdict === "PASS" ? "✅" : "❌"} verify lane: ${verdict}${level ? ` · ${level.rung} ${level.name}` : ""} (${strengthLabel}) — receipt written to qa/evidence/latest.json${badge.changed ? ` and ${README_REL_PATH}'s evidence badge refreshed` : ""} (commit ${badge.changed ? "them" : "it"} with your change)`);
|
|
1709
609
|
}
|
|
1710
610
|
|
|
611
|
+
// A TIER THAT HAS NEVER RUN HERE. A SKIP is non-fatal by design — absence of a
|
|
612
|
+
// device is not a broken promise — but "non-fatal" quietly became "invisible":
|
|
613
|
+
// maestro was never installed on one machine, so e2eSmoke skipped on every one
|
|
614
|
+
// of 37 recorded runs while the lane said PASS each time. The end-to-end flow
|
|
615
|
+
// had never executed once, and nothing ever said so. A single skip is a fact;
|
|
616
|
+
// skipping EVERY recorded run is a different fact, and only the journal can
|
|
617
|
+
// tell them apart. Counted here, from the journal, and stated once per run.
|
|
618
|
+
if (!asJson && !fast) {
|
|
619
|
+
try {
|
|
620
|
+
const never = neverRunTiers(steps, readFlightJournal(ROOT).entries);
|
|
621
|
+
if (never.length > 0) {
|
|
622
|
+
console.log("\n⚠ tiers that have NEVER run on this machine (skipped every recorded run — the lane still says PASS):");
|
|
623
|
+
for (const n of never) {
|
|
624
|
+
console.log(` ${n.name} — skipped in all ${n.runs} recorded full runs. ${n.reason.split("\n")[0]}`);
|
|
625
|
+
}
|
|
626
|
+
console.log(" A promise that only this tier could observe has never been checked here.");
|
|
627
|
+
}
|
|
628
|
+
} catch {
|
|
629
|
+
/* the journal is a convenience for this note; never let it colour a verdict */
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
1711
633
|
// The audit-cadence nudges print in the human path, not only inside the
|
|
1712
634
|
// receipt JSON — a ship-time report that lives only in a JSON field is a
|
|
1713
635
|
// report nobody reads at ship time. Nudges only; a gate this is not.
|