create-cmp-cli 0.13.0 → 0.14.1

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 (61) hide show
  1. package/package.json +6 -2
  2. package/packages/harness/package.json +38 -0
  3. package/packages/harness/src/approve.mjs +247 -0
  4. package/packages/harness/src/arch-doc.mjs +69 -0
  5. package/packages/harness/src/comment.mjs +76 -0
  6. package/packages/harness/src/lib/a11y.mjs +113 -0
  7. package/packages/harness/src/lib/affected-tests.mjs +147 -0
  8. package/packages/harness/src/lib/approvals.mjs +1403 -0
  9. package/packages/harness/src/lib/arch-doc.mjs +451 -0
  10. package/packages/harness/src/lib/audit-cadence.mjs +290 -0
  11. package/packages/harness/src/lib/comments.mjs +252 -0
  12. package/packages/harness/src/lib/component-stories.mjs +183 -0
  13. package/packages/harness/src/lib/determinism.mjs +179 -0
  14. package/packages/harness/src/lib/device-lease.mjs +249 -0
  15. package/packages/harness/src/lib/evidence-badge.mjs +158 -0
  16. package/packages/harness/src/lib/evidence-level.mjs +117 -0
  17. package/packages/harness/src/lib/feature-brief.mjs +324 -0
  18. package/packages/harness/src/lib/flight-recorder.mjs +332 -0
  19. package/packages/harness/src/lib/harness-lock.mjs +147 -0
  20. package/packages/harness/src/lib/harness-region.mjs +159 -0
  21. package/packages/harness/src/lib/inputs-hash.mjs +194 -0
  22. package/packages/harness/src/lib/reachability.mjs +211 -0
  23. package/packages/harness/src/lib/receipt-validate.mjs +234 -0
  24. package/packages/harness/src/lib/render.mjs +254 -0
  25. package/packages/harness/src/lib/spec-coverage.mjs +131 -0
  26. package/packages/harness/src/lib/step-cache.mjs +221 -0
  27. package/packages/harness/src/lib/token-drift.mjs +94 -0
  28. package/packages/harness/src/lib/tree.mjs +108 -0
  29. package/packages/harness/src/preview-gallery.mjs +122 -0
  30. package/packages/harness/src/receipt-check.mjs +96 -0
  31. package/packages/harness/src/record-audit.mjs +83 -0
  32. package/packages/harness/src/refusal-demo.mjs +498 -0
  33. package/packages/harness/src/retrospective.mjs +51 -0
  34. package/packages/harness/src/scaffold-feature.mjs +723 -0
  35. package/packages/harness/src/setup-hooks.mjs +33 -0
  36. package/packages/harness/src/verify.mjs +1723 -0
  37. package/packages/harness/src/walkthrough.mjs +499 -0
  38. package/packages/harness/src/watch.mjs +622 -0
  39. package/packages/receipts/package.json +36 -0
  40. package/packages/receipts/src/index.mjs +16 -0
  41. package/packages/receipts/src/inputs-hash.mjs +194 -0
  42. package/packages/receipts/src/receipt-validate.mjs +234 -0
  43. package/src/commands/upgrade.mjs +115 -1
  44. package/src/lib/harness-upgrade.mjs +193 -5
  45. package/src/scaffold.mjs +60 -1
  46. package/template/AGENTS.md +5 -0
  47. package/template/CLAUDE.md +30 -0
  48. package/template/gitignore +8 -0
  49. package/template/qa/lib/harness-lock.mjs +147 -0
  50. package/template/qa/lib/harness-region.mjs +159 -0
  51. package/template/qa/lib/inputs-hash.mjs +1 -1
  52. package/template/qa/lib/receipt-validate.mjs +1 -1
  53. package/template/qa/preview-gallery.mjs +17 -2
  54. package/template/qa/verify.mjs +110 -2
  55. package/template/.gradle/8.11.1/checksums/checksums.lock +0 -0
  56. package/template/.gradle/8.11.1/fileChanges/last-build.bin +0 -0
  57. package/template/.gradle/8.11.1/fileHashes/fileHashes.lock +0 -0
  58. package/template/.gradle/8.11.1/gc.properties +0 -0
  59. package/template/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  60. package/template/.gradle/buildOutputCleanup/cache.properties +0 -2
  61. package/template/.gradle/vcs-1/gc.properties +0 -0
@@ -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,211 @@
1
+ // reachability.mjs — the navigation-reachability gate (task FI-7,
2
+ // docs/AUTONOMY-GAPS.md §3): a feature that passes spec coverage, conformance,
3
+ // goldens, a11y, and on-device smoke — and is STILL unreachable because no
4
+ // destination in the navigation graph points at it — is a confident false
5
+ // green. `MealTrayScreen` was accepted that way: its `MealTrayRoute` composable
6
+ // was referenced by nothing but its own tests. This gate exists to catch
7
+ // exactly that shape of drift, mechanically, so it can never happen silently
8
+ // again.
9
+ //
10
+ // Pure Node, no Gradle — same stance as component-stories.mjs and
11
+ // spec-coverage.mjs: a pragmatic source scan, not a Kotlin front end.
12
+ //
13
+ // A "feature" is any top-level directory under commonMain's presentation/,
14
+ // except `components` and `theme` (registry/design surfaces, not screens),
15
+ // that contains at least one `*Screen.kt` file (recursive). Its ENTRY
16
+ // composables are the top-level `fun <Name>(` declarations in those files
17
+ // whose name ends in `Screen` or `Route` — the navigation-entry naming
18
+ // convention this template's own exemplar follows (HomeScreen, DetailScreen).
19
+ // A feature is REACHABLE if any entry composable's name is referenced —
20
+ // word-boundary, not a call-site parse — from a commonMain `.kt` file OUTSIDE
21
+ // its own presentation/<feature>/ directory. The navigation graph
22
+ // (AppNavHost) is the expected referencer.
23
+ //
24
+ // desktopMain (PreviewRegistry.kt, ComponentStories.kt) and test sources
25
+ // deliberately do NOT count: registering a screen in the preview gallery is
26
+ // not wiring it into the app a user runs — that is exactly the false green
27
+ // this gate exists to close, so only commonMain counts as a live reference.
28
+ //
29
+ // A feature with no entry composable at all (nothing named `*Screen`/`*Route`
30
+ // declared at top level) has nothing this gate can check reachability FOR —
31
+ // it is passed through untouched rather than flagged, the same "nothing to
32
+ // check" stance component-stories.mjs takes for an empty registry.
33
+
34
+ import fs from "node:fs";
35
+ import path from "node:path";
36
+
37
+ import { parseFeatureBlock } from "./feature-brief.mjs";
38
+
39
+ // Mirrors component-stories.mjs's walkDirs: every directory named `wanted`
40
+ // anywhere under `root`, recursive.
41
+ function walkDirs(root, wanted) {
42
+ const out = [];
43
+ (function walk(dir) {
44
+ let entries;
45
+ try {
46
+ entries = fs.readdirSync(dir, { withFileTypes: true });
47
+ } catch {
48
+ return;
49
+ }
50
+ for (const e of entries) {
51
+ if (!e.isDirectory()) continue;
52
+ const p = path.join(dir, e.name);
53
+ if (e.name === wanted) out.push(p);
54
+ else walk(p);
55
+ }
56
+ })(root);
57
+ return out;
58
+ }
59
+
60
+ // Mirrors component-stories.mjs's walkKtFilesDeep: every `.kt` file under
61
+ // `dir`, recursive.
62
+ function walkKtFilesDeep(dir) {
63
+ const out = [];
64
+ let entries;
65
+ try {
66
+ entries = fs.readdirSync(dir, { withFileTypes: true });
67
+ } catch {
68
+ return out;
69
+ }
70
+ for (const e of entries) {
71
+ const p = path.join(dir, e.name);
72
+ if (e.isDirectory()) out.push(...walkKtFilesDeep(p));
73
+ else if (e.name.endsWith(".kt")) out.push(p);
74
+ }
75
+ return out;
76
+ }
77
+
78
+ // Top-level `fun Name(` — unindented (column 0), the same line-anchored
79
+ // scanning stance as spec-coverage.mjs's CLAUSE_LINE_RE: a pragmatic source
80
+ // scan, not a Kotlin front end. Deliberately does not require `@Composable`
81
+ // on the preceding line — the entry-naming convention (Screen/Route suffix)
82
+ // is the signal, not the annotation.
83
+ const TOP_LEVEL_FUN_RE = /^fun\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:<[^>]*>)?\s*\(/gm;
84
+
85
+ /**
86
+ * The entry composables one `*Screen.kt` file's text declares: top-level
87
+ * `fun`s whose name ends `Screen` or `Route`. Order-preserving, de-duplicated
88
+ * by the caller across a feature's files.
89
+ * @param {string} text
90
+ * @returns {string[]}
91
+ */
92
+ export function findEntryComposables(text) {
93
+ const names = [];
94
+ TOP_LEVEL_FUN_RE.lastIndex = 0;
95
+ let m;
96
+ while ((m = TOP_LEVEL_FUN_RE.exec(text))) {
97
+ if (/(Screen|Route)$/.test(m[1])) names.push(m[1]);
98
+ }
99
+ return names;
100
+ }
101
+
102
+ /**
103
+ * Evaluate navigation reachability for a project root.
104
+ * @param {string} root project root (contains composeApp/)
105
+ * @returns {{verdict: "PASS"|"FAIL"|"SKIP", reason?: string, details: {features: Array<{name: string, reachable: boolean, unrouted?: boolean, entryComposables: string[]}>}}}
106
+ */
107
+ export function evaluateReachability(root) {
108
+ const commonRoot = path.join(root, "composeApp", "src", "commonMain", "kotlin");
109
+ if (!fs.existsSync(commonRoot)) {
110
+ return {
111
+ verdict: "SKIP",
112
+ reason: "no commonMain/kotlin directory under composeApp/src — kotlin root unresolvable, nothing to check",
113
+ details: { features: [] },
114
+ };
115
+ }
116
+
117
+ const presentationDirs = walkDirs(commonRoot, "presentation");
118
+ if (presentationDirs.length === 0) {
119
+ return { verdict: "SKIP", reason: "no presentation directory under commonMain — nothing to check", details: { features: [] } };
120
+ }
121
+
122
+ // The whole commonMain .kt surface, read once — both the per-feature scan
123
+ // source and the reachability search space (desktopMain/test deliberately
124
+ // excluded: see the file header).
125
+ const allKtFiles = walkKtFilesDeep(commonRoot);
126
+ const textOf = new Map(allKtFiles.map((f) => [f, fs.readFileSync(f, "utf8")]));
127
+
128
+ const features = []; // { name, dir, entryComposables }
129
+ for (const presentationDir of presentationDirs) {
130
+ let entries;
131
+ try {
132
+ entries = fs.readdirSync(presentationDir, { withFileTypes: true });
133
+ } catch {
134
+ continue;
135
+ }
136
+ for (const e of entries) {
137
+ if (!e.isDirectory() || e.name === "components" || e.name === "theme") continue;
138
+ const featureDir = path.join(presentationDir, e.name);
139
+ const screenFiles = walkKtFilesDeep(featureDir).filter((f) => path.basename(f).endsWith("Screen.kt"));
140
+ if (screenFiles.length === 0) continue; // not a feature per this gate's definition
141
+
142
+ const entryComposables = [];
143
+ for (const f of screenFiles) {
144
+ for (const name of findEntryComposables(textOf.get(f) ?? "")) {
145
+ if (!entryComposables.includes(name)) entryComposables.push(name);
146
+ }
147
+ }
148
+ features.push({ name: e.name, dir: featureDir, entryComposables });
149
+ }
150
+ }
151
+
152
+ if (features.length === 0) {
153
+ return { verdict: "SKIP", reason: "no presentation/<feature> directory has a *Screen.kt file — nothing to check", details: { features: [] } };
154
+ }
155
+
156
+ const resultFeatures = [];
157
+ const unreachable = [];
158
+ for (const feature of features) {
159
+ // Nothing named Screen/Route was declared at all — this gate has nothing
160
+ // to check reachability for; pass it through rather than flag it.
161
+ if (feature.entryComposables.length === 0) {
162
+ resultFeatures.push({ name: feature.name, reachable: true, entryComposables: [] });
163
+ continue;
164
+ }
165
+
166
+ const featurePrefix = feature.dir + path.sep;
167
+ const outsideFiles = allKtFiles.filter((f) => !f.startsWith(featurePrefix));
168
+ const reachable = feature.entryComposables.some((name) => {
169
+ const re = new RegExp(`\\b${name}\\b`);
170
+ return outsideFiles.some((f) => re.test(textOf.get(f) ?? ""));
171
+ });
172
+
173
+ if (reachable) {
174
+ resultFeatures.push({ name: feature.name, reachable: true, entryComposables: feature.entryComposables });
175
+ continue;
176
+ }
177
+
178
+ // Exemption: docs/features/<name>.md's cmp:feature block declares
179
+ // { "unrouted": true } — the same declare-not-gate mechanism
180
+ // feature-brief.mjs already defines for `touches`/`screens`.
181
+ let unrouted = false;
182
+ try {
183
+ const briefMarkdown = fs.readFileSync(path.join(root, "docs", "features", `${feature.name}.md`), "utf8");
184
+ unrouted = parseFeatureBlock(briefMarkdown).unrouted === true;
185
+ } catch {
186
+ unrouted = false;
187
+ }
188
+
189
+ if (unrouted) {
190
+ resultFeatures.push({ name: feature.name, reachable: true, unrouted: true, entryComposables: feature.entryComposables });
191
+ continue;
192
+ }
193
+
194
+ resultFeatures.push({ name: feature.name, reachable: false, entryComposables: feature.entryComposables });
195
+ unreachable.push(feature);
196
+ }
197
+
198
+ if (unreachable.length === 0) {
199
+ return { verdict: "PASS", details: { features: resultFeatures } };
200
+ }
201
+
202
+ const lines = [
203
+ "Reachability broken — a feature's screen passed every other gate but nothing in the navigation graph points at it (a screen nobody can navigate to is not a delivered feature):",
204
+ ];
205
+ for (const f of unreachable) {
206
+ lines.push(
207
+ ` [${f.name}] entry composable(s) ${f.entryComposables.join(", ")} — not referenced anywhere in commonMain outside presentation/${f.name}/. Fix it one of two ways: wire a destination for it in the navigation graph (AppNavHost), or, if it is intentionally not routed yet, declare { "unrouted": true } in docs/features/${f.name}.md's cmp:feature block.`,
208
+ );
209
+ }
210
+ return { verdict: "FAIL", reason: lines.join("\n"), details: { features: resultFeatures } };
211
+ }
@@ -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
+ }