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.
Files changed (63) hide show
  1. package/bin/create-cmp.mjs +3 -0
  2. package/package.json +6 -2
  3. package/packages/harness/package.json +38 -0
  4. package/packages/harness/src/approve.mjs +247 -0
  5. package/packages/harness/src/arch-doc.mjs +69 -0
  6. package/packages/harness/src/comment.mjs +76 -0
  7. package/packages/harness/src/lib/a11y.mjs +113 -0
  8. package/packages/harness/src/lib/affected-tests.mjs +147 -0
  9. package/packages/harness/src/lib/approvals.mjs +1403 -0
  10. package/packages/harness/src/lib/arch-doc.mjs +451 -0
  11. package/packages/harness/src/lib/audit-cadence.mjs +290 -0
  12. package/packages/harness/src/lib/comments.mjs +252 -0
  13. package/packages/harness/src/lib/component-stories.mjs +183 -0
  14. package/packages/harness/src/lib/determinism.mjs +179 -0
  15. package/packages/harness/src/lib/device-lease.mjs +249 -0
  16. package/packages/harness/src/lib/evidence-badge.mjs +158 -0
  17. package/packages/harness/src/lib/evidence-level.mjs +117 -0
  18. package/packages/harness/src/lib/feature-brief.mjs +324 -0
  19. package/packages/harness/src/lib/flight-recorder.mjs +332 -0
  20. package/packages/harness/src/lib/harness-lock.mjs +147 -0
  21. package/packages/harness/src/lib/harness-region.mjs +159 -0
  22. package/packages/harness/src/lib/inputs-hash.mjs +194 -0
  23. package/packages/harness/src/lib/reachability.mjs +211 -0
  24. package/packages/harness/src/lib/receipt-validate.mjs +234 -0
  25. package/packages/harness/src/lib/render.mjs +254 -0
  26. package/packages/harness/src/lib/spec-coverage.mjs +131 -0
  27. package/packages/harness/src/lib/step-cache.mjs +221 -0
  28. package/packages/harness/src/lib/token-drift.mjs +94 -0
  29. package/packages/harness/src/lib/tree.mjs +108 -0
  30. package/packages/harness/src/preview-gallery.mjs +122 -0
  31. package/packages/harness/src/receipt-check.mjs +96 -0
  32. package/packages/harness/src/record-audit.mjs +83 -0
  33. package/packages/harness/src/refusal-demo.mjs +498 -0
  34. package/packages/harness/src/retrospective.mjs +51 -0
  35. package/packages/harness/src/scaffold-feature.mjs +723 -0
  36. package/packages/harness/src/setup-hooks.mjs +33 -0
  37. package/packages/harness/src/verify.mjs +1709 -0
  38. package/packages/harness/src/walkthrough.mjs +499 -0
  39. package/packages/harness/src/watch.mjs +622 -0
  40. package/packages/receipts/package.json +36 -0
  41. package/packages/receipts/src/index.mjs +16 -0
  42. package/packages/receipts/src/inputs-hash.mjs +194 -0
  43. package/packages/receipts/src/receipt-validate.mjs +234 -0
  44. package/src/commands/upgrade.mjs +383 -0
  45. package/src/lib/harness-upgrade.mjs +521 -0
  46. package/src/scaffold.mjs +60 -1
  47. package/template/AGENTS.md +5 -0
  48. package/template/CLAUDE.md +34 -1
  49. package/template/README.md +4 -0
  50. package/template/gitignore +8 -0
  51. package/template/qa/lib/audit-cadence.mjs +290 -0
  52. package/template/qa/lib/determinism.mjs +179 -0
  53. package/template/qa/lib/evidence-badge.mjs +158 -0
  54. package/template/qa/lib/flight-recorder.mjs +332 -0
  55. package/template/qa/lib/harness-lock.mjs +147 -0
  56. package/template/qa/lib/harness-region.mjs +159 -0
  57. package/template/qa/lib/inputs-hash.mjs +17 -2
  58. package/template/qa/lib/receipt-validate.mjs +1 -1
  59. package/template/qa/preview-gallery.mjs +17 -2
  60. package/template/qa/record-audit.mjs +83 -0
  61. package/template/qa/retrospective.mjs +51 -0
  62. package/template/qa/verify.mjs +400 -10
  63. package/template/qa/watch.mjs +2 -2
@@ -0,0 +1,194 @@
1
+ // Shared primitive: a content hash of the "verified surface" — every tracked
2
+ // file whose content can change the verify lane's verdict, minus the lane's
3
+ // own outputs. Both qa/verify.mjs (writes inputs.hash into the receipt) and
4
+ // qa/receipt-check.mjs (recomputes it to test validity) import this module so
5
+ // there is exactly one definition of the surface and the algorithm.
6
+ //
7
+ // SINGLE SOURCE OF TRUTH: packages/receipts/src/inputs-hash.mjs in the
8
+ // create-cmp repo (the `cmp-receipts` package). The copy in a generated
9
+ // project's qa/lib/ is vendored byte-identical at scaffold time and pinned by
10
+ // test/receipts-parity.test.mjs — edit the package source, then run
11
+ // `node scripts/sync-harness.mjs`.
12
+ //
13
+ // See docs/adr/0005-evidence-binding-by-inputs-hash.md for the why.
14
+
15
+ import { execSync } from "node:child_process";
16
+ import { createHash } from "node:crypto";
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+
20
+ // Directories / files INCLUDED in the verified surface (relative to project ROOT).
21
+ // Principle: every tracked file whose content can change the lane's verdict.
22
+ export const VERIFIED_SURFACE = [
23
+ "composeApp",
24
+ "specs",
25
+ "qa",
26
+ "gradle/libs.versions.toml",
27
+ "build.gradle.kts",
28
+ "settings.gradle.kts",
29
+ "gradle.properties",
30
+ ];
31
+
32
+ // Paths EXCLUDED even though they fall under an included surface dir above.
33
+ // qa/evidence and qa-artifacts are lane OUTPUTS — including them would make
34
+ // the hash depend on the lane's own prior output. qa/comments.json is excluded
35
+ // by this file's own stated principle: comments are explicitly advisory and no
36
+ // lane step reads them, so their content cannot change the verdict — hashing
37
+ // them made resolving a review note invalidate a receipt for a tree whose
38
+ // code had not changed. qa/approvals.log.jsonl (the governance journal) is
39
+ // excluded by the same principle: it is append-only HISTORY of decisions the
40
+ // snapshot (qa/approvals.json) already carries as state — no lane step reads
41
+ // it, so recording who/why must never invalidate a receipt for a tree whose
42
+ // code did not change (the exact failure FI-8 killed for acceptance).
43
+ // qa/flight-recorder.jsonl is a lane OUTPUT in the strictest sense: the lane
44
+ // appends one line to it on every run, after the receipt is written — hashing
45
+ // it would make every run invalidate its own receipt. qa/audits.jsonl (the
46
+ // cmp-audit ledger) is read by exactly one lane step (auditCadence), which is
47
+ // a REPORT and can never change the verdict — and recording an audit is
48
+ // bookkeeping about a commit that already happened, so appending a record
49
+ // must never invalidate a receipt for a tree whose code did not change
50
+ // (approvals.log.jsonl's principle, applied to audits).
51
+ const EXCLUDED_PREFIXES = [
52
+ "qa/evidence",
53
+ "qa-artifacts",
54
+ "qa/comments.json",
55
+ "qa/approvals.log.jsonl",
56
+ "qa/flight-recorder.jsonl",
57
+ "qa/audits.jsonl",
58
+ ];
59
+
60
+ // qa/approvals.json is hashed by PROJECTION, not raw bytes. The approvals gate's
61
+ // verdict depends on exactly three row fields (artifact, status, hash) plus the
62
+ // top-level exemplarFeature (it selects the exemplar artifact's file set).
63
+ // Everything else on a row — approvedAt, mode, via, reopenedAt, accepted,
64
+ // acceptedAt — is ledger bookkeeping that records a decision without gating one.
65
+ // Hashing those bytes meant the human clicking Accept on a provenDone feature
66
+ // instantly invalidated the receipt whose PASS permitted the acceptance.
67
+ // Acceptance is a bookend recorded after proof; it must not destroy it.
68
+ // An unparsable ledger falls back to raw bytes — refusal over fabrication.
69
+ const APPROVALS_PROJECTED_PATH = "qa/approvals.json";
70
+
71
+ function projectApprovalsBytes(raw) {
72
+ let parsed;
73
+ try {
74
+ parsed = JSON.parse(raw.toString("utf8"));
75
+ } catch {
76
+ return raw;
77
+ }
78
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.artifacts)) return raw;
79
+ const rows = parsed.artifacts
80
+ .filter((a) => a && typeof a === "object")
81
+ .map((a) => ({ artifact: a.artifact ?? null, status: a.status ?? null, hash: a.hash ?? null }))
82
+ .sort((a, b) => (String(a.artifact) < String(b.artifact) ? -1 : String(a.artifact) > String(b.artifact) ? 1 : 0));
83
+ const projection = {};
84
+ if (typeof parsed.exemplarFeature === "string") projection.exemplarFeature = parsed.exemplarFeature;
85
+ projection.artifacts = rows;
86
+ return Buffer.from(`${JSON.stringify(projection)}\n`, "utf8");
87
+ }
88
+
89
+ function isExcluded(relPath) {
90
+ return EXCLUDED_PREFIXES.some((prefix) => relPath === prefix || relPath.startsWith(`${prefix}/`));
91
+ }
92
+
93
+ // The verified surface is the set of files that WILL be committed: tracked files
94
+ // PLUS untracked-but-not-ignored files (`--others --exclude-standard`). A freshly
95
+ // generated feature's files are untracked when the lane runs and the receipt is
96
+ // written, yet they land in the very same commit as the receipt — so they must be
97
+ // hashed, or the committed receipt would never attest its own commit (and CI's
98
+ // receipt-matches-HEAD gate would false-fail on every change). Gitignored scratch
99
+ // (build outputs, qa-artifacts) is still excluded via --exclude-standard.
100
+ function tryGitLsFiles(root) {
101
+ try {
102
+ const out = execSync("git ls-files -z --cached --others --exclude-standard", { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
103
+ return out.split("\0").filter(Boolean);
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ // Directory names the walk fallback must skip wherever they appear under a
110
+ // surface root. These mirror what the stamped .gitignore excludes: without
111
+ // this, a pre-`git init` hash (walk mode) includes composeApp/build/** and
112
+ // Gradle/Kotlin scratch that the post-`git init` hash (`git ls-files
113
+ // --exclude-standard`) excludes — so the stamp-time PASS receipt would read
114
+ // "INVALID — source changed" the moment the user runs `git init`, even though
115
+ // no source changed. Pre-git and post-git hashes must agree for identical
116
+ // source; that is the invariant the regression test pins.
117
+ const WALK_EXCLUDED_DIRS = new Set(["build", ".gradle", ".kotlin", ".git", ".idea", "node_modules"]);
118
+ // File-level mirror of the same principle (OS/editor junk the .gitignore covers).
119
+ const WALK_EXCLUDED_FILES = new Set([".DS_Store"]);
120
+ const WALK_EXCLUDED_SUFFIXES = [".iml", ".log"];
121
+
122
+ function walkIncludesFile(name) {
123
+ if (WALK_EXCLUDED_FILES.has(name)) return false;
124
+ return !WALK_EXCLUDED_SUFFIXES.some((suffix) => name.endsWith(suffix));
125
+ }
126
+
127
+ // Dependency-free recursive walk, used when git is unavailable (non-git scaffold).
128
+ function walkAllFiles(dir) {
129
+ const out = [];
130
+ if (!fs.existsSync(dir)) return out;
131
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
132
+ const p = path.join(dir, entry.name);
133
+ if (entry.isDirectory()) {
134
+ if (WALK_EXCLUDED_DIRS.has(entry.name)) continue; // non-source scratch — see note above
135
+ out.push(...walkAllFiles(p));
136
+ } else if (entry.isFile() && walkIncludesFile(entry.name)) out.push(p);
137
+ }
138
+ return out;
139
+ }
140
+
141
+ // Resolve the verified surface to a flat, sorted list of paths (relative to
142
+ // root, POSIX-style `/` separators) that currently exist on disk.
143
+ function resolveSurfaceFiles(root) {
144
+ const gitFiles = tryGitLsFiles(root);
145
+
146
+ if (gitFiles) {
147
+ return gitFiles
148
+ .map((p) => p.split(path.sep).join("/"))
149
+ .filter((relPath) => VERIFIED_SURFACE.some((surface) => relPath === surface || relPath.startsWith(`${surface}/`)))
150
+ .filter((relPath) => !isExcluded(relPath))
151
+ .filter((relPath) => fs.existsSync(path.join(root, relPath)) && fs.statSync(path.join(root, relPath)).isFile());
152
+ }
153
+
154
+ // Fallback: no git available — walk the surface directories directly so a
155
+ // non-git scaffold still produces a stable hash.
156
+ const collected = [];
157
+ for (const surface of VERIFIED_SURFACE) {
158
+ const abs = path.join(root, surface);
159
+ if (!fs.existsSync(abs)) continue;
160
+ const stat = fs.statSync(abs);
161
+ if (stat.isFile()) {
162
+ collected.push(surface);
163
+ } else if (stat.isDirectory()) {
164
+ for (const file of walkAllFiles(abs)) {
165
+ collected.push(path.relative(root, file).split(path.sep).join("/"));
166
+ }
167
+ }
168
+ }
169
+ return collected.filter((relPath) => !isExcluded(relPath));
170
+ }
171
+
172
+ /**
173
+ * Compute the sha256 hash of the verified surface for the project rooted at `root`.
174
+ * Deterministic: same tree (same file paths + same file bytes) → same hash.
175
+ * @param {string} root absolute path to the project root
176
+ * @returns {{ hash: string, fileCount: number }}
177
+ */
178
+ export function computeInputsHash(root) {
179
+ // Code-unit sort (default String sort), NOT localeCompare: the hash depends
180
+ // on iteration order, and ICU collation varies with the machine's locale
181
+ // (e.g. a da_DK machine orders "aa" after "z"; en orders case-insensitively
182
+ // where code units do not) — the same tree must hash identically everywhere.
183
+ const files = [...new Set(resolveSurfaceFiles(root))].sort();
184
+
185
+ const overall = createHash("sha256");
186
+ for (const relPath of files) {
187
+ const raw = fs.readFileSync(path.join(root, relPath));
188
+ const bytes = relPath === APPROVALS_PROJECTED_PATH ? projectApprovalsBytes(raw) : raw;
189
+ const fileSha = createHash("sha256").update(bytes).digest("hex");
190
+ overall.update(`${relPath}\0${fileSha}\n`);
191
+ }
192
+
193
+ return { hash: overall.digest("hex"), fileCount: files.length };
194
+ }
@@ -0,0 +1,234 @@
1
+ // The evidence-binding predicate and its service-grade extensions, as pure
2
+ // dependency-free functions. `evaluateReceipt` is the exact predicate the
3
+ // generated project's qa/receipt-check.mjs (and its Stop hook + CI) runs;
4
+ // the additional checks (freshness, execution plausibility, SKIP listing) are
5
+ // consumed by hosted validators that judge a receipt fetched from a repo
6
+ // tarball rather than the working tree.
7
+ //
8
+ // SINGLE SOURCE OF TRUTH: packages/receipts/src/receipt-validate.mjs in the
9
+ // create-cmp repo (the `cmp-receipts` package). The copy in a generated
10
+ // project's qa/lib/ is vendored byte-identical at scaffold time and pinned by
11
+ // test/receipts-parity.test.mjs — edit the package source, then run
12
+ // `node scripts/sync-harness.mjs`.
13
+ //
14
+ // See docs/adr/0005-evidence-binding-by-inputs-hash.md for the why.
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+
19
+ import { computeInputsHash } from "./inputs-hash.mjs";
20
+
21
+ /** Where a generated project keeps its committed receipt, relative to root. */
22
+ export const RECEIPT_REL_PATH = "qa/evidence/latest.json";
23
+
24
+ /**
25
+ * Read and parse the committed receipt for the project rooted at `root`.
26
+ * @param {string} root absolute path to the project root
27
+ * @returns {object|null} the parsed receipt, or null when absent/unparsable
28
+ */
29
+ export function readReceipt(root, relPath = RECEIPT_REL_PATH) {
30
+ try {
31
+ return JSON.parse(fs.readFileSync(path.join(root, relPath), "utf8"));
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * The core predicate: does this receipt validly attest the tree whose inputs
39
+ * hash `recompute()` returns? Reasons are the exact refusal strings the
40
+ * generated project's receipt-check CLI (and Stop hook) prints.
41
+ *
42
+ * @param {object} receipt parsed receipt JSON
43
+ * @param {() => {hash: string, fileCount: number}} recompute lazily invoked —
44
+ * never called when the receipt fails structurally first (missing binding,
45
+ * FAIL verdict), so callers don't pay for a hash they don't need.
46
+ * @returns {{valid: boolean, reason: string, profile: (string|undefined), recomputed?: {hash: string, fileCount: number}}}
47
+ */
48
+ export function evaluateReceipt(receipt, recompute) {
49
+ const profile = receipt.profile;
50
+
51
+ if (!receipt.inputs || typeof receipt.inputs.hash !== "string") {
52
+ return {
53
+ valid: false,
54
+ reason: `receipt predates evidence binding — re-run the lane (attesting profile: ${profile ?? "unknown"})`,
55
+ profile,
56
+ };
57
+ }
58
+
59
+ if (receipt.verdict === "FAIL") {
60
+ return {
61
+ valid: false,
62
+ reason: `the committed receipt is a FAIL (attesting profile: ${profile ?? "unknown"})`,
63
+ profile,
64
+ };
65
+ }
66
+
67
+ const recomputed = recompute();
68
+
69
+ if (receipt.inputs.hash !== recomputed.hash) {
70
+ return {
71
+ valid: false,
72
+ reason: `source changed since the receipt — re-run the lane (attesting profile: ${profile ?? "unknown"})`,
73
+ profile,
74
+ recomputed,
75
+ };
76
+ }
77
+
78
+ if (receipt.verdict !== "PASS") {
79
+ return {
80
+ valid: false,
81
+ reason: `receipt verdict is "${receipt.verdict}", not PASS (attesting profile: ${profile ?? "unknown"})`,
82
+ profile,
83
+ };
84
+ }
85
+
86
+ return { valid: true, reason: `receipt is valid — PASS, attesting profile: ${profile ?? "unknown"}`, profile, recomputed };
87
+ }
88
+
89
+ // ── Service-grade checks (hosted validators; the local predicate above does
90
+ // not enforce these — the tree it checks is by definition "now") ─────────
91
+
92
+ /** Default policy for hosted validation. Every knob is overridable. */
93
+ export const DEFAULT_POLICY = {
94
+ /** A receipt older than this no longer counts as fresh (hosted check only). */
95
+ maxAgeMs: 30 * 24 * 60 * 60 * 1000, // 30 days
96
+ /**
97
+ * Executed (non-SKIP) gates must report at least this much total wall time.
98
+ * A "PASS" receipt whose executed gates sum to less cannot attest a real
99
+ * lane run — the tell for replayed/cached or hand-written verdicts
100
+ * (evidence must attest execution, not results).
101
+ */
102
+ minExecutedMs: 5000,
103
+ };
104
+
105
+ /**
106
+ * Freshness: is the receipt's generatedAt within maxAgeMs of `now`?
107
+ * @returns {{ok: boolean, detail: string, ageMs?: number}}
108
+ */
109
+ export function checkFreshness(receipt, { now = Date.now(), maxAgeMs = DEFAULT_POLICY.maxAgeMs } = {}) {
110
+ const generatedAt = Date.parse(receipt?.generatedAt ?? "");
111
+ if (Number.isNaN(generatedAt)) {
112
+ return { ok: false, detail: "receipt has no parsable generatedAt timestamp" };
113
+ }
114
+ const ageMs = now - generatedAt;
115
+ if (ageMs < -60_000) {
116
+ // A receipt from the future is a clock lie, not a rounding artifact.
117
+ return { ok: false, detail: `receipt claims a future generatedAt (${receipt.generatedAt})`, ageMs };
118
+ }
119
+ if (ageMs > maxAgeMs) {
120
+ const days = Math.floor(ageMs / 86_400_000);
121
+ return { ok: false, detail: `receipt is stale — generated ${days} day(s) ago, older than the ${Math.floor(maxAgeMs / 86_400_000)}-day freshness window`, ageMs };
122
+ }
123
+ return { ok: true, detail: `receipt generated ${receipt.generatedAt}`, ageMs };
124
+ }
125
+
126
+ /**
127
+ * Execution plausibility: do the executed (non-SKIP) gates report durations a
128
+ * real lane run could produce? Catches replayed/cached greens and hand-edited
129
+ * receipts whose numbers were never lived.
130
+ * @returns {{ok: boolean, detail: string, executedMs?: number, executedSteps?: number}}
131
+ */
132
+ export function checkExecutionPlausibility(receipt, { minExecutedMs = DEFAULT_POLICY.minExecutedMs } = {}) {
133
+ const steps = Array.isArray(receipt?.steps) ? receipt.steps : null;
134
+ if (!steps || steps.length === 0) {
135
+ return { ok: false, detail: "receipt lists no verify-lane steps — nothing was executed" };
136
+ }
137
+ const executed = steps.filter((s) => s && s.verdict !== "SKIP");
138
+ if (executed.length === 0) {
139
+ return { ok: false, detail: "every step in the receipt is a SKIP — the lane verified nothing" };
140
+ }
141
+ let total = 0;
142
+ for (const step of executed) {
143
+ if (typeof step.durationMs !== "number" || !Number.isFinite(step.durationMs) || step.durationMs < 0) {
144
+ return { ok: false, detail: `step "${step.name ?? "?"}" reports an invalid duration (${step.durationMs}) — durations must be real, non-negative numbers` };
145
+ }
146
+ total += step.durationMs;
147
+ }
148
+ if (total < minExecutedMs) {
149
+ return {
150
+ ok: false,
151
+ 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)`,
152
+ executedMs: total,
153
+ executedSteps: executed.length,
154
+ };
155
+ }
156
+ return { ok: true, detail: `${executed.length} executed gate(s), ${total}ms total`, executedMs: total, executedSteps: executed.length };
157
+ }
158
+
159
+ /**
160
+ * List the SKIPped steps with their honest reasons. SKIPs are reported, not
161
+ * failed — green-with-gaps must be visible, never silently equated with
162
+ * fully-verified (or silently punished).
163
+ * @returns {Array<{name: string, reason: string}>}
164
+ */
165
+ export function listSkippedSteps(receipt) {
166
+ const steps = Array.isArray(receipt?.steps) ? receipt.steps : [];
167
+ return steps
168
+ .filter((s) => s && s.verdict === "SKIP")
169
+ .map((s) => ({ name: s.name ?? "?", reason: s.reason ?? "no reason recorded" }));
170
+ }
171
+
172
+ /**
173
+ * The hosted composite: validate the receipt found in an extracted repo tree
174
+ * (e.g. a tarball at a PR's head SHA) with the full service-grade policy.
175
+ *
176
+ * @param {object} args
177
+ * @param {string} args.root absolute path to the extracted tree's project root
178
+ * @param {number} [args.now] epoch ms, for freshness (defaults to Date.now())
179
+ * @param {object} [args.policy] overrides for DEFAULT_POLICY
180
+ * @returns {{
181
+ * status: "missing"|"valid"|"invalid",
182
+ * reason: string,
183
+ * profile?: string,
184
+ * checks: Array<{id: string, ok: boolean, detail: string}>,
185
+ * skips: Array<{name: string, reason: string}>,
186
+ * }}
187
+ */
188
+ export function validateReceiptForTree({ root, now = Date.now(), policy = {} } = {}) {
189
+ const effective = { ...DEFAULT_POLICY, ...policy };
190
+ const receipt = readReceipt(root);
191
+
192
+ if (receipt === null) {
193
+ return {
194
+ status: "missing",
195
+ reason: `no receipt at ${RECEIPT_REL_PATH} — this repo does not carry the create-cmp evidence harness (that is not a failure)`,
196
+ checks: [{ id: "receipt-present", ok: false, detail: `no parsable receipt at ${RECEIPT_REL_PATH}` }],
197
+ skips: [],
198
+ };
199
+ }
200
+
201
+ const checks = [{ id: "receipt-present", ok: true, detail: RECEIPT_REL_PATH }];
202
+ const skips = listSkippedSteps(receipt);
203
+
204
+ // The core predicate (binding + verdict + hash), verbatim local semantics.
205
+ const core = evaluateReceipt(receipt, () => computeInputsHash(root));
206
+ checks.push({ id: "binding-and-hash", ok: core.valid, detail: core.reason });
207
+
208
+ // Service-grade extensions run regardless, so a failing receipt reports
209
+ // every violated rule at once (refusals name what failed, all of it).
210
+ const freshness = checkFreshness(receipt, { now, maxAgeMs: effective.maxAgeMs });
211
+ checks.push({ id: "freshness", ok: freshness.ok, detail: freshness.detail });
212
+
213
+ const plausibility = checkExecutionPlausibility(receipt, { minExecutedMs: effective.minExecutedMs });
214
+ checks.push({ id: "execution-plausibility", ok: plausibility.ok, detail: plausibility.detail });
215
+
216
+ const failed = checks.filter((c) => !c.ok);
217
+ if (failed.length > 0) {
218
+ return {
219
+ status: "invalid",
220
+ reason: failed.map((c) => c.detail).join("; "),
221
+ profile: core.profile,
222
+ checks,
223
+ skips,
224
+ };
225
+ }
226
+
227
+ return {
228
+ status: "valid",
229
+ reason: core.reason,
230
+ profile: core.profile,
231
+ checks,
232
+ skips,
233
+ };
234
+ }