create-cmp-cli 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-cmp.mjs +3 -0
- package/package.json +6 -2
- package/packages/harness/package.json +38 -0
- package/packages/harness/src/approve.mjs +247 -0
- package/packages/harness/src/arch-doc.mjs +69 -0
- package/packages/harness/src/comment.mjs +76 -0
- package/packages/harness/src/lib/a11y.mjs +113 -0
- package/packages/harness/src/lib/affected-tests.mjs +147 -0
- package/packages/harness/src/lib/approvals.mjs +1403 -0
- package/packages/harness/src/lib/arch-doc.mjs +451 -0
- package/packages/harness/src/lib/audit-cadence.mjs +290 -0
- package/packages/harness/src/lib/comments.mjs +252 -0
- package/packages/harness/src/lib/component-stories.mjs +183 -0
- package/packages/harness/src/lib/determinism.mjs +179 -0
- package/packages/harness/src/lib/device-lease.mjs +249 -0
- package/packages/harness/src/lib/evidence-badge.mjs +158 -0
- package/packages/harness/src/lib/evidence-level.mjs +117 -0
- package/packages/harness/src/lib/feature-brief.mjs +324 -0
- package/packages/harness/src/lib/flight-recorder.mjs +332 -0
- package/packages/harness/src/lib/harness-lock.mjs +147 -0
- package/packages/harness/src/lib/harness-region.mjs +159 -0
- package/packages/harness/src/lib/inputs-hash.mjs +194 -0
- package/packages/harness/src/lib/reachability.mjs +211 -0
- package/packages/harness/src/lib/receipt-validate.mjs +234 -0
- package/packages/harness/src/lib/render.mjs +254 -0
- package/packages/harness/src/lib/spec-coverage.mjs +131 -0
- package/packages/harness/src/lib/step-cache.mjs +221 -0
- package/packages/harness/src/lib/token-drift.mjs +94 -0
- package/packages/harness/src/lib/tree.mjs +108 -0
- package/packages/harness/src/preview-gallery.mjs +122 -0
- package/packages/harness/src/receipt-check.mjs +96 -0
- package/packages/harness/src/record-audit.mjs +83 -0
- package/packages/harness/src/refusal-demo.mjs +498 -0
- package/packages/harness/src/retrospective.mjs +51 -0
- package/packages/harness/src/scaffold-feature.mjs +723 -0
- package/packages/harness/src/setup-hooks.mjs +33 -0
- package/packages/harness/src/verify.mjs +1709 -0
- package/packages/harness/src/walkthrough.mjs +499 -0
- package/packages/harness/src/watch.mjs +622 -0
- package/packages/receipts/package.json +36 -0
- package/packages/receipts/src/index.mjs +16 -0
- package/packages/receipts/src/inputs-hash.mjs +194 -0
- package/packages/receipts/src/receipt-validate.mjs +234 -0
- package/src/commands/upgrade.mjs +383 -0
- package/src/lib/harness-upgrade.mjs +521 -0
- package/src/scaffold.mjs +60 -1
- package/template/AGENTS.md +5 -0
- package/template/CLAUDE.md +34 -1
- package/template/README.md +4 -0
- package/template/gitignore +8 -0
- package/template/qa/lib/audit-cadence.mjs +290 -0
- package/template/qa/lib/determinism.mjs +179 -0
- package/template/qa/lib/evidence-badge.mjs +158 -0
- package/template/qa/lib/flight-recorder.mjs +332 -0
- package/template/qa/lib/harness-lock.mjs +147 -0
- package/template/qa/lib/harness-region.mjs +159 -0
- package/template/qa/lib/inputs-hash.mjs +17 -2
- package/template/qa/lib/receipt-validate.mjs +1 -1
- package/template/qa/preview-gallery.mjs +17 -2
- package/template/qa/record-audit.mjs +83 -0
- package/template/qa/retrospective.mjs +51 -0
- package/template/qa/verify.mjs +400 -10
- package/template/qa/watch.mjs +2 -2
|
@@ -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
|
+
}
|