create-cmp-cli 0.13.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 (54) 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 +1709 -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 +96 -0
  44. package/src/lib/harness-upgrade.mjs +159 -2
  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 +95 -1
@@ -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
+ }
@@ -42,6 +42,8 @@ import { colors, ok, warn, fail, step } from "../lib/log.mjs";
42
42
  import { consent } from "../bootstrap/exec.mjs";
43
43
  import { loadRegistry, latestSet, getSet } from "../lib/registry.mjs";
44
44
  import { planUpgrade, BACKUP_SUFFIX } from "../lib/upgrade.mjs";
45
+ import { writeHarnessLock, checkHarnessIntegrity, describeIntegrity } from "../../packages/harness/src/lib/harness-lock.mjs";
46
+ import { LOCAL_PATCH_PATH } from "../lib/harness-upgrade.mjs";
45
47
  import {
46
48
  planHarnessUpgrade,
47
49
  applyHarnessPlan,
@@ -51,6 +53,52 @@ import {
51
53
 
52
54
  const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
53
55
 
56
+ /**
57
+ * The harness version this engine ships. Read from the package that owns the
58
+ * lane, NOT the engine's own package.json — they version independently, which
59
+ * is the point: the lane changes far more often than the template's app shape,
60
+ * and fusing them forced an app-shape merge every time a lane fix shipped.
61
+ * @returns {string|null} semver, or null when unreadable
62
+ */
63
+ function shippedHarnessVersion() {
64
+ try {
65
+ return JSON.parse(
66
+ fs.readFileSync(path.join(REPO_ROOT, "packages/harness/package.json"), "utf8")
67
+ ).version;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Advance create-cmp.json's engineVersion to what this engine just applied.
75
+ *
76
+ * Nothing used to do this, and the omission compounded: the next
77
+ * `upgrade --harness` re-fetched the STALE version as its merge base and
78
+ * re-presented every conflict already resolved by hand. Fuelled still read
79
+ * 0.9.0 after being upgraded to 0.13.0.
80
+ *
81
+ * Only called when the sweep landed COMPLETELY. With conflicts outstanding the
82
+ * app is genuinely part-way between two engine versions, and claiming the new
83
+ * one would be the same lie in the other direction.
84
+ * @param {string} projectDir
85
+ * @param {string} version
86
+ * @returns {boolean} whether the record was updated
87
+ */
88
+ function writeBackEngineVersion(projectDir, version) {
89
+ const specPath = path.join(projectDir, "create-cmp.json");
90
+ try {
91
+ const record = JSON.parse(fs.readFileSync(specPath, "utf8"));
92
+ if (record.engineVersion === version) return false;
93
+ record.engineVersion = version;
94
+ record.upgradedAt = new Date().toISOString();
95
+ fs.writeFileSync(specPath, `${JSON.stringify(record, null, 2)}\n`);
96
+ return true;
97
+ } catch {
98
+ return false; // best-effort — never fail an applied upgrade over metadata
99
+ }
100
+ }
101
+
54
102
  function readIfExists(p) {
55
103
  try {
56
104
  return fs.readFileSync(p, "utf8");
@@ -158,6 +206,17 @@ function printHarnessReport(plan) {
158
206
  `${colors.dim(`unchanged ${c.unchanged} · already current ${c.current} · excluded state/secrets ${c.excluded}`)}\n`
159
207
  );
160
208
  const groups = [
209
+ // The machine-owned lane first: it is the bulk of the sweep and the part
210
+ // that needs no human judgement at all.
211
+ ["region-clean", "lane refreshed (machine-owned, untouched since install)", ok],
212
+ ["region-absorbed", "lane refreshed (local edit already carried by the new engine)", ok],
213
+ ["region-restored", "lane restored (files the app had deleted)", ok],
214
+ ["region-removed", "lane files retired by the engine", ok],
215
+ [
216
+ "region-patched",
217
+ `lane refreshed, LOCAL FORK preserved (not re-applied — see ${LOCAL_PATCH_PATH})`,
218
+ warn,
219
+ ],
161
220
  ["applied", "applied (engine changed, app never touched — will take the new content)", ok],
162
221
  ["merged", "merged (both changed different regions — both edits survive)", ok],
163
222
  ["added", "added (new engine files absent from the app)", ok],
@@ -169,6 +228,9 @@ function printHarnessReport(plan) {
169
228
  for (const [bucket, label, log] of groups) {
170
229
  const files = list(bucket);
171
230
  if (files.length === 0) continue;
231
+ // `orphaned` needs no action (the app's file is kept as-is), and neither do
232
+ // the clean lane buckets — they apply themselves. Only a preserved local
233
+ // fork earns a human's attention among the region buckets.
172
234
  if (bucket !== "orphaned") actionable += files.length;
173
235
  log(`${colors.bold(String(files.length))} ${label}`);
174
236
  for (const f of files) process.stdout.write(` ${f}\n`);
@@ -309,6 +371,31 @@ async function harnessPlanAndApply({ flags, record, projectDir, targetDir, tmpRo
309
371
  for (const f of result.deleted) ok(`deleted ${f} ${colors.dim(`(backup: ${f}${BACKUP_SUFFIX})`)}`);
310
372
  for (const f of result.sidecars) warn(`conflict sidecar ${f} — resolve by hand, then delete it`);
311
373
 
374
+ // ── Re-lock the lane ──────────────────────────────────────────────────────
375
+ // The machine-owned region always lands on the new engine's content, whether
376
+ // or not app-shaped files conflicted — that is what the two independent
377
+ // version numbers buy. So the lock is rewritten on its own schedule.
378
+ const harnessVersion = shippedHarnessVersion();
379
+ if (harnessVersion) {
380
+ writeHarnessLock(projectDir, { version: harnessVersion });
381
+ ok(`lane locked at ${colors.bold(harnessVersion)} — ${describeIntegrity(checkHarnessIntegrity(projectDir))}`);
382
+ }
383
+
384
+ if (result.patched.length > 0) {
385
+ warn(
386
+ `${result.patched.length} lane file(s) carried a LOCAL change that the new engine does not. ` +
387
+ `The lane was refreshed and your edits were preserved — NOT re-applied — in ${LOCAL_PATCH_PATH}.`
388
+ );
389
+ for (const f of result.patched) process.stdout.write(` ${f}\n`);
390
+ process.stdout.write(
391
+ colors.dim(
392
+ ` The diff is against the lane you WERE on, so it may not apply cleanly to the new one —\n` +
393
+ ` that is the merge this tool declined to do behind your back, not a broken patch.\n` +
394
+ ` Attempt it with: git apply --reject ${LOCAL_PATCH_PATH} (or upstream the change)\n`
395
+ )
396
+ );
397
+ }
398
+
312
399
  if (result.backups.length > 0 || result.created.length > 0) {
313
400
  process.stdout.write(`\n${colors.bold("To revert")}\n`);
314
401
  for (const f of result.backups) {
@@ -326,6 +413,15 @@ async function harnessPlanAndApply({ flags, record, projectDir, targetDir, tmpRo
326
413
  );
327
414
  return 1;
328
415
  }
416
+ // ── Advance the recorded engine version ───────────────────────────────────
417
+ // Only now, with nothing conflicted: the sweep landed completely, so the
418
+ // next upgrade's merge base is genuinely this version. Skipping this is what
419
+ // made repeat upgrades compound — the base stayed stale and every
420
+ // already-resolved conflict came back.
421
+ if (writeBackEngineVersion(projectDir, currentVersion)) {
422
+ ok(`create-cmp.json engineVersion → ${colors.bold(currentVersion)}`);
423
+ }
424
+
329
425
  process.stdout.write(
330
426
  `\n${colors.green("Applied.")} Prove the build: ${colors.cyan(`create-cmp verify --target-dir ${targetDir}`)}\n`
331
427
  );