create-cmp-cli 0.12.0 → 0.14.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/bin/create-cmp.mjs +3 -0
- package/package.json +6 -2
- package/packages/harness/package.json +38 -0
- package/packages/harness/src/approve.mjs +247 -0
- package/packages/harness/src/arch-doc.mjs +69 -0
- package/packages/harness/src/comment.mjs +76 -0
- package/packages/harness/src/lib/a11y.mjs +113 -0
- package/packages/harness/src/lib/affected-tests.mjs +147 -0
- package/packages/harness/src/lib/approvals.mjs +1403 -0
- package/packages/harness/src/lib/arch-doc.mjs +451 -0
- package/packages/harness/src/lib/audit-cadence.mjs +290 -0
- package/packages/harness/src/lib/comments.mjs +252 -0
- package/packages/harness/src/lib/component-stories.mjs +183 -0
- package/packages/harness/src/lib/determinism.mjs +179 -0
- package/packages/harness/src/lib/device-lease.mjs +249 -0
- package/packages/harness/src/lib/evidence-badge.mjs +158 -0
- package/packages/harness/src/lib/evidence-level.mjs +117 -0
- package/packages/harness/src/lib/feature-brief.mjs +324 -0
- package/packages/harness/src/lib/flight-recorder.mjs +332 -0
- package/packages/harness/src/lib/harness-lock.mjs +147 -0
- package/packages/harness/src/lib/harness-region.mjs +159 -0
- package/packages/harness/src/lib/inputs-hash.mjs +194 -0
- package/packages/harness/src/lib/reachability.mjs +211 -0
- package/packages/harness/src/lib/receipt-validate.mjs +234 -0
- package/packages/harness/src/lib/render.mjs +254 -0
- package/packages/harness/src/lib/spec-coverage.mjs +131 -0
- package/packages/harness/src/lib/step-cache.mjs +221 -0
- package/packages/harness/src/lib/token-drift.mjs +94 -0
- package/packages/harness/src/lib/tree.mjs +108 -0
- package/packages/harness/src/preview-gallery.mjs +122 -0
- package/packages/harness/src/receipt-check.mjs +96 -0
- package/packages/harness/src/record-audit.mjs +83 -0
- package/packages/harness/src/refusal-demo.mjs +498 -0
- package/packages/harness/src/retrospective.mjs +51 -0
- package/packages/harness/src/scaffold-feature.mjs +723 -0
- package/packages/harness/src/setup-hooks.mjs +33 -0
- package/packages/harness/src/verify.mjs +1709 -0
- package/packages/harness/src/walkthrough.mjs +499 -0
- package/packages/harness/src/watch.mjs +622 -0
- package/packages/receipts/package.json +36 -0
- package/packages/receipts/src/index.mjs +16 -0
- package/packages/receipts/src/inputs-hash.mjs +194 -0
- package/packages/receipts/src/receipt-validate.mjs +234 -0
- package/src/commands/upgrade.mjs +383 -0
- package/src/lib/harness-upgrade.mjs +521 -0
- package/src/scaffold.mjs +60 -1
- package/template/AGENTS.md +5 -0
- package/template/CLAUDE.md +34 -1
- package/template/README.md +4 -0
- package/template/gitignore +8 -0
- package/template/qa/lib/audit-cadence.mjs +290 -0
- package/template/qa/lib/determinism.mjs +179 -0
- package/template/qa/lib/evidence-badge.mjs +158 -0
- package/template/qa/lib/flight-recorder.mjs +332 -0
- package/template/qa/lib/harness-lock.mjs +147 -0
- package/template/qa/lib/harness-region.mjs +159 -0
- package/template/qa/lib/inputs-hash.mjs +17 -2
- package/template/qa/lib/receipt-validate.mjs +1 -1
- package/template/qa/preview-gallery.mjs +17 -2
- package/template/qa/record-audit.mjs +83 -0
- package/template/qa/retrospective.mjs +51 -0
- package/template/qa/verify.mjs +400 -10
- package/template/qa/watch.mjs +2 -2
package/template/qa/verify.mjs
CHANGED
|
@@ -37,10 +37,15 @@ import { clauseTierCoverage, scanCitations, scanSpecClauses, walkFiles } from ".
|
|
|
37
37
|
import { evaluateComponentStoryParity } from "./lib/component-stories.mjs";
|
|
38
38
|
import { evaluateReachability } from "./lib/reachability.mjs";
|
|
39
39
|
import { evidenceLevel } from "./lib/evidence-level.mjs";
|
|
40
|
+
import { updateReadmeBadge, README_REL_PATH } from "./lib/evidence-badge.mjs";
|
|
40
41
|
import { memoizeStep } from "./lib/step-cache.mjs";
|
|
41
42
|
import { changedWorkingTreePaths, deriveAffectedFilter } from "./lib/affected-tests.mjs";
|
|
42
43
|
import { acquireDeviceLease, releaseDeviceLease, formatHolder } from "./lib/device-lease.mjs";
|
|
43
44
|
import { ARCH_DOC_REL_PATH, SECTION_IDS, regenerateArchDoc } from "./lib/arch-doc.mjs";
|
|
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";
|
|
48
|
+
import { checkHarnessIntegrity, describeIntegrity, LOCK_PATH } from "./lib/harness-lock.mjs";
|
|
44
49
|
|
|
45
50
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
46
51
|
const EVIDENCE_DIR = path.join(ROOT, "qa", "evidence");
|
|
@@ -76,6 +81,18 @@ Flags:
|
|
|
76
81
|
evidence rung, and can NEVER satisfy the
|
|
77
82
|
done-gate — run the full lane once before
|
|
78
83
|
you call it done
|
|
84
|
+
--determinism run the timezone determinism probe: the JVM
|
|
85
|
+
test tier (unit + golden + the other
|
|
86
|
+
desktop suites) executes TWICE, under
|
|
87
|
+
TZ=Etc/GMT+12 (UTC-12) and TZ=Etc/GMT-14
|
|
88
|
+
(UTC+14), and the probe FAILs naming every
|
|
89
|
+
test whose verdict or failure output
|
|
90
|
+
differs — a nondeterminism leak ARCH-13's
|
|
91
|
+
static net missed. Bare (no --profile) it
|
|
92
|
+
runs JUST the probe and writes no receipt;
|
|
93
|
+
with --profile ci (or release) it runs
|
|
94
|
+
inside the lane and lands on the receipt.
|
|
95
|
+
Never combinable with --fast
|
|
79
96
|
--json print the receipt as JSON instead of the
|
|
80
97
|
human-readable step-by-step log
|
|
81
98
|
--help, -h print this usage and exit 0 without
|
|
@@ -99,7 +116,7 @@ if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
|
|
|
99
116
|
process.exit(0);
|
|
100
117
|
}
|
|
101
118
|
|
|
102
|
-
const RECOGNIZED_FLAGS = new Set(["--profile", "--json", "--fast"]);
|
|
119
|
+
const RECOGNIZED_FLAGS = new Set(["--profile", "--json", "--fast", "--determinism"]);
|
|
103
120
|
for (let i = 0; i < rawArgs.length; i += 1) {
|
|
104
121
|
const arg = rawArgs[i];
|
|
105
122
|
if (arg === "--profile") {
|
|
@@ -115,8 +132,36 @@ const args = rawArgs;
|
|
|
115
132
|
const profile = args.includes("--profile") ? args[args.indexOf("--profile") + 1] : "local";
|
|
116
133
|
const asJson = args.includes("--json");
|
|
117
134
|
const fast = args.includes("--fast");
|
|
135
|
+
// --no-journal suppresses the flight-recorder append (qa/watch.mjs passes it).
|
|
136
|
+
// See the append site below for why the inner loop must not write here.
|
|
137
|
+
const noJournal = args.includes("--no-journal");
|
|
118
138
|
const mode = fast ? "fast" : "full";
|
|
119
139
|
|
|
140
|
+
// ── --determinism: the timezone double-run probe (roadmap §10 item 8) ───────
|
|
141
|
+
// Refusals up front, by name (same stance as unknown arguments above):
|
|
142
|
+
// - never with --fast: the probe deliberately runs the JVM test tier twice,
|
|
143
|
+
// and --fast is the inner loop that exists to not pay such costs — the
|
|
144
|
+
// combination is a contradiction, so it is refused rather than silently
|
|
145
|
+
// resolved either way.
|
|
146
|
+
// - only the ci profile (and release, which inherits ci) carries the probe's
|
|
147
|
+
// lane row; asking for it in local/scaffold is refused with the two ways
|
|
148
|
+
// that DO work, instead of silently running a step the requested profile
|
|
149
|
+
// does not own.
|
|
150
|
+
const determinism = args.includes("--determinism");
|
|
151
|
+
const profileExplicit = args.includes("--profile");
|
|
152
|
+
if (determinism && fast) {
|
|
153
|
+
console.error(
|
|
154
|
+
"--determinism cannot be combined with --fast: the probe runs the JVM test tier twice by design, and --fast is the inner loop. Run it alone (node qa/verify.mjs --determinism) or inside a full ci/release lane (--profile ci --determinism).",
|
|
155
|
+
);
|
|
156
|
+
process.exit(2);
|
|
157
|
+
}
|
|
158
|
+
if (determinism && profileExplicit && profile !== "ci" && profile !== "release") {
|
|
159
|
+
console.error(
|
|
160
|
+
`--determinism belongs to the ci profile (release inherits it), not "${profile}" — run --profile ci --determinism, or bare --determinism to run the probe alone.`,
|
|
161
|
+
);
|
|
162
|
+
process.exit(2);
|
|
163
|
+
}
|
|
164
|
+
|
|
120
165
|
const GRADLEW = process.platform === "win32" ? "gradlew.bat" : "./gradlew";
|
|
121
166
|
|
|
122
167
|
// ── `--rerun` is scoped to FULL mode ────────────────────────────────────────
|
|
@@ -158,6 +203,14 @@ function sh(cmd, opts = {}) {
|
|
|
158
203
|
const LANE_MARKER = path.join(ROOT, "composeApp", "build", ".cmp-lane-in-progress");
|
|
159
204
|
const KSP_COLLISION_RE = /Storage for \[[^\]]*\] is already registered/;
|
|
160
205
|
|
|
206
|
+
// Degraded-path activations observed during this run — self-heals and
|
|
207
|
+
// fallbacks that kept the lane moving without failing it. Collected for the
|
|
208
|
+
// flight recorder (qa/lib/flight-recorder.mjs): a degradation that fires
|
|
209
|
+
// once is a shrug, one that fires every run for a month is the tooling
|
|
210
|
+
// quietly rotting under a green lane — and only a journal can tell those
|
|
211
|
+
// two apart.
|
|
212
|
+
const DEGRADED_PATHS = [];
|
|
213
|
+
|
|
161
214
|
// The daemon's half of defense 2 above — pid + ISO timestamp, mirroring
|
|
162
215
|
// LANE_MARKER's own content shape (see where LANE_MARKER is stamped, below).
|
|
163
216
|
const RENDER_MARKER = path.join(ROOT, "composeApp", "build", ".cmp-render-in-progress");
|
|
@@ -197,6 +250,7 @@ function shGradle(cmd, opts = {}) {
|
|
|
197
250
|
const retry = sh(cmd, opts);
|
|
198
251
|
retry.durationMs += first.durationMs;
|
|
199
252
|
retry.selfHealed = "ksp-cache-collision";
|
|
253
|
+
DEGRADED_PATHS.push("ksp-cache-collision: cleared kspCaches and retried the Gradle step");
|
|
200
254
|
return retry;
|
|
201
255
|
}
|
|
202
256
|
|
|
@@ -363,6 +417,62 @@ function settleAdb() {
|
|
|
363
417
|
// itself lives in qa/lib/spec-coverage.mjs — the SAME scan feature-brief.mjs
|
|
364
418
|
// derives doneness from, so this gate and the Features view can never disagree
|
|
365
419
|
// about a clause. This step owns only the orphan decision + bookkeeping.
|
|
420
|
+
// The first question any verdict depends on: is the lane that is about to
|
|
421
|
+
// issue it the lane this app was given?
|
|
422
|
+
//
|
|
423
|
+
// Without this the receipt is unfalsifiable in one specific way — edit
|
|
424
|
+
// qa/verify.mjs to force every step PASS and the receipt still validates,
|
|
425
|
+
// because the edited file is simply part of the hashed input surface. Hashing
|
|
426
|
+
// the machine-owned region against qa/harness.lock.json closes that: the lane
|
|
427
|
+
// cannot vouch for itself while modified.
|
|
428
|
+
//
|
|
429
|
+
// DELIBERATELY NOT MEMOIZED. Every other pure-Node step can serve a cached
|
|
430
|
+
// PASS when its inputs are unchanged; a cached PASS on an integrity check is
|
|
431
|
+
// precisely the failure it exists to prevent, and 34 file reads are too cheap
|
|
432
|
+
// to be worth the risk.
|
|
433
|
+
//
|
|
434
|
+
// Three states, three verdicts:
|
|
435
|
+
// intact PASS
|
|
436
|
+
// modified FAIL — named files, with the command that restores them
|
|
437
|
+
// unlocked SKIP — an app stamped before locks existed. Nothing is known to
|
|
438
|
+
// be wrong, but nothing is proven either; recording the gap keeps
|
|
439
|
+
// the pipeline honest instead of quietly passing.
|
|
440
|
+
function stepHarnessIntegrity() {
|
|
441
|
+
const started = Date.now();
|
|
442
|
+
const r = checkHarnessIntegrity(ROOT);
|
|
443
|
+
const base = { name: "harnessIntegrity", durationMs: Date.now() - started, harness: r };
|
|
444
|
+
|
|
445
|
+
if (r.status === "intact") {
|
|
446
|
+
return { ...base, verdict: "PASS", note: describeIntegrity(r) };
|
|
447
|
+
}
|
|
448
|
+
if (r.status === "unlocked") {
|
|
449
|
+
return {
|
|
450
|
+
...base,
|
|
451
|
+
verdict: "SKIP",
|
|
452
|
+
reason: `no ${LOCK_PATH} — this app was stamped before harness locks existed. ` +
|
|
453
|
+
"`npx create-cmp-cli upgrade --harness` records one.",
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const named = [
|
|
458
|
+
...r.modified.map((f) => `modified ${f}`),
|
|
459
|
+
...r.missing.map((f) => `missing ${f}`),
|
|
460
|
+
...r.extra.map((f) => `unrecorded ${f}`),
|
|
461
|
+
];
|
|
462
|
+
return {
|
|
463
|
+
...base,
|
|
464
|
+
verdict: "FAIL",
|
|
465
|
+
reason:
|
|
466
|
+
`the verify lane has been modified since it was installed — ${describeIntegrity(r)}. ` +
|
|
467
|
+
"Lane code is machine-owned: it is byte-identical in every create-cmp app and carries " +
|
|
468
|
+
"no app content, so a local edit is either an accident, a half-applied upgrade, or an " +
|
|
469
|
+
"attempt to make this receipt say something the lane would not. Restore it with " +
|
|
470
|
+
"`npx create-cmp-cli upgrade --harness`, which also reports any genuine local patch " +
|
|
471
|
+
"instead of discarding it.",
|
|
472
|
+
files: named,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
366
476
|
function stepSpecCoverage() {
|
|
367
477
|
const started = Date.now();
|
|
368
478
|
const specsDir = path.join(ROOT, "specs");
|
|
@@ -696,6 +806,7 @@ function stepUnitTests() {
|
|
|
696
806
|
res = retry;
|
|
697
807
|
note = "full suite — the affected-test filter matched no tests (fell back)";
|
|
698
808
|
affected = null;
|
|
809
|
+
DEGRADED_PATHS.push("affected-test filter matched no tests — fell back to the full desktopTest suite");
|
|
699
810
|
}
|
|
700
811
|
const summary = junitSummary(path.join(ROOT, "composeApp/build/test-results/desktopTest"));
|
|
701
812
|
let details = summary ?? undefined;
|
|
@@ -728,6 +839,115 @@ const stepA11y = gradleTestStep(
|
|
|
728
839
|
"A11y gate failed (SHELL-04): interactive nodes must expose a testTag, text, or contentDescription:",
|
|
729
840
|
);
|
|
730
841
|
|
|
842
|
+
// ── Determinism probe (roadmap §10 item 8) — opt-in, ci-profile ────────────
|
|
843
|
+
// ARCH-13 statically bans ambient time reads — in APP code. A library the
|
|
844
|
+
// app calls can still read the wall clock, and a golden can still depend on
|
|
845
|
+
// the machine's timezone through a seam the static net cannot see (a
|
|
846
|
+
// ViewModel constructed without its injected clock already caused one
|
|
847
|
+
// overnight golden-tree drift). This probe closes that gap DYNAMICALLY: it
|
|
848
|
+
// runs the JVM test tier twice, under two timezones whose local calendar
|
|
849
|
+
// dates never agree — the offsets are 26 hours apart, so any date-derived
|
|
850
|
+
// value differs between the legs at every instant (see DETERMINISM_TIMEZONES
|
|
851
|
+
// in qa/lib/determinism.mjs for why UTC-12/UTC+14 and not UTC/UTC+14) — and
|
|
852
|
+
// FAILs naming every test whose outcome differs between the legs.
|
|
853
|
+
//
|
|
854
|
+
// Mechanics that carry the honesty:
|
|
855
|
+
// - TZ reaches the test JVM through the environment: Gradle forwards the
|
|
856
|
+
// client's environment to the daemon on every build, and test workers
|
|
857
|
+
// fork from the daemon — so the child env below is inherited all the way
|
|
858
|
+
// down to the JVM whose default timezone the tests see.
|
|
859
|
+
// - BOTH legs force --rerun. Without it Gradle would mark the second leg
|
|
860
|
+
// up-to-date (TZ is not a declared build input) and replay the first
|
|
861
|
+
// leg's results — the probe would then compare a run against its own
|
|
862
|
+
// echo and certify a determinism it never tested (the build-cache-replay
|
|
863
|
+
// lesson, again). The legs use the mode-scoped RERUN like every other
|
|
864
|
+
// desktopTest invocation — and because --determinism is refused alongside
|
|
865
|
+
// --fast up front, RERUN is always " --rerun" by the time a leg runs.
|
|
866
|
+
// - Only verdicts and failure output are compared; durations are never even
|
|
867
|
+
// parsed (qa/lib/determinism.mjs), so a timing wobble is structurally
|
|
868
|
+
// unable to trip the probe.
|
|
869
|
+
function stepDeterminism() {
|
|
870
|
+
const started = Date.now();
|
|
871
|
+
const elapsed = () => Date.now() - started;
|
|
872
|
+
if (!determinism) {
|
|
873
|
+
return {
|
|
874
|
+
name: "determinism",
|
|
875
|
+
verdict: "SKIP",
|
|
876
|
+
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",
|
|
877
|
+
durationMs: elapsed(),
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const resultsDir = path.join(ROOT, "composeApp/build/test-results/desktopTest");
|
|
882
|
+
const legs = [];
|
|
883
|
+
for (const { tz, label } of DETERMINISM_TIMEZONES) {
|
|
884
|
+
const res = shGradle(`${GRADLEW} :composeApp:desktopTest${RERUN} --console=plain`, { env: { ...process.env, TZ: tz } });
|
|
885
|
+
// Parsed NOW, before the next leg overwrites the same results directory.
|
|
886
|
+
const outcomes = parseJUnitOutcomes(resultsDir);
|
|
887
|
+
legs.push({ tz, label, ok: res.ok, outcomes, tail: res.out.split("\n").slice(-8).join("\n") });
|
|
888
|
+
}
|
|
889
|
+
const [a, b] = legs;
|
|
890
|
+
const labelA = `TZ=${a.tz} (${a.label})`;
|
|
891
|
+
const labelB = `TZ=${b.tz} (${b.label})`;
|
|
892
|
+
const countA = Object.keys(a.outcomes).length;
|
|
893
|
+
const countB = Object.keys(b.outcomes).length;
|
|
894
|
+
|
|
895
|
+
if (countA === 0 && countB === 0) {
|
|
896
|
+
// Neither leg produced a single test result: the suite failed before
|
|
897
|
+
// running anything (build error). The probe measured nothing — that is
|
|
898
|
+
// a FAIL that says so, never a PASS by absence of differences.
|
|
899
|
+
return {
|
|
900
|
+
name: "determinism",
|
|
901
|
+
verdict: "FAIL",
|
|
902
|
+
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}`,
|
|
903
|
+
durationMs: elapsed(),
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
if (countA === 0 || countB === 0) {
|
|
907
|
+
const ran = countA > 0 ? { label: labelA, n: countA } : { label: labelB, n: countB };
|
|
908
|
+
const empty = countA > 0 ? b : a;
|
|
909
|
+
return {
|
|
910
|
+
name: "determinism",
|
|
911
|
+
verdict: "FAIL",
|
|
912
|
+
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}`,
|
|
913
|
+
durationMs: elapsed(),
|
|
914
|
+
details: { timezones: DETERMINISM_TIMEZONES.map((t) => t.tz) },
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const diffs = compareOutcomes(a.outcomes, b.outcomes, labelA, labelB);
|
|
919
|
+
if (diffs.length > 0) {
|
|
920
|
+
const lines = [
|
|
921
|
+
`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"):`,
|
|
922
|
+
];
|
|
923
|
+
for (const d of diffs.slice(0, 20)) lines.push(` [${d.step}] ${d.test} — ${d.detail}`);
|
|
924
|
+
if (diffs.length > 20) lines.push(` … and ${diffs.length - 20} more differing test(s)`);
|
|
925
|
+
return {
|
|
926
|
+
name: "determinism",
|
|
927
|
+
verdict: "FAIL",
|
|
928
|
+
reason: lines.join("\n"),
|
|
929
|
+
durationMs: elapsed(),
|
|
930
|
+
details: { timezones: DETERMINISM_TIMEZONES.map((t) => t.tz), diffs: diffs.slice(0, 50) },
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const failedIdentically = Object.values(a.outcomes).filter((o) => o.status !== "pass" && o.status !== "skip").length;
|
|
935
|
+
return {
|
|
936
|
+
name: "determinism",
|
|
937
|
+
verdict: "PASS",
|
|
938
|
+
// Identical red is DETERMINISTIC red: the probe's claim ("no timezone
|
|
939
|
+
// dependence") holds, and the failing tests already belong to
|
|
940
|
+
// unitTests/goldenTrees, which fail the lane on their own merits — a
|
|
941
|
+
// second FAIL here would report the same defect twice under a wrong name.
|
|
942
|
+
note:
|
|
943
|
+
failedIdentically > 0
|
|
944
|
+
? `${failedIdentically} test(s) failed identically under both timezones — deterministic, but red (the owning test steps report it)`
|
|
945
|
+
: undefined,
|
|
946
|
+
durationMs: elapsed(),
|
|
947
|
+
details: { timezones: DETERMINISM_TIMEZONES.map((t) => t.tz), testsCompared: countA },
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
|
|
731
951
|
// Live tokenDrift tier (harness M4-D): when a debug app + device are available,
|
|
732
952
|
// fetches the declared catalog and the live semantics tree off the debug-only
|
|
733
953
|
// inspector server (127.0.0.1:9500, see composeApp/src/androidDebug/.../
|
|
@@ -1052,6 +1272,43 @@ function stepReleaseSmoke() {
|
|
|
1052
1272
|
return runMaestroSmoke("releaseSmoke", install.durationMs);
|
|
1053
1273
|
}
|
|
1054
1274
|
|
|
1275
|
+
// ── Audit cadence (roadmap §10 item 9) — a REPORT, never a gate ────────────
|
|
1276
|
+
// cmp-audit (the adversarial platform-semantics audit) found six latent
|
|
1277
|
+
// defects the first time a human happened to ask for it — which is exactly
|
|
1278
|
+
// why it must not depend on someone remembering to ask. This step is the
|
|
1279
|
+
// cheapest honest replacement for that memory: at ship time (release
|
|
1280
|
+
// profile) the receipt lists which androidMain subsystems changed since
|
|
1281
|
+
// their last RECORDED audit (qa/audits.jsonl, appended by
|
|
1282
|
+
// node qa/record-audit.mjs). The derivation lives in
|
|
1283
|
+
// qa/lib/audit-cadence.mjs; this step adds only the bookkeeping every step
|
|
1284
|
+
// carries — and by construction it maps every outcome to PASS or SKIP,
|
|
1285
|
+
// never FAIL: audit debt is a judgment call (a rename is not six latent
|
|
1286
|
+
// defects), and a gate here would teach people to game the ledger, which
|
|
1287
|
+
// would destroy the only value it has.
|
|
1288
|
+
function stepAuditCadence() {
|
|
1289
|
+
const started = Date.now();
|
|
1290
|
+
const report = evaluateAuditCadence(ROOT);
|
|
1291
|
+
if (!report.ok) {
|
|
1292
|
+
return { name: "auditCadence", verdict: "SKIP", reason: report.reason, durationMs: Date.now() - started };
|
|
1293
|
+
}
|
|
1294
|
+
return {
|
|
1295
|
+
name: "auditCadence",
|
|
1296
|
+
verdict: "PASS",
|
|
1297
|
+
note: report.summary,
|
|
1298
|
+
durationMs: Date.now() - started,
|
|
1299
|
+
details: {
|
|
1300
|
+
packageRoot: report.packageRoot,
|
|
1301
|
+
subsystems: report.subsystems.map((s) => ({
|
|
1302
|
+
name: s.name,
|
|
1303
|
+
status: s.status,
|
|
1304
|
+
changedFiles: s.changedFiles,
|
|
1305
|
+
lastAudit: s.audit ? { sha: s.audit.sha, at: s.audit.at, by: s.audit.by } : null,
|
|
1306
|
+
})),
|
|
1307
|
+
lines: report.lines,
|
|
1308
|
+
},
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1055
1312
|
// ── Lane ───────────────────────────────────────────────────────────────────
|
|
1056
1313
|
|
|
1057
1314
|
// Device-dependent steps, in lane order. Used twice: receipt STRENGTH (which
|
|
@@ -1107,8 +1364,11 @@ const stepArchDocMemo = memoized("archDoc", stepArchDoc);
|
|
|
1107
1364
|
const stepsForProfile = {
|
|
1108
1365
|
// scaffold: what `create-cmp --verify` proves at stamp time — specCoverage,
|
|
1109
1366
|
// the full JVM tier (unit + conformance + golden + UI tests) plus the Android build.
|
|
1110
|
-
scaffold: [stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepArchDocMemo, stepSchemaHistory, stepBuild, stepUnitTests],
|
|
1367
|
+
scaffold: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepArchDocMemo, stepSchemaHistory, stepBuild, stepUnitTests],
|
|
1111
1368
|
local: [
|
|
1369
|
+
// First, always: every verdict below is only worth what the lane issuing
|
|
1370
|
+
// it is worth.
|
|
1371
|
+
stepHarnessIntegrity,
|
|
1112
1372
|
stepSpecCoverageMemo,
|
|
1113
1373
|
stepApprovalsMemo,
|
|
1114
1374
|
stepComponentStoriesMemo,
|
|
@@ -1137,19 +1397,57 @@ const stepsForProfile = {
|
|
|
1137
1397
|
stepAndroidChecks,
|
|
1138
1398
|
],
|
|
1139
1399
|
};
|
|
1140
|
-
|
|
1141
|
-
//
|
|
1142
|
-
//
|
|
1143
|
-
//
|
|
1144
|
-
//
|
|
1145
|
-
//
|
|
1146
|
-
|
|
1400
|
+
// ci = local + the determinism probe's row — the first place ci diverges
|
|
1401
|
+
// from local. The probe is OPT-IN (the step SKIPs unless --determinism was
|
|
1402
|
+
// passed: it doubles the JVM test tier's cost), but its row lives in the ci
|
|
1403
|
+
// profile so a ci receipt always records whether the probe ran — an honest,
|
|
1404
|
+
// visible gap beats an invisible one ("SKIPs are recorded so the pipeline
|
|
1405
|
+
// stays honest", per the profile's own contract). local deliberately does
|
|
1406
|
+
// NOT carry the row: the per-change developer profile is not where a
|
|
1407
|
+
// deliberate double-run belongs.
|
|
1408
|
+
stepsForProfile.ci = [...stepsForProfile.local, stepDeterminism];
|
|
1409
|
+
// release = everything ci proves PLUS the audit-cadence report and the
|
|
1410
|
+
// release-APK behavior smoke. The expensive proofs are profile-tiered by
|
|
1411
|
+
// decision: per-change stays fast (local/ci pay for the release COMPILE via
|
|
1412
|
+
// releaseBuild, already in the set), and the release-variant *behavior* cost
|
|
1413
|
+
// lands once, at ship time. auditCadence (a report, never a gate) also
|
|
1414
|
+
// belongs to ship time — "what moved in androidMain since its last
|
|
1415
|
+
// adversarial audit?" is the question asked before shipping, not per edit.
|
|
1416
|
+
// releaseSmoke runs last so the device ends the run holding the exact build
|
|
1417
|
+
// that was proven.
|
|
1418
|
+
stepsForProfile.release = [...stepsForProfile.ci, stepAuditCadence, stepReleaseSmoke];
|
|
1147
1419
|
|
|
1148
1420
|
if (!stepsForProfile[profile]) {
|
|
1149
1421
|
console.error(`Unknown profile "${profile}" — use scaffold | local | ci | release.`);
|
|
1150
1422
|
process.exit(2);
|
|
1151
1423
|
}
|
|
1152
1424
|
|
|
1425
|
+
// ── Bare --determinism: the probe, nothing else, and NO receipt ─────────────
|
|
1426
|
+
// "Run it alone" means alone: no other steps, and deliberately no
|
|
1427
|
+
// qa/evidence/latest.json. The done-gate (qa/receipt-check.mjs) validates a
|
|
1428
|
+
// receipt by verdict + content hash — a receipt whose steps are one probe
|
|
1429
|
+
// would satisfy it while attesting almost nothing, so a probe-only run must
|
|
1430
|
+
// never mint one. The lane marker IS still stamped: the probe runs Gradle
|
|
1431
|
+
// and owes the preview daemon the same coexistence courtesy as the lane.
|
|
1432
|
+
if (determinism && !profileExplicit) {
|
|
1433
|
+
fs.mkdirSync(path.dirname(LANE_MARKER), { recursive: true });
|
|
1434
|
+
fs.writeFileSync(LANE_MARKER, `${process.pid} ${new Date().toISOString()}\n`);
|
|
1435
|
+
let probe;
|
|
1436
|
+
try {
|
|
1437
|
+
probe = stepDeterminism();
|
|
1438
|
+
} finally {
|
|
1439
|
+
fs.rmSync(LANE_MARKER, { force: true });
|
|
1440
|
+
}
|
|
1441
|
+
if (asJson) {
|
|
1442
|
+
console.log(JSON.stringify(probe, null, 2));
|
|
1443
|
+
} else {
|
|
1444
|
+
const mark = probe.verdict === "PASS" ? "✓" : "✗";
|
|
1445
|
+
console.log(`${mark} determinism: ${probe.verdict}${probe.note ? ` (${probe.note})` : ""}${probe.reason ? ` — ${probe.reason}` : ""}`);
|
|
1446
|
+
console.log("\n(probe-only run — no receipt written; the full lane is where evidence is earned)");
|
|
1447
|
+
}
|
|
1448
|
+
process.exit(probe.verdict === "FAIL" ? 1 : 0);
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1153
1451
|
// ── --fast: the inner loop, mechanically unable to claim done ───────────────
|
|
1154
1452
|
// The genuinely slow tier is device/release work — every DEVICE_STEPS entry
|
|
1155
1453
|
// (Gradle install + emulator + Maestro + instrumented runner) plus
|
|
@@ -1202,6 +1500,7 @@ if (fast) {
|
|
|
1202
1500
|
// always removed, even on a failing step, so the eyes only ever defer briefly.
|
|
1203
1501
|
fs.mkdirSync(path.dirname(LANE_MARKER), { recursive: true });
|
|
1204
1502
|
fs.writeFileSync(LANE_MARKER, `${process.pid} ${new Date().toISOString()}\n`);
|
|
1503
|
+
const laneStartedAt = Date.now(); // for the flight-recorder entry's durationMs
|
|
1205
1504
|
const steps = [];
|
|
1206
1505
|
try {
|
|
1207
1506
|
for (const step of laneSteps) {
|
|
@@ -1263,6 +1562,30 @@ if (fs.existsSync(ARTIFACTS_DIR)) {
|
|
|
1263
1562
|
// Bind the receipt to the content of the verified surface (ADR-0005), NOT the
|
|
1264
1563
|
// parent SHA (rebase/merge-fragile). Must be computed before latest.json is
|
|
1265
1564
|
// written — the receipt is an output and must never hash itself.
|
|
1565
|
+
/**
|
|
1566
|
+
* The receipt's harness summary — compact by design. The per-file detail lives
|
|
1567
|
+
* on the harnessIntegrity step; this is the part a receipt-holder needs to
|
|
1568
|
+
* identify the lane, plus the names of any modified files (an auditor told
|
|
1569
|
+
* "not intact" and not told which files has been given a rumour, not a fact).
|
|
1570
|
+
*/
|
|
1571
|
+
function harnessForReceipt() {
|
|
1572
|
+
const row = steps.find((st) => st.name === "harnessIntegrity");
|
|
1573
|
+
const r = row?.harness ?? checkHarnessIntegrity(ROOT);
|
|
1574
|
+
const summary = {
|
|
1575
|
+
name: r.name,
|
|
1576
|
+
version: r.version,
|
|
1577
|
+
sha256: r.sha256,
|
|
1578
|
+
status: r.status,
|
|
1579
|
+
intact: r.status === "intact",
|
|
1580
|
+
};
|
|
1581
|
+
if (r.status === "modified") {
|
|
1582
|
+
summary.modified = r.modified;
|
|
1583
|
+
summary.missing = r.missing;
|
|
1584
|
+
summary.extra = r.extra;
|
|
1585
|
+
}
|
|
1586
|
+
return summary;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1266
1589
|
const inputs = computeInputsHash(ROOT);
|
|
1267
1590
|
|
|
1268
1591
|
// The receipt. Deterministic key order; ONE volatile timestamp field.
|
|
@@ -1285,6 +1608,16 @@ const receipt = {
|
|
|
1285
1608
|
fileCount: inputs.fileCount,
|
|
1286
1609
|
},
|
|
1287
1610
|
steps,
|
|
1611
|
+
// WHICH LANE issued this verdict. A receipt that cannot name its own harness
|
|
1612
|
+
// can only be checked against the tree it came from; naming the version and
|
|
1613
|
+
// the region digest lets a third party who holds the receipt ask the harder
|
|
1614
|
+
// question — was this the real published lane? — without the tree at all.
|
|
1615
|
+
//
|
|
1616
|
+
// `intact` is the LOCAL claim only: unmodified since installed. It is a
|
|
1617
|
+
// checksum, not a signature, and someone who edits the lane can edit this
|
|
1618
|
+
// too. What they cannot edit is what the registry published under that
|
|
1619
|
+
// version, which is why `version` + `sha256` travel together.
|
|
1620
|
+
harness: harnessForReceipt(),
|
|
1288
1621
|
strength: { onDeviceSteps },
|
|
1289
1622
|
evidenceLevel: level,
|
|
1290
1623
|
artifacts,
|
|
@@ -1301,6 +1634,51 @@ fs.writeFileSync(path.join(EVIDENCE_DIR, "latest.json"), `${JSON.stringify(recei
|
|
|
1301
1634
|
// studio console's Evidence audit trail reconstructs the full history from the
|
|
1302
1635
|
// git log of this file — every commit is one verified, attributed state.
|
|
1303
1636
|
|
|
1637
|
+
// The README's evidence badge is DERIVED from the receipt just written — an
|
|
1638
|
+
// output, never a gate, so it runs after the verdict and cannot change it. It
|
|
1639
|
+
// renders the rung together with the commit it was attested against, so the
|
|
1640
|
+
// sentence stays true as the tree moves on (qa/lib/evidence-badge.mjs).
|
|
1641
|
+
const badge = updateReadmeBadge(ROOT);
|
|
1642
|
+
|
|
1643
|
+
// ── Flight recorder (roadmap §10 item 5) — the lane journals its own run ────
|
|
1644
|
+
// One JSON line per run into qa/flight-recorder.jsonl (committed, and
|
|
1645
|
+
// excluded from the receipt's hashed surface — qa/lib/flight-recorder.mjs
|
|
1646
|
+
// carries the whole rationale). Appended AFTER the receipt so the entry
|
|
1647
|
+
// records the final verdict and rung. A failed append must never fail the
|
|
1648
|
+
// lane — a recorder that breaks the thing it observes is worse than no
|
|
1649
|
+
// recorder — so the failure degrades to a note in the lane's own output,
|
|
1650
|
+
// which is itself the honest record of the degradation.
|
|
1651
|
+
//
|
|
1652
|
+
// --no-journal is the ONE exemption, and qa/watch.mjs passes it on every
|
|
1653
|
+
// save-triggered run. Same rule the README badge obeys, for the same reason:
|
|
1654
|
+
// THE INNER LOOP DOES NOT WRITE TO COMMITTED FILES. A watcher journaling every
|
|
1655
|
+
// save would add hundreds of lines a day to a committed file — turning the
|
|
1656
|
+
// app's history into keystroke noise and leaving a permanently-dirty tree in
|
|
1657
|
+
// the loop the recorder exists to observe. What survives is every full lane
|
|
1658
|
+
// and every DELIBERATE fast run, which is what the retrospective's questions
|
|
1659
|
+
// actually rest on (SKIP reasons, degraded paths, the longest stretch with no
|
|
1660
|
+
// full lane). qa/retrospective.mjs discloses the exemption in its own output
|
|
1661
|
+
// so the fast-vs-full ratio is never read as a complete census.
|
|
1662
|
+
const flight = noJournal
|
|
1663
|
+
? { ok: true, skipped: true }
|
|
1664
|
+
: appendFlightRecord(
|
|
1665
|
+
ROOT,
|
|
1666
|
+
buildFlightEntry({
|
|
1667
|
+
profile,
|
|
1668
|
+
mode,
|
|
1669
|
+
verdict,
|
|
1670
|
+
evidenceLevel: level,
|
|
1671
|
+
steps,
|
|
1672
|
+
sha: receipt.commit.sha,
|
|
1673
|
+
durationMs: Date.now() - laneStartedAt,
|
|
1674
|
+
onDeviceSteps,
|
|
1675
|
+
degraded: DEGRADED_PATHS,
|
|
1676
|
+
}),
|
|
1677
|
+
);
|
|
1678
|
+
if (!flight.ok) {
|
|
1679
|
+
console.error(`· flight recorder: journal append failed (${flight.reason}) — the lane verdict is unaffected, but this run is missing from qa/flight-recorder.jsonl`);
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1304
1682
|
if (asJson) {
|
|
1305
1683
|
console.log(JSON.stringify(receipt, null, 2));
|
|
1306
1684
|
if (fast) {
|
|
@@ -1313,7 +1691,19 @@ if (asJson) {
|
|
|
1313
1691
|
`\n${verdict === "PASS" ? "⚡⚡" : "❌"} verify lane [FAST — INNER LOOP ONLY, NOT DONE]: ${verdict} (skipped device/release tier: ${fastExcluded.join(", ") || "none"}) — this fast receipt satisfies no done-gate; run the full lane (node qa/verify.mjs) once before you finish`,
|
|
1314
1692
|
);
|
|
1315
1693
|
} else {
|
|
1316
|
-
console.log(`\n${verdict === "PASS" ? "✅" : "❌"} verify lane: ${verdict}${level ? ` · ${level.rung} ${level.name}` : ""} (${strengthLabel}) — receipt written to qa/evidence/latest.json (commit it with your change)`);
|
|
1694
|
+
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)`);
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
// The audit-cadence nudges print in the human path, not only inside the
|
|
1698
|
+
// receipt JSON — a ship-time report that lives only in a JSON field is a
|
|
1699
|
+
// report nobody reads at ship time. Nudges only; a gate this is not.
|
|
1700
|
+
if (!asJson) {
|
|
1701
|
+
const auditStep = steps.find((s) => s.name === "auditCadence");
|
|
1702
|
+
const auditLines = auditStep?.details?.lines ?? [];
|
|
1703
|
+
if (auditLines.length > 0) {
|
|
1704
|
+
console.log("\naudit cadence (report, never a gate):");
|
|
1705
|
+
for (const l of auditLines) console.log(` ${l}`);
|
|
1706
|
+
}
|
|
1317
1707
|
}
|
|
1318
1708
|
|
|
1319
1709
|
process.exit(verdict === "PASS" ? 0 : 1);
|
package/template/qa/watch.mjs
CHANGED
|
@@ -358,8 +358,8 @@ function main() {
|
|
|
358
358
|
const n = runCounter;
|
|
359
359
|
const startedAtIso = new Date().toISOString();
|
|
360
360
|
const started = Date.now();
|
|
361
|
-
say(`── watch run #${n} starting (node qa/verify.mjs --fast) …`);
|
|
362
|
-
const child = spawn(process.execPath, [path.join(ROOT, "qa", "verify.mjs"), "--fast", "--json"], {
|
|
361
|
+
say(`── watch run #${n} starting (node qa/verify.mjs --fast --no-journal) …`);
|
|
362
|
+
const child = spawn(process.execPath, [path.join(ROOT, "qa", "verify.mjs"), "--fast", "--json", "--no-journal"], {
|
|
363
363
|
cwd: ROOT,
|
|
364
364
|
stdio: ["ignore", "pipe", "pipe"],
|
|
365
365
|
// Its own process GROUP: verify spawns Gradle through a shell, and a
|