create-cmp-cli 0.24.0 → 0.25.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.
Files changed (141) hide show
  1. package/bin/create-cmp.mjs +33 -3
  2. package/package.json +10 -4
  3. package/packages/harness/package.json +18 -8
  4. package/packages/harness/src/approve.mjs +10 -11
  5. package/packages/harness/src/console/console-data.mjs +117 -0
  6. package/packages/harness/src/console/console-evidence.mjs +122 -0
  7. package/packages/harness/src/console/console-overview.mjs +642 -0
  8. package/packages/harness/src/console/console-shell.mjs +1139 -0
  9. package/packages/harness/src/console/console-tabs.mjs +2828 -0
  10. package/packages/harness/src/console/contrast.mjs +74 -0
  11. package/packages/harness/src/console/preview-service.mjs +1384 -0
  12. package/packages/harness/src/framework-check.mjs +271 -37
  13. package/packages/harness/src/lib/a11y.mjs +1 -1
  14. package/packages/harness/src/lib/affected-tests.mjs +99 -44
  15. package/packages/harness/src/lib/approvals.mjs +177 -350
  16. package/packages/harness/src/lib/audit-cadence.mjs +1 -1
  17. package/packages/harness/src/lib/comments.mjs +18 -4
  18. package/packages/harness/src/lib/determinism.mjs +275 -14
  19. package/packages/harness/src/lib/evidence-badge.mjs +34 -2
  20. package/packages/harness/src/lib/evidence-ladder.mjs +202 -0
  21. package/packages/harness/src/lib/evidence-level.mjs +144 -59
  22. package/packages/harness/src/lib/feature-brief.mjs +53 -16
  23. package/packages/harness/src/lib/flight-recorder.mjs +72 -8
  24. package/packages/harness/src/lib/framework-check.mjs +434 -53
  25. package/packages/harness/src/lib/harness-lock.mjs +87 -19
  26. package/packages/harness/src/lib/harness-manifest.mjs +208 -0
  27. package/packages/harness/src/lib/harness-region.mjs +106 -2
  28. package/packages/harness/src/lib/harness-source.mjs +142 -0
  29. package/packages/harness/src/lib/inputs-hash.mjs +175 -22
  30. package/packages/harness/src/lib/lane-markers.mjs +78 -0
  31. package/packages/harness/src/lib/lane-narrator.mjs +4 -2
  32. package/packages/harness/src/lib/lane-runner.mjs +72 -6
  33. package/packages/harness/src/lib/plan.mjs +44 -11
  34. package/packages/harness/src/lib/plant-calibration.mjs +135 -0
  35. package/packages/harness/src/lib/profile-loader.mjs +323 -0
  36. package/packages/harness/src/lib/profiles/cmp/affected.mjs +70 -0
  37. package/packages/harness/src/lib/profiles/cmp/android-checks.mjs +56 -0
  38. package/packages/harness/src/lib/profiles/cmp/artifacts.mjs +381 -0
  39. package/packages/harness/src/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
  40. package/packages/harness/src/lib/profiles/cmp/console-copy.mjs +29 -0
  41. package/packages/harness/src/lib/profiles/cmp/declarations.mjs +181 -0
  42. package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
  43. package/packages/harness/src/lib/profiles/cmp/index.mjs +47 -0
  44. package/packages/harness/src/lib/profiles/cmp/ladder.mjs +67 -0
  45. package/packages/harness/src/lib/profiles/cmp/maestro.mjs +113 -0
  46. package/packages/harness/src/lib/profiles/cmp/plants.mjs +95 -0
  47. package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/reachability.mjs +1 -1
  48. package/packages/harness/src/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +229 -27
  49. package/packages/harness/src/lib/receipt-validate.mjs +84 -16
  50. package/packages/harness/src/lib/spec-coverage.mjs +179 -92
  51. package/packages/harness/src/lib/spec-model.mjs +262 -0
  52. package/packages/harness/src/lib/step-cache.mjs +11 -2
  53. package/packages/harness/src/lib/step-outcomes.mjs +84 -151
  54. package/packages/harness/src/lib/walk.mjs +1 -1
  55. package/packages/harness/src/preview-gallery.mjs +1 -1
  56. package/packages/harness/src/receipt-check.mjs +80 -24
  57. package/packages/harness/src/record-audit.mjs +1 -1
  58. package/packages/harness/src/scaffold-feature.mjs +10 -2
  59. package/packages/harness/src/verify.mjs +222 -142
  60. package/packages/harness/src/watch.mjs +146 -33
  61. package/packages/receipts/package.json +1 -1
  62. package/packages/receipts/src/index.mjs +2 -2
  63. package/packages/receipts/src/inputs-hash.mjs +175 -22
  64. package/packages/receipts/src/receipt-validate.mjs +84 -16
  65. package/src/commands/attach.mjs +128 -4
  66. package/src/commands/harden.mjs +7 -1
  67. package/src/commands/upgrade.mjs +7 -0
  68. package/src/lib/minimal.mjs +23 -1
  69. package/src/scaffold.mjs +8 -0
  70. package/template/.claude/settings.json +2 -2
  71. package/template/.claude/skills/add-feature/SKILL.md +7 -3
  72. package/template/.claude/skills/add-repository/SKILL.md +2 -2
  73. package/template/.claude/skills/add-screen/SKILL.md +8 -4
  74. package/template/.githooks/pre-push +9 -0
  75. package/template/CLAUDE.md +55 -186
  76. package/template/docs/TESTING.md +1 -1
  77. package/template/gitignore +3 -0
  78. package/template/qa/approve.mjs +10 -11
  79. package/template/qa/comments.json +1 -1
  80. package/template/qa/evidence/schema.json +258 -34
  81. package/template/qa/framework-check.mjs +271 -37
  82. package/template/qa/harness-manifest.json +10 -0
  83. package/template/qa/lib/a11y.mjs +1 -1
  84. package/template/qa/lib/affected-tests.mjs +99 -44
  85. package/template/qa/lib/approvals.mjs +177 -350
  86. package/template/qa/lib/audit-cadence.mjs +1 -1
  87. package/template/qa/lib/comments.mjs +18 -4
  88. package/template/qa/lib/determinism.mjs +275 -14
  89. package/template/qa/lib/evidence-badge.mjs +34 -2
  90. package/template/qa/lib/evidence-ladder.mjs +202 -0
  91. package/template/qa/lib/evidence-level.mjs +144 -59
  92. package/template/qa/lib/feature-brief.mjs +53 -16
  93. package/template/qa/lib/flight-recorder.mjs +72 -8
  94. package/template/qa/lib/framework-check.mjs +434 -53
  95. package/template/qa/lib/harness-lock.mjs +87 -19
  96. package/template/qa/lib/harness-manifest.mjs +208 -0
  97. package/template/qa/lib/harness-region.mjs +106 -2
  98. package/template/qa/lib/harness-source.mjs +142 -0
  99. package/template/qa/lib/inputs-hash.mjs +175 -22
  100. package/template/qa/lib/lane-markers.mjs +78 -0
  101. package/template/qa/lib/lane-narrator.mjs +4 -2
  102. package/template/qa/lib/lane-runner.mjs +72 -6
  103. package/template/qa/lib/plan.mjs +44 -11
  104. package/template/qa/lib/plant-calibration.mjs +135 -0
  105. package/template/qa/lib/profile-loader.mjs +323 -0
  106. package/template/qa/lib/profiles/cmp/affected.mjs +70 -0
  107. package/template/qa/lib/profiles/cmp/android-checks.mjs +56 -0
  108. package/template/qa/lib/profiles/cmp/artifacts.mjs +381 -0
  109. package/template/qa/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
  110. package/template/qa/lib/profiles/cmp/console-copy.mjs +29 -0
  111. package/template/qa/lib/profiles/cmp/declarations.mjs +181 -0
  112. package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
  113. package/template/qa/lib/profiles/cmp/index.mjs +47 -0
  114. package/template/qa/lib/profiles/cmp/ladder.mjs +67 -0
  115. package/template/qa/lib/profiles/cmp/maestro.mjs +113 -0
  116. package/template/qa/lib/profiles/cmp/plants.mjs +95 -0
  117. package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/reachability.mjs +1 -1
  118. package/template/qa/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +229 -27
  119. package/template/qa/lib/receipt-validate.mjs +84 -16
  120. package/template/qa/lib/spec-coverage.mjs +179 -92
  121. package/template/qa/lib/spec-model.mjs +262 -0
  122. package/template/qa/lib/step-cache.mjs +11 -2
  123. package/template/qa/lib/step-outcomes.mjs +84 -151
  124. package/template/qa/lib/walk.mjs +1 -1
  125. package/template/qa/preview-gallery.mjs +1 -1
  126. package/template/qa/receipt-check.mjs +80 -24
  127. package/template/qa/record-audit.mjs +1 -1
  128. package/template/qa/scaffold-feature.mjs +10 -2
  129. package/template/qa/verified-surface.json +17 -0
  130. package/template/qa/verify.mjs +222 -142
  131. package/template/qa/watch.mjs +146 -33
  132. /package/packages/harness/src/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
  133. /package/packages/harness/src/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
  134. /package/packages/harness/src/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
  135. /package/packages/harness/src/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
  136. /package/packages/harness/src/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
  137. /package/template/qa/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
  138. /package/template/qa/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
  139. /package/template/qa/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
  140. /package/template/qa/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
  141. /package/template/qa/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
@@ -20,35 +20,47 @@
20
20
  import fs from "node:fs";
21
21
  import path from "node:path";
22
22
  import { compareTokenDrift } from "./token-drift.mjs";
23
- import { evaluateApprovalsGate } from "./approvals.mjs";
24
- import { E2E_FLOW_DIR, TIERS_SATISFYING, clauseTierCoverage, listFlowFiles, scanCitations, scanSpecClauses, walkFiles } from "./spec-coverage.mjs";
23
+ import { evaluateApprovalsGate } from "../../approvals.mjs";
24
+ import { clauseTierCoverage, listFlowFiles, scanCitations, scanSpecClauses, walkFiles } from "../../spec-coverage.mjs";
25
+ import { specModelFrom } from "../../spec-model.mjs";
26
+ import { layout as cmpLayout, tiers as cmpTiers, reports, grammar as cmpGrammar } from "./declarations.mjs";
27
+ import { RENDER_MARKER_FRESH_MS, RENDER_MARKER_NAME } from "../../lane-markers.mjs";
28
+
29
+ // The scanner's model, built from this profile's own declarations — the pack
30
+ // hands its facts to the core rather than round-tripping through the manifest.
31
+ const SPEC_MODEL = (() => {
32
+ const r = specModelFrom({ id: "cmp", layout: cmpLayout, tiers: cmpTiers, grammar: cmpGrammar });
33
+ if (!r.ok) throw new Error(r.reason);
34
+ return r.model;
35
+ })();
36
+ /** Where the flows the lane runs live. */
37
+ const E2E_FLOW_DIR = cmpLayout.flows.dir;
25
38
  import { evaluateComponentStoryParity } from "./component-stories.mjs";
26
39
  import { evaluateReachability } from "./reachability.mjs";
27
40
  import { evaluateE2eCoverage } from "./e2e-coverage.mjs";
28
- import { memoizeStep } from "./step-cache.mjs";
29
- import { changedWorkingTreePaths, deriveAffectedFilter } from "./affected-tests.mjs";
41
+ import { memoizeStep } from "../../step-cache.mjs";
42
+ import { changedWorkingTreePaths, deriveAffectedFilter } from "../../affected-tests.mjs";
43
+ import { affected as cmpAffected } from "./affected.mjs";
30
44
  import { acquireDeviceLease, releaseDeviceLease, formatHolder } from "./device-lease.mjs";
31
- import { ARCH_DOC_REL_PATH, SECTION_IDS, regenerateArchDoc } from "./arch-doc.mjs";
32
- import { DETERMINISM_TIMEZONES, compareOutcomes, parseJUnitOutcomes } from "./determinism.mjs";
33
- import { evaluateAuditCadence } from "./audit-cadence.mjs";
34
- import { androidChecksOutcome, deviceLogIncidents, maestroOutcome, parseMaestroJunit } from "./step-outcomes.mjs";
45
+ import { ARCH_DOC_REL_PATH, SECTION_IDS, regenerateArchDoc } from "../../arch-doc.mjs";
46
+ import { DETERMINISM_TIMEZONES, compareOutcomes, parseReportOutcomes, reportFormatProblem } from "../../determinism.mjs";
47
+ import { evaluateAuditCadence } from "../../audit-cadence.mjs";
48
+ import { androidChecksOutcome } from "./android-checks.mjs";
49
+ import { deviceLogIncidents, maestroOutcome, parseMaestroJunit } from "./maestro.mjs";
35
50
  import { ensureDevice, releaseDevice } from "./device-provider.mjs";
36
- import { checkHarnessIntegrity, describeIntegrity, LOCK_PATH } from "./harness-lock.mjs";
37
- import { stepDisplayName } from "./lane-runner.mjs";
38
- import { CMP_LADDER } from "./evidence-level.mjs";
51
+ import { checkHarnessIntegrity, describeIntegrity, LOCK_PATH } from "../../harness-lock.mjs";
52
+ import { stepDisplayName } from "../../lane-runner.mjs";
53
+ import { CMP_LADDER } from "./ladder.mjs";
39
54
 
40
55
  /**
41
56
  * @param {object} ctx
42
57
  * @param {string} ctx.ROOT project root
43
58
  * @param {string} ctx.HERE the qa/ directory
44
- * @param {string} ctx.GRADLEW the wrapper invocation
45
- * @param {string} ctx.RERUN " --rerun" in full mode, "" in fast
46
59
  * @param {boolean} ctx.fast
47
60
  * @param {boolean} ctx.determinism
48
61
  * @param {string} ctx.profile
49
62
  * @param {"full"|"fast"} ctx.mode
50
63
  * @param {Function} ctx.sh subprocess helper (throws StepTimeout past the step's deadline)
51
- * @param {Function} ctx.shGradle sh + the KSP-collision self-heal
52
64
  * @param {Function} ctx.tryGit
53
65
  * @param {Function} ctx.tryGitLines
54
66
  * @param {string[]} ctx.DEGRADED_PATHS degraded-path activations the receipt reports
@@ -56,8 +68,112 @@ import { CMP_LADDER } from "./evidence-level.mjs";
56
68
  * FAST_EXCLUDED_NAMES: string[], STEP_FN_BY_NAME: Record<string, Function>,
57
69
  * stepDeterminism: Function, releaseLease: () => void}}
58
70
  */
71
+ /**
72
+ * Is the inspector server in this app at all? `--no-inspector` deletes the
73
+ * androidDebug inspector sources (the feature manifest's paths), so the file
74
+ * that answers `/inspect/health` is simply not there. Asked BEFORE the lane
75
+ * touches a device: the 2026-09-08 context audit found this step booting a
76
+ * headless emulator first and only then discovering the endpoint it wanted to
77
+ * read was never shipped — a device paid for by every `--no-inspector` app,
78
+ * for a step that then SKIPped. Structural, so the receipt stays done-evidence.
79
+ */
80
+ export function inspectorShipped(root) {
81
+ const base = path.join(root, "composeApp", "src", "androidDebug");
82
+ if (!fs.existsSync(base)) return false;
83
+ const stack = [base];
84
+ while (stack.length) {
85
+ const dir = stack.pop();
86
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
87
+ if (e.isDirectory()) stack.push(path.join(dir, e.name));
88
+ else if (e.name === "InspectorHttpServer.kt") return true;
89
+ }
90
+ }
91
+ return false;
92
+ }
93
+
94
+
59
95
  export function createCmpSteps(ctx) {
60
- const { ROOT, HERE, GRADLEW, RERUN, fast, determinism, profile, mode, sh, shGradle, tryGit, tryGitLines, DEGRADED_PATHS } = ctx;
96
+ const { ROOT, HERE, fast, determinism, profile, mode, sh, tryGit, tryGitLines, DEGRADED_PATHS } = ctx;
97
+
98
+ // ── Gradle is THIS profile's build tool ───────────────────────────────────
99
+ // The wrapper invocation, the integrity flag and the coexistence dance used
100
+ // to live in qa/verify.mjs and arrive through ctx — which meant the runner,
101
+ // the spine every stack shares, knew what Gradle is, where composeApp/build
102
+ // sits, and what a KSP cache collision looks like. Stage 0 PR 6a moved them
103
+ // here: the runner hands the pack a plain subprocess helper, and the pack
104
+ // wraps it with whatever its own toolchain needs.
105
+ const GRADLEW = process.platform === "win32" ? "gradlew.bat" : "./gradlew";
106
+
107
+ // `--rerun` exists for evidence integrity: it stops Gradle's build cache
108
+ // replaying a PASS recorded against a different tree into a receipt that
109
+ // claims tests executed. That belongs to runs producing integrity-bearing
110
+ // artifacts — and a --fast run does not: its receipt already declares itself
111
+ // non-evidence (mode "fast", no evidence rung, refused by
112
+ // qa/receipt-check.mjs), so forcing execution there paid an integrity tax to
113
+ // protect an artifact with nothing to protect.
114
+ const RERUN = fast ? "" : " --rerun";
115
+
116
+ // ── Preview-daemon coexistence ───────────────────────────────────────────
117
+ // The preview daemon (the eyes) and this lane both spawn Gradle against this
118
+ // project and share <buildDir>/kspCaches, whose KSP incremental storage is
119
+ // single-owner — two concurrent builds throw "Storage for [...] is already
120
+ // registered" and one side dies. Three defenses, all automatic:
121
+ // 1. COORDINATE (this lane -> the daemon): the lane stamps its marker for
122
+ // its duration (qa/.lane-in-progress, core state); the preview service
123
+ // defers renders while it exists (mtime-bounded, so a crashed lane
124
+ // never wedges the eyes for long).
125
+ // 2. COORDINATE (the daemon -> this lane), the symmetric half: the daemon
126
+ // stamps its OWN marker for the duration of a render's Gradle build;
127
+ // shGradle waits for it to clear (or go stale) before launching this
128
+ // lane's own Gradle command — same mtime-bounded shape, so a crashed
129
+ // daemon never wedges the lane for long either.
130
+ // 3. SELF-HEAL: a Gradle step that still hits the collision clears
131
+ // kspCaches and retries once — the manual recovery that always worked,
132
+ // automated.
133
+ // The render marker is the PROVIDER's, so it lives under this profile's own
134
+ // buildDir; qa/lib/lane-markers.mjs names the file and the freshness bound
135
+ // so the daemon and the lane can never disagree about either.
136
+ const KSP_COLLISION_RE = /Storage for \[[^\]]*\] is already registered/;
137
+ const BUILD_DIR = cmpLayout.buildDir;
138
+ const RENDER_MARKER = path.join(ROOT, ...BUILD_DIR.split("/"), RENDER_MARKER_NAME);
139
+ const RENDER_WAIT_TIMEOUT_MS = 3 * 60 * 1000; // give up waiting after this long regardless
140
+ const RENDER_WAIT_POLL_MS = 2000;
141
+
142
+ /**
143
+ * Defer this lane's next Gradle command while the preview daemon's render
144
+ * marker is present AND fresh (mtime younger than RENDER_MARKER_FRESH_MS).
145
+ * Polls every RENDER_WAIT_POLL_MS; gives up and proceeds anyway after
146
+ * RENDER_WAIT_TIMEOUT_MS, or the moment the marker disappears or goes stale —
147
+ * whichever comes first. A missing/unreadable marker returns immediately:
148
+ * this is a coexistence courtesy, never a hard dependency on the daemon.
149
+ */
150
+ function waitForRenderMarker() {
151
+ const deadline = Date.now() + RENDER_WAIT_TIMEOUT_MS;
152
+ for (;;) {
153
+ let stat;
154
+ try {
155
+ stat = fs.statSync(RENDER_MARKER);
156
+ } catch {
157
+ return; // no render in flight
158
+ }
159
+ if (Date.now() - stat.mtimeMs >= RENDER_MARKER_FRESH_MS) return; // gone stale
160
+ if (Date.now() >= deadline) return; // waited long enough — proceed regardless
161
+ sh(`sleep ${RENDER_WAIT_POLL_MS / 1000}`);
162
+ }
163
+ }
164
+
165
+ function shGradle(cmd, opts = {}) {
166
+ waitForRenderMarker();
167
+ const first = sh(cmd, opts);
168
+ if (first.ok || !KSP_COLLISION_RE.test(first.out)) return first;
169
+ console.error("· KSP cache collision (concurrent Gradle — the preview daemon?) — clearing kspCaches, retrying once");
170
+ fs.rmSync(path.join(ROOT, ...BUILD_DIR.split("/"), "kspCaches"), { recursive: true, force: true });
171
+ const retry = sh(cmd, opts);
172
+ retry.durationMs += first.durationMs;
173
+ retry.selfHealed = "ksp-cache-collision";
174
+ DEGRADED_PATHS.push("ksp-cache-collision: cleared kspCaches and retried the Gradle step");
175
+ return retry;
176
+ }
61
177
 
62
178
  // Recursive: desktopTest writes TEST-*.xml flat, but connected (instrumented) results
63
179
  // land one directory level down per device (build/outputs/androidTest-results/connected/
@@ -252,6 +368,29 @@ function stepHarnessIntegrity() {
252
368
  const r = checkHarnessIntegrity(ROOT);
253
369
  const base = { name: "harnessIntegrity", durationMs: Date.now() - started, harness: r };
254
370
 
371
+ // ADR-0010 — THE FLOOR, BEFORE THE STATUS. A region with no engine module
372
+ // reads `intact` and is telling the truth: it is unmodified since it was
373
+ // locked. It is simply not a lane, and a lane that is not there cannot vouch
374
+ // for anything.
375
+ //
376
+ // On an ordinary tree this cannot fire — a region with no qa/verify.mjs has
377
+ // nothing to run this check. It fires in the one mode ADR-0008 rejects: a
378
+ // lane running from OUTSIDE the region, over a project whose qa/ holds only
379
+ // declarations. There the lane runs, this step executes, and until now it
380
+ // returned PASS over a project carrying none of the code that produced the
381
+ // verdict — the residue that ADR predicted, in the row that exists to
382
+ // prevent exactly it.
383
+ if (r.vacuous) {
384
+ return {
385
+ ...base,
386
+ verdict: "FAIL",
387
+ reason:
388
+ `the harness region holds ${r.fileCount} file(s) and NONE of them is engine code — this is not a lane, so it cannot vouch for one. ` +
389
+ `A lane running from outside the region (node_modules) over a project whose qa/ carries only declarations produces exactly this: ` +
390
+ `install the lane into the tree (\`prooflane init\`, or \`prooflane upgrade\` to re-vendor it) so the code that issues the verdict is the code the receipt binds`,
391
+ };
392
+ }
393
+
255
394
  if (r.status === "intact") {
256
395
  return { ...base, verdict: "PASS", note: describeIntegrity(r) };
257
396
  }
@@ -290,16 +429,16 @@ function stepSpecCoverage() {
290
429
  return { name: "specCoverage", verdict: "SKIP", reason: "no specs/ directory in this project", durationMs: Date.now() - started };
291
430
  }
292
431
 
293
- const clauses = scanSpecClauses(ROOT);
294
- const tags = scanCitations(ROOT);
295
- const searchDirs = [path.join(ROOT, "composeApp/src"), path.join(ROOT, "qa/e2e")];
296
- const files = searchDirs.flatMap((d) => walkFiles(d, [".kt", ".kts", ".yaml", ".yml"]));
432
+ const clauses = scanSpecClauses(ROOT, SPEC_MODEL);
433
+ const tags = scanCitations(ROOT, SPEC_MODEL);
434
+ const searchDirs = SPEC_MODEL.citationRoots.map((rel) => path.join(ROOT, ...rel.split("/")));
435
+ const files = searchDirs.flatMap((d) => walkFiles(d, [...SPEC_MODEL.citationExts, ...(SPEC_MODEL.flows ? SPEC_MODEL.flows.exts : [])]));
297
436
 
298
437
  const citedIds = new Set(tags.map((t) => t.id));
299
438
  const orphanClauses = [...clauses.entries()].filter(([, c]) => !c.withdrawn).filter(([id]) => !citedIds.has(id));
300
439
  const orphanTags = tags.filter((t) => !clauses.has(t.id) || clauses.get(t.id).withdrawn);
301
440
 
302
- const tiers = clauseTierCoverage(clauses, tags);
441
+ const tiers = clauseTierCoverage(clauses, tags, SPEC_MODEL);
303
442
 
304
443
  if (orphanClauses.length === 0 && orphanTags.length === 0 && tiers.unmetTier.length === 0) {
305
444
  // Tier visibility, still not a gate for UNDECLARED clauses (industry rule:
@@ -330,11 +469,18 @@ function stepSpecCoverage() {
330
469
  // failure than a missing one, and its message has to say WHY the citation it
331
470
  // can see does not count.
332
471
  for (const u of tiers.unmetTier) {
472
+ if (u.unknown) {
473
+ lines.push(
474
+ ` [${u.id}] ${u.file} — declares [tier: ${u.requiredTier}], a requirement this profile does not define. ` +
475
+ `Declared requirements: ${Object.keys(SPEC_MODEL.tiers.satisfying).join(", ")}. Fix the tag or declare the tier in the profile.`,
476
+ );
477
+ continue;
478
+ }
333
479
  const has = u.tiers.length ? `cited only from ${u.tiers.join(", ")}` : "cited by nothing";
334
480
  lines.push(
335
481
  ` [${u.id}] ${u.file} — declares [tier: ${u.requiredTier}] but is ${has}. ` +
336
482
  `A test on those tiers cannot observe this promise (no process lifecycle, no OS facts, no real device). ` +
337
- `Add a citing test in ${TIERS_SATISFYING[u.requiredTier].join(" or ")} — and note that tier SKIPPING for want of a device ` +
483
+ `Add a citing test in ${SPEC_MODEL.tiers.satisfying[u.requiredTier].join(" or ")} — and note that tier SKIPPING for want of a device ` +
338
484
  `leaves this clause unproven, which is the point: "I could not check this" is a failure, not a quieter rung.`,
339
485
  );
340
486
  }
@@ -627,7 +773,7 @@ function stepUnitTests() {
627
773
  if (changed === null) {
628
774
  note = "full suite — git unavailable, cannot derive the change (fail open)";
629
775
  } else {
630
- const filter = deriveAffectedFilter(changed);
776
+ const filter = deriveAffectedFilter(changed, cmpAffected);
631
777
  if (filter.mode === "filtered") {
632
778
  testsArgs = filter.patterns.map((p) => ` --tests "${p}"`).join("");
633
779
  note = `affected: ${filter.patterns.join(", ")} — ${filter.sourcePaths.length} changed source file(s)`;
@@ -720,12 +866,17 @@ function stepDeterminism() {
720
866
  };
721
867
  }
722
868
 
723
- const resultsDir = path.join(ROOT, "composeApp/build/test-results/desktopTest");
869
+ const reportProblem = reportFormatProblem(reports);
870
+ if (reportProblem) return { name: "determinism", verdict: "ERROR", reason: reportProblem, durationMs: elapsed() };
871
+ const resultsDir = path.join(ROOT, reports.dir, "desktopTest");
724
872
  const legs = [];
725
873
  for (const { tz, label } of DETERMINISM_TIMEZONES) {
726
874
  const res = shGradle(`${GRADLEW} :composeApp:desktopTest${RERUN} --console=plain`, { env: { ...process.env, TZ: tz } });
727
875
  // Parsed NOW, before the next leg overwrites the same results directory.
728
- const outcomes = parseJUnitOutcomes(resultsDir);
876
+ // The DISPATCHER, not one format's parser. A pack that named the JUnit
877
+ // reader directly would silently ignore its own `reports.format` the day a
878
+ // profile declared tap or ctrf — reading zero tests and calling it green.
879
+ const outcomes = parseReportOutcomes(resultsDir, { format: reports.format });
729
880
  legs.push({ tz, label, ok: res.ok, outcomes, tail: res.out.split("\n").slice(-8).join("\n") });
730
881
  }
731
882
  const [a, b] = legs;
@@ -757,7 +908,17 @@ function stepDeterminism() {
757
908
  };
758
909
  }
759
910
 
760
- const diffs = compareOutcomes(a.outcomes, b.outcomes, labelA, labelB);
911
+ // WHICH STEP owns a test class is this stack's knowledge: Kotlin class-name
912
+ // conventions on the left, this pack's step names on the right. It lived in
913
+ // qa/lib/determinism.mjs until Stage 0 A3, where the core knowing four step
914
+ // names was stamping them onto other stacks' determinism diffs.
915
+ const attribute = (classname) => {
916
+ if (/GoldenTreeTest$/.test(classname)) return "goldenTrees";
917
+ if (/ArchitectureConformanceTest$/.test(classname)) return "conformance";
918
+ if (/A11yConformanceTest$/.test(classname)) return "a11y";
919
+ return "unitTests";
920
+ };
921
+ const diffs = compareOutcomes(a.outcomes, b.outcomes, labelA, labelB, attribute);
761
922
  if (diffs.length > 0) {
762
923
  const lines = [
763
924
  `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"):`,
@@ -830,6 +991,10 @@ function stepTokenDrift() {
830
991
  const started = Date.now();
831
992
  const elapsed = () => Date.now() - started;
832
993
 
994
+ if (!inspectorShipped(ROOT)) {
995
+ return { name: "tokenDrift", verdict: "SKIP", skipKind: "structure", reason: "inspector not shipped in this project (--no-inspector) — no endpoint to read, no device needed", durationMs: elapsed() };
996
+ }
997
+
833
998
  const device = ensureLaneDevice("tokenDrift");
834
999
  if (device) return { ...device, durationMs: elapsed() };
835
1000
 
@@ -908,7 +1073,7 @@ function maestroGuards(name) {
908
1073
  if (!fs.existsSync(path.join(ROOT, E2E_FLOW_DIR))) {
909
1074
  return { name, verdict: "SKIP", skipKind: "structure", reason: "e2e harness not included in this project (--no-e2e)", durationMs: 0 };
910
1075
  }
911
- if (listFlowFiles(ROOT).length === 0) {
1076
+ if (listFlowFiles(ROOT, SPEC_MODEL).length === 0) {
912
1077
  return { name, verdict: "SKIP", skipKind: "structure", reason: `${E2E_FLOW_DIR}/ holds no flows (*.yaml) — nothing to drive`, durationMs: 0 };
913
1078
  }
914
1079
  if (!maestroAvailable()) {
@@ -944,7 +1109,7 @@ function maestroGuards(name) {
944
1109
  const E2E_RUN_BOUND_MS = (flowCount) => 120_000 + 180_000 * Math.max(1, flowCount);
945
1110
 
946
1111
  function runMaestroSmoke(name, priorDurationMs) {
947
- const flows = listFlowFiles(ROOT);
1112
+ const flows = listFlowFiles(ROOT, SPEC_MODEL);
948
1113
  const report = path.join(ROOT, "qa-artifacts", `maestro-${name}.xml`);
949
1114
  fs.mkdirSync(path.dirname(report), { recursive: true });
950
1115
  fs.rmSync(report, { force: true });
@@ -1347,6 +1512,14 @@ const STEP_FN_BY_NAME = {
1347
1512
  releaseSmoke: stepReleaseSmoke,
1348
1513
  releaseBuild: stepReleaseBuild,
1349
1514
  };
1515
+ // Where to look when one of THIS pack's steps blows its deadline. The spine
1516
+ // prints it verbatim and knows nothing about Gradle or adb (Stage 0 PR 6b.2);
1517
+ // marked on the step function, like `fn.layer`.
1518
+ const GRADLE_TIMEOUT_HINT = "A wedged Gradle daemon or a device that stopped answering are the usual causes; check `./gradlew --status` and `adb devices`.";
1519
+ for (const fn of [stepBuild, stepReleaseBuild, stepUnitTests, stepConformance, stepGoldenTrees, stepA11y, ...Object.values(STEP_FN_BY_NAME)]) {
1520
+ if (typeof fn === "function") fn.timeoutHint = GRADLE_TIMEOUT_HINT;
1521
+ }
1522
+
1350
1523
  for (const name of FAST_EXCLUDED_NAMES) {
1351
1524
  if (!STEP_FN_BY_NAME[name]) {
1352
1525
  // Drift guard: a new device-tier step must be mapped here or --fast would silently run it.
@@ -1356,11 +1529,40 @@ for (const name of FAST_EXCLUDED_NAMES) {
1356
1529
  }
1357
1530
 
1358
1531
  return {
1532
+ // WHICH PACK this is. The receipt names it (verify.mjs → receipt.pack) so a
1533
+ // cmp L2 and some other pack's L2 can never be mistaken for the same claim
1534
+ // on the wire. The pack declares its own identity — the spine writes what it
1535
+ // is told, never a name it assumed (docs/proposals/AGNOSTIC-HARNESS-
1536
+ // ARCHITECTURE.md §8.1, Stage 0 PR 1). Versioned with the harness until the
1537
+ // profile loader lands, so no `version` here yet: the spine pairs `id` with
1538
+ // the lock's version.
1539
+ id: "cmp",
1359
1540
  stepsForProfile,
1360
1541
  DEVICE_STEPS,
1542
+ // The verdict line's strength phrase. The core prints what it is handed and
1543
+ // nothing when handed nothing: "desktop-only" is THIS stack's word for the
1544
+ // absence of a device, not the harness's word for the absence of anything.
1545
+ // It used to be composed in verify.mjs, so a backend service with neither a
1546
+ // desktop nor a device read "(desktop-only)" on every verdict it ever printed.
1547
+ strengthLabel: (onDeviceSteps) => (onDeviceSteps.length ? `on-device: ${onDeviceSteps.join("+")}` : "desktop-only"),
1361
1548
  FAST_EXCLUDED_NAMES,
1362
1549
  STEP_FN_BY_NAME,
1363
1550
  stepDeterminism,
1551
+ // The step whose failure makes every verdict behind it meaningless — for
1552
+ // this stack, `./gradlew :composeApp:assembleDebug`. The spine stops the
1553
+ // lane there (qa/lib/lane-runner.mjs's compileShortCircuit) and it used to
1554
+ // find this step by the literal name "build", which is Gradle's word for
1555
+ // the task and Gradle's word for the output directory, and nobody else's.
1556
+ // Declared here so the spine reads a name instead of assuming one; the
1557
+ // value is unchanged, so this lane stops exactly where it always did.
1558
+ compileStepName: "build",
1559
+ // The bounds on a step's derived deadline. These are the spine's historical
1560
+ // fallback to the millisecond, so this lane does not move — but they are a
1561
+ // judgement about how long a cold Gradle daemon plus an Android build can
1562
+ // honestly take, and that judgement belongs to whoever owns the toolchain.
1563
+ // Declared so the numbers have an owner rather than being inherited by
1564
+ // every stack that never chose them.
1565
+ stepDeadlines: { floorMs: 5 * 60_000, ceilingMs: 30 * 60_000 },
1364
1566
  // The ladder this pack's steps can earn (evidence-level.mjs). A pack that
1365
1567
  // returns none earns no rung — the spine never grades a pack by another
1366
1568
  // pack's step names.
@@ -6,7 +6,7 @@
6
6
  // tarball rather than the working tree.
7
7
  //
8
8
  // SINGLE SOURCE OF TRUTH: packages/receipts/src/receipt-validate.mjs in the
9
- // create-cmp repo (the `@create-cmp/receipts` package). The copy in a generated
9
+ // create-cmp repo (the `prooflane-receipts` package). The copy in a generated
10
10
  // project's qa/lib/ is vendored byte-identical at scaffold time and pinned by
11
11
  // test/receipts-parity.test.mjs — edit the package source, then run
12
12
  // `node scripts/sync-harness.mjs`.
@@ -74,20 +74,31 @@ export function checkLaneVouching(receipt) {
74
74
  detail: `the receipt's verdict is PASS but ${failed.length} step(s) did not pass: ${names} — the row is the more specific truth`,
75
75
  };
76
76
  }
77
- const integrity = steps.find((s) => s && s.name === "harnessIntegrity");
77
+ // THE ROW THAT VOUCHES IS THE ROW CARRYING THE VOUCHING DATA, not the row with
78
+ // a particular name. This found a step named exactly `harnessIntegrity` — a
79
+ // name the cmp pack chose, that `REQUIRED_EXPORTS` never mentions, and that a
80
+ // profile author has no way to discover. A green lane whose self-vouching step
81
+ // was spelled `harness_integrity` minted receipts that were invalid FOREVER,
82
+ // in every reader, and the refusal accused the lane of not vouching for
83
+ // itself. The `harness` object on a step row is what the schema already
84
+ // documents as the integrity check's own findings, so it is the honest key.
85
+ // The name is kept as a fallback for receipts written before rows carried it.
86
+ const integrity = steps.find((s) => s && s.harness && typeof s.harness === "object") ?? steps.find((s) => s && s.name === "harnessIntegrity");
78
87
  if (!integrity) {
79
88
  return {
80
89
  ok: false,
81
- detail: "receipt has no harnessIntegrity row — nothing vouches that the lane's own code is the code that ran",
90
+ detail:
91
+ "no step on this receipt vouches for the lane — no row carries a `harness` object and none is named harnessIntegrity, " +
92
+ "so nothing attests that the lane's own code is the code that ran",
82
93
  };
83
94
  }
84
95
  if (integrity.verdict !== "PASS") {
85
96
  return {
86
97
  ok: false,
87
- detail: `harnessIntegrity is ${integrity.verdict}, not PASS — the lane did not vouch for itself, so its PASS over the tree cannot be trusted`,
98
+ detail: `${integrity.name ?? "the integrity step"} is ${integrity.verdict}, not PASS — the lane did not vouch for itself, so its PASS over the tree cannot be trusted`,
88
99
  };
89
100
  }
90
- return { ok: true, detail: "lane vouched for itself (harnessIntegrity PASS, no failing rows)" };
101
+ return { ok: true, detail: `lane vouched for itself (${integrity.name ?? "integrity step"} PASS, no failing rows)` };
91
102
  }
92
103
 
93
104
  export function evaluateReceipt(receipt, recompute) {
@@ -101,6 +112,33 @@ export function evaluateReceipt(receipt, recompute) {
101
112
  };
102
113
  }
103
114
 
115
+ // WHICH PACK GRADED THIS — ADR-0011. §8.9's comparability rule rests entirely
116
+ // on this field ("a `cmp` L2 and any other pack's L2 are different claims"),
117
+ // and until now no predicate read it: remove `pack` and a receipt kept its
118
+ // rung while losing the only thing that says what the rung is a rung OF.
119
+ //
120
+ // REFUSED, not flagged, and the cost is known. A receipt written before the
121
+ // field existed (2026-09-04) is refused too, because nothing in a receipt can
122
+ // tell "never had one" from "had one, and it was removed" — and of those two
123
+ // errors, accepting tampering is the one a predicate exists to prevent. The
124
+ // remedy is the one the binding check above already offers for the same class
125
+ // of staleness, and costs the same: re-run the lane.
126
+ //
127
+ // This is deliberately NOT the ADR-0007 case. There a label moved and no
128
+ // assertion changed, so invalidating old receipts would have been pure loss.
129
+ // Here the receipt is genuinely missing the field that makes its rung mean
130
+ // something — it is not being punished for a name, it is being asked for a
131
+ // claim it never made.
132
+ if (!receipt.pack || typeof receipt.pack.id !== "string" || receipt.pack.id.length === 0) {
133
+ return {
134
+ valid: false,
135
+ reason:
136
+ `receipt names no step pack — re-run the lane (attesting profile: ${profile ?? "unknown"}). ` +
137
+ `A rung is comparable only within its pack, so a receipt that does not name one cannot be compared to any other`,
138
+ profile,
139
+ };
140
+ }
141
+
104
142
  if (receipt.verdict === "FAIL") {
105
143
  return {
106
144
  valid: false,
@@ -146,12 +184,33 @@ export const DEFAULT_POLICY = {
146
184
  /** A receipt older than this no longer counts as fresh (hosted check only). */
147
185
  maxAgeMs: 30 * 24 * 60 * 60 * 1000, // 30 days
148
186
  /**
149
- * Executed (non-SKIP) gates must report at least this much total wall time.
150
- * A "PASS" receipt whose executed gates sum to less cannot attest a real
151
- * lane run — the tell for replayed/cached or hand-written verdicts
152
- * (evidence must attest execution, not results).
187
+ * An absolute wall-time floor for executed (non-SKIP) gates. `null` OFF by
188
+ * default, and that is a decision rather than an omission.
189
+ *
190
+ * This was 5000, with the reasoning that a PASS receipt summing to less
191
+ * cannot attest a real lane run: the tell for a replayed/cached green or a
192
+ * hand-written verdict. That reasoning holds for a Gradle lane and is FALSE
193
+ * for a Go service, a Rust crate, a Python package or a TypeScript library,
194
+ * whose lanes honestly finish in hundreds of milliseconds. Those adopters
195
+ * were told their evidence was fabricated — the one accusation this product
196
+ * cannot afford to make wrongly.
197
+ *
198
+ * The number was not the defect. ONE receipt carries nothing that could
199
+ * justify any number: no start time, no top-level duration, no baseline —
200
+ * `generatedAt` is a timestamp, not an interval — so nothing on it can be
201
+ * cross-checked against anything else on it. A floor is therefore a fact
202
+ * about the STACK, and this module does not know the stack. It is the
203
+ * notary's to set, from data the notary has and the receipt does not: a
204
+ * lane that has taken thirty seconds every day for a month and today claims
205
+ * forty-two milliseconds is a real finding, and it is a finding about a
206
+ * HISTORY, not about a receipt.
207
+ *
208
+ * What is lost, said plainly: a hand-written receipt claiming small
209
+ * durations is no longer refused here. It was never much of a defence — a
210
+ * forger types a larger number — and every stack-independent check that
211
+ * does catch fabrication is untouched below.
153
212
  */
154
- minExecutedMs: 5000,
213
+ minExecutedMs: null,
155
214
  };
156
215
 
157
216
  /**
@@ -176,9 +235,15 @@ export function checkFreshness(receipt, { now = Date.now(), maxAgeMs = DEFAULT_P
176
235
  }
177
236
 
178
237
  /**
179
- * Execution plausibility: do the executed (non-SKIP) gates report durations a
180
- * real lane run could produce? Catches replayed/cached greens and hand-edited
181
- * receipts whose numbers were never lived.
238
+ * Execution plausibility: did this lane execute anything, and are its numbers
239
+ * real numbers?
240
+ *
241
+ * Three refusals, all stack-independent and all about the SHAPE of the
242
+ * evidence rather than its size: a receipt with no steps, a receipt whose every
243
+ * step is a SKIP or an ERROR (neither measured anything), and a step whose
244
+ * duration is not a finite non-negative number. An absolute wall-time floor is
245
+ * a fourth check and is OFF unless a caller sets `minExecutedMs` — see
246
+ * DEFAULT_POLICY for why a default one is a claim about the stack.
182
247
  * @returns {{ok: boolean, detail: string, executedMs?: number, executedSteps?: number}}
183
248
  */
184
249
  export function checkExecutionPlausibility(receipt, { minExecutedMs = DEFAULT_POLICY.minExecutedMs } = {}) {
@@ -200,10 +265,13 @@ export function checkExecutionPlausibility(receipt, { minExecutedMs = DEFAULT_PO
200
265
  }
201
266
  total += step.durationMs;
202
267
  }
203
- if (total < minExecutedMs) {
268
+ // Applied only when a caller supplies one. The message attributes the floor
269
+ // to whoever set it and states the measurement, rather than asserting that a
270
+ // fast receipt cannot be real — which this module has no way to know.
271
+ if (typeof minExecutedMs === "number" && minExecutedMs > 0 && total < minExecutedMs) {
204
272
  return {
205
273
  ok: false,
206
- detail: `implausibly fast — executed gates report ${total}ms total, below the ${minExecutedMs}ms floor; a receipt this fast cannot attest a real lane run (evidence must attest execution)`,
274
+ detail: `executed gates report ${total}ms total, below this validator's configured ${minExecutedMs}ms floor for a fast stack that may be honest, so treat it as a finding to explain rather than proof of fabrication`,
207
275
  executedMs: total,
208
276
  executedSteps: executed.length,
209
277
  };
@@ -247,7 +315,7 @@ export function validateReceiptForTree({ root, now = Date.now(), policy = {} } =
247
315
  if (receipt === null) {
248
316
  return {
249
317
  status: "missing",
250
- reason: `no receipt at ${RECEIPT_REL_PATH} — this repo does not carry the create-cmp evidence harness (that is not a failure)`,
318
+ reason: `no receipt at ${RECEIPT_REL_PATH} — this repo does not carry the prooflane evidence harness (that is not a failure)`,
251
319
  checks: [{ id: "receipt-present", ok: false, detail: `no parsable receipt at ${RECEIPT_REL_PATH}` }],
252
320
  skips: [],
253
321
  };