create-cmp-cli 0.19.0 → 0.20.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/package.json +1 -1
- package/packages/harness/src/approve.mjs +23 -2
- package/packages/harness/src/lib/approvals.mjs +42 -4
- package/packages/harness/src/lib/inputs-hash.mjs +70 -3
- package/packages/harness/src/lib/receipt-validate.mjs +52 -0
- package/packages/harness/src/lib/spec-coverage.mjs +76 -1
- package/packages/harness/src/lib/steps-cmp.mjs +9 -0
- package/packages/harness/src/lib/walk.mjs +1 -1
- package/packages/harness/src/receipt-check.mjs +24 -1
- package/packages/harness/src/verify.mjs +9 -5
- package/packages/receipts/src/index.mjs +1 -0
- package/packages/receipts/src/inputs-hash.mjs +70 -3
- package/packages/receipts/src/receipt-validate.mjs +52 -0
- package/template/CLAUDE.md +8 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +1 -1
- package/template/qa/approve.mjs +23 -2
- package/template/qa/lib/approvals.mjs +42 -4
- package/template/qa/lib/inputs-hash.mjs +70 -3
- package/template/qa/lib/receipt-validate.mjs +52 -0
- package/template/qa/lib/spec-coverage.mjs +76 -1
- package/template/qa/lib/steps-cmp.mjs +9 -0
- package/template/qa/lib/walk.mjs +1 -1
- package/template/qa/receipt-check.mjs +24 -1
- package/template/qa/verify.mjs +9 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-cmp-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Create production mobile apps (Android + iOS, one Kotlin codebase) with AI — the delivery harness for Compose Multiplatform, the current generation of cross-platform (Google-backed KMP, iOS stable since May 2025). A deterministic, non-interactive generator that scaffolds a green-building app in minutes, then holds AI-driven changes to a machine-enforced verify lane with a committed evidence receipt. Every app carries a device-free UI preview loop (real screens rendered headlessly on save; changed-screen attribution and compile-error surfacing for coding agents, a live gallery for humans) plus agent-first docs (CLAUDE.md + AGENTS.md). Installs the `create-cmp` command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -134,7 +134,13 @@ function refuseIfUnresolvable() {
|
|
|
134
134
|
|
|
135
135
|
if (args.includes("--accept-defaults")) {
|
|
136
136
|
refuseIfUnresolvable();
|
|
137
|
-
const
|
|
137
|
+
const expressAsIdx = args.indexOf("--as");
|
|
138
|
+
const expressSigner = expressAsIdx >= 0 ? args[expressAsIdx + 1] : undefined;
|
|
139
|
+
if (!expressSigner || expressSigner.startsWith("--")) {
|
|
140
|
+
console.error('--accept-defaults needs a signer: node qa/approve.mjs --accept-defaults --as "Your Name <you@example.com>"');
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
const { approved, skipped } = approveAllDefaults(ROOT, expressSigner);
|
|
138
144
|
for (const id of approved) {
|
|
139
145
|
console.log(`✓ approved ${id} [defaults-accepted]`);
|
|
140
146
|
}
|
|
@@ -246,7 +252,22 @@ if (args.length === 0) {
|
|
|
246
252
|
refuseIfUnresolvable();
|
|
247
253
|
|
|
248
254
|
const artifactId = args[0];
|
|
249
|
-
|
|
255
|
+
// The signer is REQUIRED, not optional: see approveArtifact's refusal. Parsed
|
|
256
|
+
// here rather than defaulted from git config on purpose — `git config user.name`
|
|
257
|
+
// is whatever the machine says, and an agent running on a developer's laptop
|
|
258
|
+
// would sign with that developer's name. An approval must be typed by whoever
|
|
259
|
+
// is answerable for it.
|
|
260
|
+
const asIndex = args.indexOf("--as");
|
|
261
|
+
const approvedBy = asIndex >= 0 ? args[asIndex + 1] : undefined;
|
|
262
|
+
if (!approvedBy || approvedBy.startsWith("--")) {
|
|
263
|
+
console.error(
|
|
264
|
+
'approve needs a signer: node qa/approve.mjs <artifact> --as "Your Name <you@example.com>"\n' +
|
|
265
|
+
"An approval is a signature on a hash; a row that records no signer cannot tell a human's\n" +
|
|
266
|
+
"sign-off from an agent's.",
|
|
267
|
+
);
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
const result = approveArtifact(ROOT, artifactId, { via: "cli", approvedBy });
|
|
250
271
|
if (!result.ok) {
|
|
251
272
|
console.error(`error: ${result.reason}`);
|
|
252
273
|
process.exit(1);
|
|
@@ -781,6 +781,7 @@ export function resolveArtifactStatus(root, artifact, storedRecord) {
|
|
|
781
781
|
hash: recomputed.hash,
|
|
782
782
|
storedHash: storedRecord.hash ?? null,
|
|
783
783
|
approvedAt: storedRecord.approvedAt ?? null,
|
|
784
|
+
approvedBy: storedRecord.approvedBy ?? null,
|
|
784
785
|
fileCount: recomputed.fileCount,
|
|
785
786
|
missing: recomputed.missing,
|
|
786
787
|
resolvable,
|
|
@@ -801,6 +802,7 @@ export function resolveArtifactStatus(root, artifact, storedRecord) {
|
|
|
801
802
|
hash: recomputed.hash,
|
|
802
803
|
storedHash: null,
|
|
803
804
|
approvedAt: null,
|
|
805
|
+
approvedBy: null,
|
|
804
806
|
fileCount: recomputed.fileCount,
|
|
805
807
|
missing: recomputed.missing,
|
|
806
808
|
resolvable,
|
|
@@ -834,6 +836,10 @@ export function resolveArtifactStatus(root, artifact, storedRecord) {
|
|
|
834
836
|
hash: recomputed.hash,
|
|
835
837
|
storedHash: storedRecord.hash,
|
|
836
838
|
approvedAt: storedRecord.approvedAt,
|
|
839
|
+
// WHO signed. Null on rows written before signers were recorded; the gate
|
|
840
|
+
// treats a signed-by-nobody approval as FAIL, because that row cannot tell
|
|
841
|
+
// a human's sign-off from an agent's.
|
|
842
|
+
approvedBy: storedRecord.approvedBy ?? null,
|
|
837
843
|
fileCount: recomputed.fileCount,
|
|
838
844
|
missing: recomputed.missing,
|
|
839
845
|
resolvable,
|
|
@@ -920,7 +926,23 @@ export function approveArtifact(root, artifactId, options = {}) {
|
|
|
920
926
|
const state = loadApprovals(root);
|
|
921
927
|
const others = state.artifacts.filter((a) => a.artifact !== artifactId);
|
|
922
928
|
const approvedAt = new Date().toISOString();
|
|
923
|
-
|
|
929
|
+
if (!options.approvedBy || !String(options.approvedBy).trim()) {
|
|
930
|
+
return {
|
|
931
|
+
ok: false,
|
|
932
|
+
reason:
|
|
933
|
+
`cannot approve "${artifactId}" — no signer was given. An approval is a person's ` +
|
|
934
|
+
"signature on a hash; a row that records no signer cannot distinguish a human's sign-off " +
|
|
935
|
+
"from an agent's, and an agent that invalidates an approval can clear it by re-approving. " +
|
|
936
|
+
"Pass the signer: `node qa/approve.mjs <artifact> --as \"Name <email>\"`.",
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
const record = {
|
|
940
|
+
artifact: artifactId,
|
|
941
|
+
status: "approved",
|
|
942
|
+
hash: resolved.hash,
|
|
943
|
+
approvedAt,
|
|
944
|
+
approvedBy: String(options.approvedBy).trim(),
|
|
945
|
+
};
|
|
924
946
|
if (options.mode) record.mode = options.mode;
|
|
925
947
|
if (options.via) record.via = options.via;
|
|
926
948
|
others.push(record);
|
|
@@ -929,10 +951,11 @@ export function approveArtifact(root, artifactId, options = {}) {
|
|
|
929
951
|
verb: "approve",
|
|
930
952
|
artifact: artifactId,
|
|
931
953
|
hash: resolved.hash,
|
|
954
|
+
approvedBy: String(options.approvedBy).trim(),
|
|
932
955
|
...(options.via ? { via: options.via } : {}),
|
|
933
956
|
...(options.mode ? { mode: options.mode } : {}),
|
|
934
957
|
});
|
|
935
|
-
return { ok: true, artifact: artifactId, hash: resolved.hash, approvedAt, ...(options.mode ? { mode: options.mode } : {}) };
|
|
958
|
+
return { ok: true, artifact: artifactId, hash: resolved.hash, approvedAt, approvedBy: record.approvedBy, ...(options.mode ? { mode: options.mode } : {}) };
|
|
936
959
|
}
|
|
937
960
|
|
|
938
961
|
/**
|
|
@@ -945,7 +968,7 @@ export function approveArtifact(root, artifactId, options = {}) {
|
|
|
945
968
|
* @param {string} root
|
|
946
969
|
* @returns {{ok: true, approved: string[], skipped: Array<{id: string, reason: string}>}}
|
|
947
970
|
*/
|
|
948
|
-
export function approveAllDefaults(root) {
|
|
971
|
+
export function approveAllDefaults(root, approvedBy) {
|
|
949
972
|
const registry = listGovernedArtifacts(root);
|
|
950
973
|
const state = loadApprovals(root);
|
|
951
974
|
const byId = new Map(state.artifacts.map((a) => [a.artifact, a]));
|
|
@@ -954,7 +977,7 @@ export function approveAllDefaults(root) {
|
|
|
954
977
|
for (const artifact of registry) {
|
|
955
978
|
const live = resolveArtifactStatus(root, artifact, byId.get(artifact.id));
|
|
956
979
|
if (live.status === "approved") continue; // already settled — never overwritten by the express lane
|
|
957
|
-
const result = approveArtifact(root, artifact.id, { mode: "defaults-accepted" });
|
|
980
|
+
const result = approveArtifact(root, artifact.id, { mode: "defaults-accepted", approvedBy });
|
|
958
981
|
if (result.ok) approved.push(artifact.id);
|
|
959
982
|
else skipped.push({ id: artifact.id, reason: result.reason });
|
|
960
983
|
}
|
|
@@ -1143,6 +1166,21 @@ export function evaluateApprovalsGate(root) {
|
|
|
1143
1166
|
const statuses = getApprovalStatuses(root);
|
|
1144
1167
|
const mismatched = statuses.filter((s) => s.status === "changed-since-approval");
|
|
1145
1168
|
const pending = statuses.filter((s) => s.status === "unreviewed" || s.status === "reopened");
|
|
1169
|
+
// An "approved" row with no signer attests nothing about WHO signed, which is
|
|
1170
|
+
// the one fact an approval exists to record. It cannot distinguish a human's
|
|
1171
|
+
// sign-off from an agent's, and an agent that invalidates an approval can
|
|
1172
|
+
// clear it by re-approving — the gate then guards only against accident, not
|
|
1173
|
+
// against the population it is pointed at. Rows written before signers were
|
|
1174
|
+
// recorded land here; the fix is one re-approval each, and the message says so.
|
|
1175
|
+
const unsigned = statuses.filter((s) => s.status === "approved" && !s.approvedBy);
|
|
1176
|
+
|
|
1177
|
+
if (unsigned.length > 0) {
|
|
1178
|
+
const lines = ["Approval recorded without a signer — re-approve to say who signed:"];
|
|
1179
|
+
for (const s of unsigned) {
|
|
1180
|
+
lines.push(` [${s.id}] ${s.label} — approved ${shortHash(s.storedHash)} by nobody. Re-approve: node qa/approve.mjs ${s.id} --as "Your Name <you@example.com>"`);
|
|
1181
|
+
}
|
|
1182
|
+
return { verdict: "FAIL", reason: lines.join("\n"), statuses };
|
|
1183
|
+
}
|
|
1146
1184
|
|
|
1147
1185
|
if (mismatched.length > 0) {
|
|
1148
1186
|
const lines = ["Approval invalidated — a governed artifact changed after sign-off:"];
|
|
@@ -17,8 +17,19 @@ import { createHash } from "node:crypto";
|
|
|
17
17
|
import fs from "node:fs";
|
|
18
18
|
import path from "node:path";
|
|
19
19
|
|
|
20
|
-
// Directories / files
|
|
20
|
+
// Directories / files included in the verified surface (relative to project ROOT).
|
|
21
21
|
// Principle: every tracked file whose content can change the lane's verdict.
|
|
22
|
+
//
|
|
23
|
+
// THIS IS A DEFAULT, NOT A LAW (evidence-economics S8, 2026-09-03). It is the
|
|
24
|
+
// surface of a Compose Multiplatform app, and it used to be hardcoded inside
|
|
25
|
+
// this module — which is the SPINE, shared by every adopter. A repo whose code
|
|
26
|
+
// lives in services/ or src/ that vendored this file had its verified surface
|
|
27
|
+
// silently shrink to whatever happened to match: no error, no failed step, a
|
|
28
|
+
// receipt that still validated and still looked identical, and a hash that had
|
|
29
|
+
// quietly stopped covering the application. A gate that attests less while
|
|
30
|
+
// looking the same is the worst failure this harness can have, so the surface
|
|
31
|
+
// is now resolved per project (see resolveVerifiedSurface) and an empty one is
|
|
32
|
+
// refused rather than hashed.
|
|
22
33
|
export const VERIFIED_SURFACE = [
|
|
23
34
|
"composeApp",
|
|
24
35
|
"specs",
|
|
@@ -147,9 +158,52 @@ function walkAllFiles(dir) {
|
|
|
147
158
|
return out;
|
|
148
159
|
}
|
|
149
160
|
|
|
161
|
+
/** Where a project may declare its own verified surface (see resolveVerifiedSurface). */
|
|
162
|
+
export const SURFACE_CONFIG_REL = "qa/verified-surface.json";
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The surface THIS project attests — its own declaration when it has one, the
|
|
166
|
+
* Compose Multiplatform default otherwise.
|
|
167
|
+
*
|
|
168
|
+
* Read from a file rather than passed as an argument on purpose: qa/verify.mjs
|
|
169
|
+
* (which writes inputs.hash) and qa/receipt-check.mjs (which recomputes it)
|
|
170
|
+
* must never disagree about what was hashed, and two call sites taking a
|
|
171
|
+
* parameter is two places to get it wrong. The file lives under qa/, so it is
|
|
172
|
+
* itself inside the surface — changing the definition invalidates receipts,
|
|
173
|
+
* which is correct: the tree's coverage changed.
|
|
174
|
+
*
|
|
175
|
+
* Shape: {"surface": ["services", "docs", "build-logic", ".github", "qa"]}.
|
|
176
|
+
* Malformed or empty content is REFUSED, never silently defaulted — a project
|
|
177
|
+
* that tried to declare a surface and failed must not fall back to a smaller
|
|
178
|
+
* one behind the operator's back.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} root project root
|
|
181
|
+
* @returns {string[]} surface entries, relative to root
|
|
182
|
+
*/
|
|
183
|
+
export function resolveVerifiedSurface(root) {
|
|
184
|
+
const p = path.join(root, SURFACE_CONFIG_REL);
|
|
185
|
+
let raw;
|
|
186
|
+
try {
|
|
187
|
+
raw = fs.readFileSync(p, "utf8");
|
|
188
|
+
} catch {
|
|
189
|
+
return VERIFIED_SURFACE; // no declaration — the CMP default, unchanged
|
|
190
|
+
}
|
|
191
|
+
let parsed;
|
|
192
|
+
try {
|
|
193
|
+
parsed = JSON.parse(raw);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
throw new Error(`${SURFACE_CONFIG_REL} is not valid JSON (${err.message}) — refusing to hash a surface this project failed to declare.`);
|
|
196
|
+
}
|
|
197
|
+
const list = parsed && Array.isArray(parsed.surface) ? parsed.surface.filter((x) => typeof x === "string" && x.trim() !== "") : null;
|
|
198
|
+
if (!list || list.length === 0) {
|
|
199
|
+
throw new Error(`${SURFACE_CONFIG_REL} declares no surface — expected {"surface": ["dir", …]}. Refusing to hash nothing.`);
|
|
200
|
+
}
|
|
201
|
+
return list;
|
|
202
|
+
}
|
|
203
|
+
|
|
150
204
|
// Resolve the verified surface to a flat, sorted list of paths (relative to
|
|
151
205
|
// root, POSIX-style `/` separators) that currently exist on disk.
|
|
152
|
-
function resolveSurfaceFiles(root) {
|
|
206
|
+
function resolveSurfaceFiles(root, VERIFIED_SURFACE) {
|
|
153
207
|
const gitFiles = tryGitLsFiles(root);
|
|
154
208
|
|
|
155
209
|
if (gitFiles) {
|
|
@@ -189,7 +243,20 @@ export function computeInputsHash(root) {
|
|
|
189
243
|
// on iteration order, and ICU collation varies with the machine's locale
|
|
190
244
|
// (e.g. a da_DK machine orders "aa" after "z"; en orders case-insensitively
|
|
191
245
|
// where code units do not) — the same tree must hash identically everywhere.
|
|
192
|
-
const
|
|
246
|
+
const surface = resolveVerifiedSurface(root);
|
|
247
|
+
const files = [...new Set(resolveSurfaceFiles(root, surface))].sort();
|
|
248
|
+
|
|
249
|
+
// A surface that matches NOTHING is a misconfiguration, not a valid hash.
|
|
250
|
+
// Hashing zero files yields a stable, confident-looking digest that attests
|
|
251
|
+
// the empty set — the silent shrink this whole change exists to prevent, in
|
|
252
|
+
// its most extreme form. Refuse, and name what was looked for.
|
|
253
|
+
if (files.length === 0) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`the verified surface matched no files under ${root} — nothing would be attested. ` +
|
|
256
|
+
`Surface: ${surface.join(", ")}. ` +
|
|
257
|
+
`A project whose code lives elsewhere declares its own in ${SURFACE_CONFIG_REL}: {"surface": ["services", "qa", …]}.`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
193
260
|
|
|
194
261
|
const overall = createHash("sha256");
|
|
195
262
|
for (const relPath of files) {
|
|
@@ -45,6 +45,51 @@ export function readReceipt(root, relPath = RECEIPT_REL_PATH) {
|
|
|
45
45
|
* FAIL verdict), so callers don't pay for a hash they don't need.
|
|
46
46
|
* @returns {{valid: boolean, reason: string, profile: (string|undefined), recomputed?: {hash: string, fileCount: number}}}
|
|
47
47
|
*/
|
|
48
|
+
/**
|
|
49
|
+
* Does this receipt's own row-level evidence support its PASS?
|
|
50
|
+
*
|
|
51
|
+
* The receipt is necessarily excluded from the inputs hash it carries — a file
|
|
52
|
+
* cannot hash itself — so steps[] is the only thing between this gate and a text
|
|
53
|
+
* editor, and the top-level verdict is the most editable field on it.
|
|
54
|
+
*
|
|
55
|
+
* Two failures this catches, both observed downstream (payment-blueprint F2/F3):
|
|
56
|
+
* a receipt whose verdict was hand-edited from FAIL to PASS while its rows still
|
|
57
|
+
* said otherwise, and a lane made green by DELETING harness.lock.json, which
|
|
58
|
+
* downgraded harnessIntegrity from FAIL to SKIP and took the lane's verdict with
|
|
59
|
+
* it — a lane vouching for a tree with nothing vouching for the lane.
|
|
60
|
+
*
|
|
61
|
+
* @param {{verdict?: string, steps?: Array<{name?: string, verdict?: string}>}} receipt
|
|
62
|
+
* @returns {{ok: boolean, detail: string}}
|
|
63
|
+
*/
|
|
64
|
+
export function checkLaneVouching(receipt) {
|
|
65
|
+
const steps = Array.isArray(receipt?.steps) ? receipt.steps : null;
|
|
66
|
+
if (!steps || steps.length === 0) {
|
|
67
|
+
return { ok: false, detail: "receipt lists no verify-lane steps — a PASS over nothing attests nothing" };
|
|
68
|
+
}
|
|
69
|
+
const failed = steps.filter((s) => s && (s.verdict === "FAIL" || s.verdict === "ERROR"));
|
|
70
|
+
if (failed.length > 0) {
|
|
71
|
+
const names = failed.map((s) => `${s.name ?? "?"} (${s.verdict})`).join(", ");
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
detail: `the receipt's verdict is PASS but ${failed.length} step(s) did not pass: ${names} — the row is the more specific truth`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const integrity = steps.find((s) => s && s.name === "harnessIntegrity");
|
|
78
|
+
if (!integrity) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
detail: "receipt has no harnessIntegrity row — nothing vouches that the lane's own code is the code that ran",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (integrity.verdict !== "PASS") {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
detail: `harnessIntegrity is ${integrity.verdict}, not PASS — the lane did not vouch for itself, so its PASS over the tree cannot be trusted`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, detail: "lane vouched for itself (harnessIntegrity PASS, no failing rows)" };
|
|
91
|
+
}
|
|
92
|
+
|
|
48
93
|
export function evaluateReceipt(receipt, recompute) {
|
|
49
94
|
const profile = receipt.profile;
|
|
50
95
|
|
|
@@ -83,6 +128,13 @@ export function evaluateReceipt(receipt, recompute) {
|
|
|
83
128
|
};
|
|
84
129
|
}
|
|
85
130
|
|
|
131
|
+
// Did the lane vouch for ITSELF? See checkLaneVouching — the top-level verdict
|
|
132
|
+
// is the most editable field on a file the hash cannot cover.
|
|
133
|
+
const vouching = checkLaneVouching(receipt);
|
|
134
|
+
if (!vouching.ok) {
|
|
135
|
+
return { valid: false, reason: `${vouching.detail} (attesting profile: ${profile ?? "unknown"})`, profile, recomputed };
|
|
136
|
+
}
|
|
137
|
+
|
|
86
138
|
return { valid: true, reason: `receipt is valid — PASS, attesting profile: ${profile ?? "unknown"}`, profile, recomputed };
|
|
87
139
|
}
|
|
88
140
|
|
|
@@ -30,6 +30,78 @@ export const TIERS_SATISFYING = Object.freeze({
|
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
const TAG_LINE_RE = /^(?:\/\/|#)\s*SPEC:/;
|
|
33
|
+
|
|
34
|
+
// A citation is a claim that a TEST covers a clause, so it has to sit on one.
|
|
35
|
+
// Counting the tag wherever it appears makes a red specCoverage curable with a
|
|
36
|
+
// comment and zero assertions — the one escape this gate exists to close. It is
|
|
37
|
+
// not hypothetical: payment-blueprint hit a citation that had drifted onto a
|
|
38
|
+
// class declaration, where it counted for the whole file while testing nothing.
|
|
39
|
+
//
|
|
40
|
+
// So a tag counts only when a test declaration follows it within
|
|
41
|
+
// BINDING_WINDOW non-blank lines. The window is small enough that the tag must
|
|
42
|
+
// be attached to the test, and loose enough for the @DisplayName / annotation
|
|
43
|
+
// stack that idiomatically sits between them.
|
|
44
|
+
export const BINDING_WINDOW = 5;
|
|
45
|
+
|
|
46
|
+
// Kotlin @Test, a backticked test function, and the node:test / Maestro-adjacent
|
|
47
|
+
// `test(` / `it(` call forms. Deliberately syntactic: a citation's binding must
|
|
48
|
+
// be readable without compiling anything.
|
|
49
|
+
const TEST_DECL_RE = /@Test\b|\bfun\s+`[^`]+`\s*\(|\b(?:test|it)\s*\(/;
|
|
50
|
+
|
|
51
|
+
// A tag whose first meaningful line declares a TYPE is documenting that type,
|
|
52
|
+
// not claiming a test — and it must be refused structurally rather than by
|
|
53
|
+
// distance, because a short class body puts a real @Test inside the window and
|
|
54
|
+
// would otherwise launder the citation. This is exactly payment-blueprint's
|
|
55
|
+
// drift: `// SPEC: PP-07` sat on `class PaymentWorkerTest`, three properties
|
|
56
|
+
// above a genuine @Test, and vouched for the whole file.
|
|
57
|
+
const TYPE_DECL_RE = /^(?:@\w+\s+)*(?:public\s+|internal\s+|private\s+|abstract\s+|open\s+|sealed\s+|data\s+|enum\s+)*(?:class|object|interface)\b/;
|
|
58
|
+
|
|
59
|
+
// A YAML flow's own shape counts as its test: a Maestro file IS the test, so a
|
|
60
|
+
// tag in one binds to the flow rather than to a declaration inside it.
|
|
61
|
+
const FLOW_EXTS = [".yaml", ".yml"];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Does a test declaration follow `index` within BINDING_WINDOW non-blank lines,
|
|
65
|
+
* skipping block-comment bodies (a tag inside one is documentation, not a claim)?
|
|
66
|
+
* @param {string[]} lines
|
|
67
|
+
* @param {number} index line the tag sits on
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
export function citationIsBound(lines, index) {
|
|
71
|
+
let seen = 0;
|
|
72
|
+
let inBlockComment = false;
|
|
73
|
+
for (let i = index + 1; i < lines.length && seen < BINDING_WINDOW; i += 1) {
|
|
74
|
+
const line = lines[i].trim();
|
|
75
|
+
if (line === "") continue;
|
|
76
|
+
if (inBlockComment) {
|
|
77
|
+
if (line.includes("*/")) inBlockComment = false;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (line.startsWith("/*")) {
|
|
81
|
+
if (!line.includes("*/")) inBlockComment = true;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (line.startsWith("//") || line.startsWith("*")) continue;
|
|
85
|
+
seen += 1;
|
|
86
|
+
// The FIRST meaningful line decides whether this tag is on a test at all.
|
|
87
|
+
if (seen === 1 && TYPE_DECL_RE.test(line)) return false;
|
|
88
|
+
if (TEST_DECL_RE.test(line)) return true;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Is this tag inside a block comment that began earlier in the file? */
|
|
94
|
+
function insideBlockComment(lines, index) {
|
|
95
|
+
let open = false;
|
|
96
|
+
for (let i = 0; i < index; i += 1) {
|
|
97
|
+
const line = lines[i];
|
|
98
|
+
for (let c = 0; c < line.length - 1; c += 1) {
|
|
99
|
+
if (!open && line[c] === "/" && line[c + 1] === "*") open = true;
|
|
100
|
+
else if (open && line[c] === "*" && line[c + 1] === "/") open = false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return open;
|
|
104
|
+
}
|
|
33
105
|
const TAG_IDS_RE = /SPEC:\s*([A-Z0-9,\s-]+)/;
|
|
34
106
|
const CLAUSE_ID_RE = /^[A-Z][A-Z0-9]*-\d{2,}$/;
|
|
35
107
|
|
|
@@ -105,11 +177,14 @@ export function scanCitations(root) {
|
|
|
105
177
|
const tier = tierForFile(rel);
|
|
106
178
|
fs.readFileSync(f, "utf8")
|
|
107
179
|
.split("\n")
|
|
108
|
-
.forEach((line, i) => {
|
|
180
|
+
.forEach((line, i, lines) => {
|
|
109
181
|
const trimmed = line.trim();
|
|
110
182
|
if (!TAG_LINE_RE.test(trimmed)) return;
|
|
111
183
|
const m = trimmed.match(TAG_IDS_RE);
|
|
112
184
|
if (!m) return;
|
|
185
|
+
// A flow file IS its test; anything else must have a test under the tag.
|
|
186
|
+
const isFlow = FLOW_EXTS.some((ext) => rel.endsWith(ext));
|
|
187
|
+
if (!isFlow && (insideBlockComment(lines, i) || !citationIsBound(lines, i))) return;
|
|
113
188
|
const ids = m[1]
|
|
114
189
|
.split(/[,\s]+/)
|
|
115
190
|
.map((s) => s.trim())
|
|
@@ -1177,6 +1177,15 @@ const stepsForProfile = {
|
|
|
1177
1177
|
// scaffold: what `create-cmp --verify` proves at stamp time — specCoverage,
|
|
1178
1178
|
// the full JVM tier (unit + conformance + golden + UI tests) plus the Android build.
|
|
1179
1179
|
scaffold: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepArchDocMemo, stepSchemaHistory, stepBuild, stepUnitTests],
|
|
1180
|
+
// smoke (docs/GATE-RULES.md Rule 0, docs/PRINCIPLES.md #2): the smallest
|
|
1181
|
+
// end-to-end lane — every pure-Node step through the REAL runner, marker,
|
|
1182
|
+
// receipt and journal, and NO Gradle, no device, no network. Its job is to
|
|
1183
|
+
// prove the framework RETURNS, fast, in both directions, before any real
|
|
1184
|
+
// work is pointed at it. scripts/framework-check.mjs drives it: PASS on a
|
|
1185
|
+
// fresh scaffold, then FAIL BY NAME on one planted spec edit, each bounded
|
|
1186
|
+
// in seconds. Its receipt is refused as done-evidence (qa/receipt-check.mjs)
|
|
1187
|
+
// exactly like --fast: it proves the instrument, never the change.
|
|
1188
|
+
smoke: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepArchDocMemo, stepSchemaHistory],
|
|
1180
1189
|
local: [
|
|
1181
1190
|
// First, always: every verdict below is only worth what the lane issuing
|
|
1182
1191
|
// it is worth.
|
|
@@ -512,7 +512,7 @@ export function renderInject(data) {
|
|
|
512
512
|
for (const a of arrivals)
|
|
513
513
|
parts.push(`▲ ARRIVED, UNPLANNED — ${a.label} (${a.status}): ${a.reason ?? "no recorded reason"}. Offer: handle now, or after the current walk lands (recommended: after).`);
|
|
514
514
|
parts.push(
|
|
515
|
-
"Protocol: open every reply with the chat header line above, verbatim. Speak stages as Decide·Design·Contract·Build·Prove·Sign-off — with their plain-words gloss on first mention — and clauses as promises. Declare the chain at kickoff and advance it as you go; if the studio line above says DOWN or not running, restore it (preview tool) or surface it before proceeding. Quiet between headers (one line per stage transition). At any human gate, render the full stop card: stage, what it is in plain words, then the easiest act first (the studio console when it is up; the CLI as fallback), then what comes after. Quote the lane's cost only from the measured figure in the card — never estimate it. Never open a second walk silently.",
|
|
515
|
+
"Protocol: open every reply with the chat header line above, verbatim. Speak stages as Decide·Design·Contract·Build·Prove·Sign-off — with their plain-words gloss on first mention — and clauses as promises. Declare the chain at kickoff and advance it as you go; if the studio line above says DOWN or not running, restore it (preview tool) or surface it before proceeding. Quiet between headers (one line per stage transition). At any human gate, render the full stop card: stage, what it is in plain words, then the easiest act first (the studio console when it is up; the CLI as fallback), then what comes after. Quote the lane's cost only from the measured figure in the card — never estimate it. Never open a second walk silently. Principles that bind THIS turn (docs/PRINCIPLES.md): derived, never claimed — name the command behind any claim; the layer you changed cannot certify itself — run its consumers, stamp a fresh app for a template or harness change; never wait on nothing — every wait is bounded, and if you are blocked, say on what and stop.",
|
|
516
516
|
);
|
|
517
517
|
return parts.join("\n\n");
|
|
518
518
|
}
|
|
@@ -99,7 +99,30 @@ function evaluate() {
|
|
|
99
99
|
profile: receipt.profile,
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
|
-
|
|
102
|
+
// smoke (GATE-RULES Rule 0) runs no Gradle: it proves the framework returns,
|
|
103
|
+
// never that the change is good. Refused like --fast, for the same reason.
|
|
104
|
+
if (receipt.stage === "smoke" || receipt.profile === "smoke") {
|
|
105
|
+
return {
|
|
106
|
+
valid: false,
|
|
107
|
+
reason: "the last verify run was the smoke profile (the framework check — no build, no tests; it proves the instrument, not this change); run the change-stage lane (`node qa/verify.mjs`) before finishing",
|
|
108
|
+
profile: receipt.profile,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// A surface this project cannot resolve is a REFUSAL with an explanation,
|
|
112
|
+
// never an unhandled stack trace: this runs as the Stop hook on every turn
|
|
113
|
+
// end, and a crash there reads as a broken harness rather than as the
|
|
114
|
+
// misconfiguration it is. (evidence-economics S8 follow-up: computeInputsHash
|
|
115
|
+
// now throws rather than returning a confident hash of the empty set.)
|
|
116
|
+
let result;
|
|
117
|
+
try {
|
|
118
|
+
result = evaluateReceipt(receipt, () => computeInputsHash(ROOT));
|
|
119
|
+
} catch (err) {
|
|
120
|
+
return {
|
|
121
|
+
valid: false,
|
|
122
|
+
reason: `cannot verify this receipt — ${err && err.message ? err.message : String(err)}`,
|
|
123
|
+
profile: receipt.profile,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
103
126
|
// Surface the receipt's evidence rung (the ladder — qa/lib/evidence-level.mjs)
|
|
104
127
|
// alongside the verdict: the rung is the receipt's own derived field, read
|
|
105
128
|
// verbatim, never recomputed here. Older receipts without it stay valid.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// The verify lane — this project's single verification gate.
|
|
3
3
|
//
|
|
4
|
-
// node qa/verify.mjs [--profile scaffold|local|ci|nightly|release] [--fast] [--json]
|
|
4
|
+
// node qa/verify.mjs [--profile smoke|scaffold|local|ci|nightly|release] [--fast] [--json]
|
|
5
5
|
//
|
|
6
6
|
// Runs every verification step this project carries, aggregates a typed
|
|
7
7
|
// PASS/FAIL verdict, and writes the evidence receipt to qa/evidence/latest.json.
|
|
@@ -51,7 +51,7 @@ const ARTIFACTS_DIR = path.join(ROOT, "qa-artifacts");
|
|
|
51
51
|
// killed). Same refusal-over-fabrication stance as qa/approve.mjs, which
|
|
52
52
|
// refuses an unknown artifact by name rather than guessing: an unknown
|
|
53
53
|
// argument here is refused by name, not swallowed into "run everything".
|
|
54
|
-
const USAGE = `node qa/verify.mjs [--profile scaffold|local|ci|nightly|release] [--fast] [--json] [--help]
|
|
54
|
+
const USAGE = `node qa/verify.mjs [--profile smoke|scaffold|local|ci|nightly|release] [--fast] [--json] [--help]
|
|
55
55
|
|
|
56
56
|
The verify lane — this project's single verification gate. Runs every
|
|
57
57
|
verification step this project carries, aggregates a typed PASS/FAIL
|
|
@@ -59,7 +59,7 @@ verdict, and writes the evidence receipt to qa/evidence/latest.json (commit
|
|
|
59
59
|
it with your change — see CLAUDE.md). Exit code: 0 = PASS, 1 = FAIL.
|
|
60
60
|
|
|
61
61
|
Flags:
|
|
62
|
-
--profile <scaffold|local|ci|nightly|release>
|
|
62
|
+
--profile <smoke|scaffold|local|ci|nightly|release>
|
|
63
63
|
which step set to run (default: local)
|
|
64
64
|
--fast INNER LOOP ONLY — run the resolved profile
|
|
65
65
|
minus the device/release tier (releaseBuild,
|
|
@@ -101,6 +101,10 @@ Flags:
|
|
|
101
101
|
running anything
|
|
102
102
|
|
|
103
103
|
Profiles:
|
|
104
|
+
smoke the smallest end-to-end lane: every pure-Node gate through the real
|
|
105
|
+
runner, receipt and journal — no Gradle, no device. Seconds. Proves the
|
|
106
|
+
FRAMEWORK returns, both ways; never the change (its receipt is refused
|
|
107
|
+
as done-evidence). Driven by scripts/framework-check.mjs.
|
|
104
108
|
scaffold spec coverage + build + unit tests (what \`create-cmp --verify\`
|
|
105
109
|
proves at stamp time)
|
|
106
110
|
local everything; device-dependent steps SKIP when no device is
|
|
@@ -321,7 +325,7 @@ const { stepsForProfile, DEVICE_STEPS, FAST_EXCLUDED_NAMES, STEP_FN_BY_NAME } =
|
|
|
321
325
|
|
|
322
326
|
|
|
323
327
|
if (!stepsForProfile[profile]) {
|
|
324
|
-
console.error(`Unknown profile "${profile}" — use scaffold | local | ci | nightly | release.`);
|
|
328
|
+
console.error(`Unknown profile "${profile}" — use smoke | scaffold | local | ci | nightly | release.`);
|
|
325
329
|
process.exit(2);
|
|
326
330
|
}
|
|
327
331
|
|
|
@@ -499,7 +503,7 @@ const inputs = computeInputsHash(ROOT);
|
|
|
499
503
|
// more than its stage allows. scaffold → scaffold, local → change (per commit),
|
|
500
504
|
// ci → merge, nightly → nightly (proves the harness, never a change), release →
|
|
501
505
|
// release. Receipts predating this field are read as their profile's stage.
|
|
502
|
-
const STAGE_OF_PROFILE = { scaffold: "scaffold", local: "change", ci: "merge", nightly: "nightly", release: "release" };
|
|
506
|
+
const STAGE_OF_PROFILE = { smoke: "smoke", scaffold: "scaffold", local: "change", ci: "merge", nightly: "nightly", release: "release" };
|
|
503
507
|
const receipt = {
|
|
504
508
|
schema: "cmp-evidence/1",
|
|
505
509
|
profile,
|
|
@@ -17,8 +17,19 @@ import { createHash } from "node:crypto";
|
|
|
17
17
|
import fs from "node:fs";
|
|
18
18
|
import path from "node:path";
|
|
19
19
|
|
|
20
|
-
// Directories / files
|
|
20
|
+
// Directories / files included in the verified surface (relative to project ROOT).
|
|
21
21
|
// Principle: every tracked file whose content can change the lane's verdict.
|
|
22
|
+
//
|
|
23
|
+
// THIS IS A DEFAULT, NOT A LAW (evidence-economics S8, 2026-09-03). It is the
|
|
24
|
+
// surface of a Compose Multiplatform app, and it used to be hardcoded inside
|
|
25
|
+
// this module — which is the SPINE, shared by every adopter. A repo whose code
|
|
26
|
+
// lives in services/ or src/ that vendored this file had its verified surface
|
|
27
|
+
// silently shrink to whatever happened to match: no error, no failed step, a
|
|
28
|
+
// receipt that still validated and still looked identical, and a hash that had
|
|
29
|
+
// quietly stopped covering the application. A gate that attests less while
|
|
30
|
+
// looking the same is the worst failure this harness can have, so the surface
|
|
31
|
+
// is now resolved per project (see resolveVerifiedSurface) and an empty one is
|
|
32
|
+
// refused rather than hashed.
|
|
22
33
|
export const VERIFIED_SURFACE = [
|
|
23
34
|
"composeApp",
|
|
24
35
|
"specs",
|
|
@@ -147,9 +158,52 @@ function walkAllFiles(dir) {
|
|
|
147
158
|
return out;
|
|
148
159
|
}
|
|
149
160
|
|
|
161
|
+
/** Where a project may declare its own verified surface (see resolveVerifiedSurface). */
|
|
162
|
+
export const SURFACE_CONFIG_REL = "qa/verified-surface.json";
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The surface THIS project attests — its own declaration when it has one, the
|
|
166
|
+
* Compose Multiplatform default otherwise.
|
|
167
|
+
*
|
|
168
|
+
* Read from a file rather than passed as an argument on purpose: qa/verify.mjs
|
|
169
|
+
* (which writes inputs.hash) and qa/receipt-check.mjs (which recomputes it)
|
|
170
|
+
* must never disagree about what was hashed, and two call sites taking a
|
|
171
|
+
* parameter is two places to get it wrong. The file lives under qa/, so it is
|
|
172
|
+
* itself inside the surface — changing the definition invalidates receipts,
|
|
173
|
+
* which is correct: the tree's coverage changed.
|
|
174
|
+
*
|
|
175
|
+
* Shape: {"surface": ["services", "docs", "build-logic", ".github", "qa"]}.
|
|
176
|
+
* Malformed or empty content is REFUSED, never silently defaulted — a project
|
|
177
|
+
* that tried to declare a surface and failed must not fall back to a smaller
|
|
178
|
+
* one behind the operator's back.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} root project root
|
|
181
|
+
* @returns {string[]} surface entries, relative to root
|
|
182
|
+
*/
|
|
183
|
+
export function resolveVerifiedSurface(root) {
|
|
184
|
+
const p = path.join(root, SURFACE_CONFIG_REL);
|
|
185
|
+
let raw;
|
|
186
|
+
try {
|
|
187
|
+
raw = fs.readFileSync(p, "utf8");
|
|
188
|
+
} catch {
|
|
189
|
+
return VERIFIED_SURFACE; // no declaration — the CMP default, unchanged
|
|
190
|
+
}
|
|
191
|
+
let parsed;
|
|
192
|
+
try {
|
|
193
|
+
parsed = JSON.parse(raw);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
throw new Error(`${SURFACE_CONFIG_REL} is not valid JSON (${err.message}) — refusing to hash a surface this project failed to declare.`);
|
|
196
|
+
}
|
|
197
|
+
const list = parsed && Array.isArray(parsed.surface) ? parsed.surface.filter((x) => typeof x === "string" && x.trim() !== "") : null;
|
|
198
|
+
if (!list || list.length === 0) {
|
|
199
|
+
throw new Error(`${SURFACE_CONFIG_REL} declares no surface — expected {"surface": ["dir", …]}. Refusing to hash nothing.`);
|
|
200
|
+
}
|
|
201
|
+
return list;
|
|
202
|
+
}
|
|
203
|
+
|
|
150
204
|
// Resolve the verified surface to a flat, sorted list of paths (relative to
|
|
151
205
|
// root, POSIX-style `/` separators) that currently exist on disk.
|
|
152
|
-
function resolveSurfaceFiles(root) {
|
|
206
|
+
function resolveSurfaceFiles(root, VERIFIED_SURFACE) {
|
|
153
207
|
const gitFiles = tryGitLsFiles(root);
|
|
154
208
|
|
|
155
209
|
if (gitFiles) {
|
|
@@ -189,7 +243,20 @@ export function computeInputsHash(root) {
|
|
|
189
243
|
// on iteration order, and ICU collation varies with the machine's locale
|
|
190
244
|
// (e.g. a da_DK machine orders "aa" after "z"; en orders case-insensitively
|
|
191
245
|
// where code units do not) — the same tree must hash identically everywhere.
|
|
192
|
-
const
|
|
246
|
+
const surface = resolveVerifiedSurface(root);
|
|
247
|
+
const files = [...new Set(resolveSurfaceFiles(root, surface))].sort();
|
|
248
|
+
|
|
249
|
+
// A surface that matches NOTHING is a misconfiguration, not a valid hash.
|
|
250
|
+
// Hashing zero files yields a stable, confident-looking digest that attests
|
|
251
|
+
// the empty set — the silent shrink this whole change exists to prevent, in
|
|
252
|
+
// its most extreme form. Refuse, and name what was looked for.
|
|
253
|
+
if (files.length === 0) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`the verified surface matched no files under ${root} — nothing would be attested. ` +
|
|
256
|
+
`Surface: ${surface.join(", ")}. ` +
|
|
257
|
+
`A project whose code lives elsewhere declares its own in ${SURFACE_CONFIG_REL}: {"surface": ["services", "qa", …]}.`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
193
260
|
|
|
194
261
|
const overall = createHash("sha256");
|
|
195
262
|
for (const relPath of files) {
|
|
@@ -45,6 +45,51 @@ export function readReceipt(root, relPath = RECEIPT_REL_PATH) {
|
|
|
45
45
|
* FAIL verdict), so callers don't pay for a hash they don't need.
|
|
46
46
|
* @returns {{valid: boolean, reason: string, profile: (string|undefined), recomputed?: {hash: string, fileCount: number}}}
|
|
47
47
|
*/
|
|
48
|
+
/**
|
|
49
|
+
* Does this receipt's own row-level evidence support its PASS?
|
|
50
|
+
*
|
|
51
|
+
* The receipt is necessarily excluded from the inputs hash it carries — a file
|
|
52
|
+
* cannot hash itself — so steps[] is the only thing between this gate and a text
|
|
53
|
+
* editor, and the top-level verdict is the most editable field on it.
|
|
54
|
+
*
|
|
55
|
+
* Two failures this catches, both observed downstream (payment-blueprint F2/F3):
|
|
56
|
+
* a receipt whose verdict was hand-edited from FAIL to PASS while its rows still
|
|
57
|
+
* said otherwise, and a lane made green by DELETING harness.lock.json, which
|
|
58
|
+
* downgraded harnessIntegrity from FAIL to SKIP and took the lane's verdict with
|
|
59
|
+
* it — a lane vouching for a tree with nothing vouching for the lane.
|
|
60
|
+
*
|
|
61
|
+
* @param {{verdict?: string, steps?: Array<{name?: string, verdict?: string}>}} receipt
|
|
62
|
+
* @returns {{ok: boolean, detail: string}}
|
|
63
|
+
*/
|
|
64
|
+
export function checkLaneVouching(receipt) {
|
|
65
|
+
const steps = Array.isArray(receipt?.steps) ? receipt.steps : null;
|
|
66
|
+
if (!steps || steps.length === 0) {
|
|
67
|
+
return { ok: false, detail: "receipt lists no verify-lane steps — a PASS over nothing attests nothing" };
|
|
68
|
+
}
|
|
69
|
+
const failed = steps.filter((s) => s && (s.verdict === "FAIL" || s.verdict === "ERROR"));
|
|
70
|
+
if (failed.length > 0) {
|
|
71
|
+
const names = failed.map((s) => `${s.name ?? "?"} (${s.verdict})`).join(", ");
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
detail: `the receipt's verdict is PASS but ${failed.length} step(s) did not pass: ${names} — the row is the more specific truth`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const integrity = steps.find((s) => s && s.name === "harnessIntegrity");
|
|
78
|
+
if (!integrity) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
detail: "receipt has no harnessIntegrity row — nothing vouches that the lane's own code is the code that ran",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (integrity.verdict !== "PASS") {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
detail: `harnessIntegrity is ${integrity.verdict}, not PASS — the lane did not vouch for itself, so its PASS over the tree cannot be trusted`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, detail: "lane vouched for itself (harnessIntegrity PASS, no failing rows)" };
|
|
91
|
+
}
|
|
92
|
+
|
|
48
93
|
export function evaluateReceipt(receipt, recompute) {
|
|
49
94
|
const profile = receipt.profile;
|
|
50
95
|
|
|
@@ -83,6 +128,13 @@ export function evaluateReceipt(receipt, recompute) {
|
|
|
83
128
|
};
|
|
84
129
|
}
|
|
85
130
|
|
|
131
|
+
// Did the lane vouch for ITSELF? See checkLaneVouching — the top-level verdict
|
|
132
|
+
// is the most editable field on a file the hash cannot cover.
|
|
133
|
+
const vouching = checkLaneVouching(receipt);
|
|
134
|
+
if (!vouching.ok) {
|
|
135
|
+
return { valid: false, reason: `${vouching.detail} (attesting profile: ${profile ?? "unknown"})`, profile, recomputed };
|
|
136
|
+
}
|
|
137
|
+
|
|
86
138
|
return { valid: true, reason: `receipt is valid — PASS, attesting profile: ${profile ?? "unknown"}`, profile, recomputed };
|
|
87
139
|
}
|
|
88
140
|
|
package/template/CLAUDE.md
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
Generated by [create-cmp](https://github.com/kvdm-co-pilot/create-cmp) with a verification
|
|
5
5
|
harness. Every AI session in this repo works under this contract.
|
|
6
6
|
|
|
7
|
+
**Principles** (the full form, with the episode behind each, is create-cmp's
|
|
8
|
+
`docs/PRINCIPLES.md`): derived, never claimed · prove the instrument before you read it · the
|
|
9
|
+
layer you changed cannot certify itself · proof costs what the change costs and never runs
|
|
10
|
+
silent · never wait on nothing · a signature binds content, a decision is closed · one record,
|
|
11
|
+
read first. These govern every rule below; when a rule below and a principle disagree, the
|
|
12
|
+
principle wins and the rule is the bug.
|
|
13
|
+
|
|
7
14
|
## Definition of done
|
|
8
15
|
|
|
9
16
|
Done means `node qa/verify.mjs` reports PASS and the receipt it writes
|
|
@@ -518,6 +525,7 @@ conventions) · [`CONTRIBUTING.md`](./CONTRIBUTING.md) (workflow, Conventional C
|
|
|
518
525
|
| `./gradlew :composeApp:assembleRelease` | Android release build — R8 + `lintVital`, the variant the lane's `releaseBuild` step proves. Produces an **unsigned** APK; signing needs a keystore, which is yours to create and keep out of the repo. |
|
|
519
526
|
| `./gradlew :composeApp:hotRunDesktop --auto` | Desktop dev-client with hot reload |
|
|
520
527
|
| `./gradlew :composeApp:connectedDebugAndroidTest` | Instrumented behavior tests on the attached device (the lane's `androidChecks` step) |
|
|
528
|
+
| `node qa/verify.mjs --profile smoke` | The smallest end-to-end lane: every pure-Node gate, no Gradle, no device — seconds. Proves the framework *returns*, never the change (its receipt is refused as done-evidence). Run it first in any repo whose harness is new or freshly upgraded |
|
|
521
529
|
| `node qa/verify.mjs --profile nightly` | Scheduled stage: everything `ci` proves with the determinism probe forced on. Proves the harness, never a change — its receipt (`stage: "nightly"`) is refused as done-evidence, exactly like `--fast`. Schedule it; never wait on it |
|
|
522
530
|
| `node qa/verify.mjs --profile release` | Ship-time lane: everything `ci` proves plus the audit-cadence report (`auditCadence` — which androidMain subsystems changed since their last recorded `cmp-audit`; a nudge, never a gate) and the release-APK Maestro smoke (`releaseSmoke`) |
|
|
523
531
|
| `node qa/verify.mjs --determinism` | Timezone determinism probe, alone: runs the JVM test tier twice under UTC-12 and UTC+14 and FAILs naming any test whose outcome differs — the dynamic net behind ARCH-13's static one. Opt-in inside a lane via `--profile ci --determinism`; never with `--fast`; writes no receipt on its own |
|
|
@@ -119,7 +119,6 @@ class ArchitectureConformanceTest {
|
|
|
119
119
|
)
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
// SPEC: ARCH-04
|
|
123
122
|
// Component-derived tags (component-system-deep-dive.md §6.4) count as tag provenance:
|
|
124
123
|
// a screen built entirely from registry components (ScreenColumn/AppHeader/
|
|
125
124
|
// ContentStateContainer/…) is automation-reachable through the tags THOSE components
|
|
@@ -138,6 +137,7 @@ class ArchitectureConformanceTest {
|
|
|
138
137
|
return text.contains("testTag") || (importsComponents && screenTagArgument.containsMatchIn(text))
|
|
139
138
|
}
|
|
140
139
|
|
|
140
|
+
// SPEC: ARCH-04
|
|
141
141
|
@Test
|
|
142
142
|
fun `ARCH-04 every feature composable file is automation-reachable - literal testTag or screenTag provenance`() {
|
|
143
143
|
// Scoped by CONTENT (contains @Composable), not by *Screen.kt filename: real apps
|
package/template/qa/approve.mjs
CHANGED
|
@@ -134,7 +134,13 @@ function refuseIfUnresolvable() {
|
|
|
134
134
|
|
|
135
135
|
if (args.includes("--accept-defaults")) {
|
|
136
136
|
refuseIfUnresolvable();
|
|
137
|
-
const
|
|
137
|
+
const expressAsIdx = args.indexOf("--as");
|
|
138
|
+
const expressSigner = expressAsIdx >= 0 ? args[expressAsIdx + 1] : undefined;
|
|
139
|
+
if (!expressSigner || expressSigner.startsWith("--")) {
|
|
140
|
+
console.error('--accept-defaults needs a signer: node qa/approve.mjs --accept-defaults --as "Your Name <you@example.com>"');
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
const { approved, skipped } = approveAllDefaults(ROOT, expressSigner);
|
|
138
144
|
for (const id of approved) {
|
|
139
145
|
console.log(`✓ approved ${id} [defaults-accepted]`);
|
|
140
146
|
}
|
|
@@ -246,7 +252,22 @@ if (args.length === 0) {
|
|
|
246
252
|
refuseIfUnresolvable();
|
|
247
253
|
|
|
248
254
|
const artifactId = args[0];
|
|
249
|
-
|
|
255
|
+
// The signer is REQUIRED, not optional: see approveArtifact's refusal. Parsed
|
|
256
|
+
// here rather than defaulted from git config on purpose — `git config user.name`
|
|
257
|
+
// is whatever the machine says, and an agent running on a developer's laptop
|
|
258
|
+
// would sign with that developer's name. An approval must be typed by whoever
|
|
259
|
+
// is answerable for it.
|
|
260
|
+
const asIndex = args.indexOf("--as");
|
|
261
|
+
const approvedBy = asIndex >= 0 ? args[asIndex + 1] : undefined;
|
|
262
|
+
if (!approvedBy || approvedBy.startsWith("--")) {
|
|
263
|
+
console.error(
|
|
264
|
+
'approve needs a signer: node qa/approve.mjs <artifact> --as "Your Name <you@example.com>"\n' +
|
|
265
|
+
"An approval is a signature on a hash; a row that records no signer cannot tell a human's\n" +
|
|
266
|
+
"sign-off from an agent's.",
|
|
267
|
+
);
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
const result = approveArtifact(ROOT, artifactId, { via: "cli", approvedBy });
|
|
250
271
|
if (!result.ok) {
|
|
251
272
|
console.error(`error: ${result.reason}`);
|
|
252
273
|
process.exit(1);
|
|
@@ -781,6 +781,7 @@ export function resolveArtifactStatus(root, artifact, storedRecord) {
|
|
|
781
781
|
hash: recomputed.hash,
|
|
782
782
|
storedHash: storedRecord.hash ?? null,
|
|
783
783
|
approvedAt: storedRecord.approvedAt ?? null,
|
|
784
|
+
approvedBy: storedRecord.approvedBy ?? null,
|
|
784
785
|
fileCount: recomputed.fileCount,
|
|
785
786
|
missing: recomputed.missing,
|
|
786
787
|
resolvable,
|
|
@@ -801,6 +802,7 @@ export function resolveArtifactStatus(root, artifact, storedRecord) {
|
|
|
801
802
|
hash: recomputed.hash,
|
|
802
803
|
storedHash: null,
|
|
803
804
|
approvedAt: null,
|
|
805
|
+
approvedBy: null,
|
|
804
806
|
fileCount: recomputed.fileCount,
|
|
805
807
|
missing: recomputed.missing,
|
|
806
808
|
resolvable,
|
|
@@ -834,6 +836,10 @@ export function resolveArtifactStatus(root, artifact, storedRecord) {
|
|
|
834
836
|
hash: recomputed.hash,
|
|
835
837
|
storedHash: storedRecord.hash,
|
|
836
838
|
approvedAt: storedRecord.approvedAt,
|
|
839
|
+
// WHO signed. Null on rows written before signers were recorded; the gate
|
|
840
|
+
// treats a signed-by-nobody approval as FAIL, because that row cannot tell
|
|
841
|
+
// a human's sign-off from an agent's.
|
|
842
|
+
approvedBy: storedRecord.approvedBy ?? null,
|
|
837
843
|
fileCount: recomputed.fileCount,
|
|
838
844
|
missing: recomputed.missing,
|
|
839
845
|
resolvable,
|
|
@@ -920,7 +926,23 @@ export function approveArtifact(root, artifactId, options = {}) {
|
|
|
920
926
|
const state = loadApprovals(root);
|
|
921
927
|
const others = state.artifacts.filter((a) => a.artifact !== artifactId);
|
|
922
928
|
const approvedAt = new Date().toISOString();
|
|
923
|
-
|
|
929
|
+
if (!options.approvedBy || !String(options.approvedBy).trim()) {
|
|
930
|
+
return {
|
|
931
|
+
ok: false,
|
|
932
|
+
reason:
|
|
933
|
+
`cannot approve "${artifactId}" — no signer was given. An approval is a person's ` +
|
|
934
|
+
"signature on a hash; a row that records no signer cannot distinguish a human's sign-off " +
|
|
935
|
+
"from an agent's, and an agent that invalidates an approval can clear it by re-approving. " +
|
|
936
|
+
"Pass the signer: `node qa/approve.mjs <artifact> --as \"Name <email>\"`.",
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
const record = {
|
|
940
|
+
artifact: artifactId,
|
|
941
|
+
status: "approved",
|
|
942
|
+
hash: resolved.hash,
|
|
943
|
+
approvedAt,
|
|
944
|
+
approvedBy: String(options.approvedBy).trim(),
|
|
945
|
+
};
|
|
924
946
|
if (options.mode) record.mode = options.mode;
|
|
925
947
|
if (options.via) record.via = options.via;
|
|
926
948
|
others.push(record);
|
|
@@ -929,10 +951,11 @@ export function approveArtifact(root, artifactId, options = {}) {
|
|
|
929
951
|
verb: "approve",
|
|
930
952
|
artifact: artifactId,
|
|
931
953
|
hash: resolved.hash,
|
|
954
|
+
approvedBy: String(options.approvedBy).trim(),
|
|
932
955
|
...(options.via ? { via: options.via } : {}),
|
|
933
956
|
...(options.mode ? { mode: options.mode } : {}),
|
|
934
957
|
});
|
|
935
|
-
return { ok: true, artifact: artifactId, hash: resolved.hash, approvedAt, ...(options.mode ? { mode: options.mode } : {}) };
|
|
958
|
+
return { ok: true, artifact: artifactId, hash: resolved.hash, approvedAt, approvedBy: record.approvedBy, ...(options.mode ? { mode: options.mode } : {}) };
|
|
936
959
|
}
|
|
937
960
|
|
|
938
961
|
/**
|
|
@@ -945,7 +968,7 @@ export function approveArtifact(root, artifactId, options = {}) {
|
|
|
945
968
|
* @param {string} root
|
|
946
969
|
* @returns {{ok: true, approved: string[], skipped: Array<{id: string, reason: string}>}}
|
|
947
970
|
*/
|
|
948
|
-
export function approveAllDefaults(root) {
|
|
971
|
+
export function approveAllDefaults(root, approvedBy) {
|
|
949
972
|
const registry = listGovernedArtifacts(root);
|
|
950
973
|
const state = loadApprovals(root);
|
|
951
974
|
const byId = new Map(state.artifacts.map((a) => [a.artifact, a]));
|
|
@@ -954,7 +977,7 @@ export function approveAllDefaults(root) {
|
|
|
954
977
|
for (const artifact of registry) {
|
|
955
978
|
const live = resolveArtifactStatus(root, artifact, byId.get(artifact.id));
|
|
956
979
|
if (live.status === "approved") continue; // already settled — never overwritten by the express lane
|
|
957
|
-
const result = approveArtifact(root, artifact.id, { mode: "defaults-accepted" });
|
|
980
|
+
const result = approveArtifact(root, artifact.id, { mode: "defaults-accepted", approvedBy });
|
|
958
981
|
if (result.ok) approved.push(artifact.id);
|
|
959
982
|
else skipped.push({ id: artifact.id, reason: result.reason });
|
|
960
983
|
}
|
|
@@ -1143,6 +1166,21 @@ export function evaluateApprovalsGate(root) {
|
|
|
1143
1166
|
const statuses = getApprovalStatuses(root);
|
|
1144
1167
|
const mismatched = statuses.filter((s) => s.status === "changed-since-approval");
|
|
1145
1168
|
const pending = statuses.filter((s) => s.status === "unreviewed" || s.status === "reopened");
|
|
1169
|
+
// An "approved" row with no signer attests nothing about WHO signed, which is
|
|
1170
|
+
// the one fact an approval exists to record. It cannot distinguish a human's
|
|
1171
|
+
// sign-off from an agent's, and an agent that invalidates an approval can
|
|
1172
|
+
// clear it by re-approving — the gate then guards only against accident, not
|
|
1173
|
+
// against the population it is pointed at. Rows written before signers were
|
|
1174
|
+
// recorded land here; the fix is one re-approval each, and the message says so.
|
|
1175
|
+
const unsigned = statuses.filter((s) => s.status === "approved" && !s.approvedBy);
|
|
1176
|
+
|
|
1177
|
+
if (unsigned.length > 0) {
|
|
1178
|
+
const lines = ["Approval recorded without a signer — re-approve to say who signed:"];
|
|
1179
|
+
for (const s of unsigned) {
|
|
1180
|
+
lines.push(` [${s.id}] ${s.label} — approved ${shortHash(s.storedHash)} by nobody. Re-approve: node qa/approve.mjs ${s.id} --as "Your Name <you@example.com>"`);
|
|
1181
|
+
}
|
|
1182
|
+
return { verdict: "FAIL", reason: lines.join("\n"), statuses };
|
|
1183
|
+
}
|
|
1146
1184
|
|
|
1147
1185
|
if (mismatched.length > 0) {
|
|
1148
1186
|
const lines = ["Approval invalidated — a governed artifact changed after sign-off:"];
|
|
@@ -17,8 +17,19 @@ import { createHash } from "node:crypto";
|
|
|
17
17
|
import fs from "node:fs";
|
|
18
18
|
import path from "node:path";
|
|
19
19
|
|
|
20
|
-
// Directories / files
|
|
20
|
+
// Directories / files included in the verified surface (relative to project ROOT).
|
|
21
21
|
// Principle: every tracked file whose content can change the lane's verdict.
|
|
22
|
+
//
|
|
23
|
+
// THIS IS A DEFAULT, NOT A LAW (evidence-economics S8, 2026-09-03). It is the
|
|
24
|
+
// surface of a Compose Multiplatform app, and it used to be hardcoded inside
|
|
25
|
+
// this module — which is the SPINE, shared by every adopter. A repo whose code
|
|
26
|
+
// lives in services/ or src/ that vendored this file had its verified surface
|
|
27
|
+
// silently shrink to whatever happened to match: no error, no failed step, a
|
|
28
|
+
// receipt that still validated and still looked identical, and a hash that had
|
|
29
|
+
// quietly stopped covering the application. A gate that attests less while
|
|
30
|
+
// looking the same is the worst failure this harness can have, so the surface
|
|
31
|
+
// is now resolved per project (see resolveVerifiedSurface) and an empty one is
|
|
32
|
+
// refused rather than hashed.
|
|
22
33
|
export const VERIFIED_SURFACE = [
|
|
23
34
|
"composeApp",
|
|
24
35
|
"specs",
|
|
@@ -147,9 +158,52 @@ function walkAllFiles(dir) {
|
|
|
147
158
|
return out;
|
|
148
159
|
}
|
|
149
160
|
|
|
161
|
+
/** Where a project may declare its own verified surface (see resolveVerifiedSurface). */
|
|
162
|
+
export const SURFACE_CONFIG_REL = "qa/verified-surface.json";
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The surface THIS project attests — its own declaration when it has one, the
|
|
166
|
+
* Compose Multiplatform default otherwise.
|
|
167
|
+
*
|
|
168
|
+
* Read from a file rather than passed as an argument on purpose: qa/verify.mjs
|
|
169
|
+
* (which writes inputs.hash) and qa/receipt-check.mjs (which recomputes it)
|
|
170
|
+
* must never disagree about what was hashed, and two call sites taking a
|
|
171
|
+
* parameter is two places to get it wrong. The file lives under qa/, so it is
|
|
172
|
+
* itself inside the surface — changing the definition invalidates receipts,
|
|
173
|
+
* which is correct: the tree's coverage changed.
|
|
174
|
+
*
|
|
175
|
+
* Shape: {"surface": ["services", "docs", "build-logic", ".github", "qa"]}.
|
|
176
|
+
* Malformed or empty content is REFUSED, never silently defaulted — a project
|
|
177
|
+
* that tried to declare a surface and failed must not fall back to a smaller
|
|
178
|
+
* one behind the operator's back.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} root project root
|
|
181
|
+
* @returns {string[]} surface entries, relative to root
|
|
182
|
+
*/
|
|
183
|
+
export function resolveVerifiedSurface(root) {
|
|
184
|
+
const p = path.join(root, SURFACE_CONFIG_REL);
|
|
185
|
+
let raw;
|
|
186
|
+
try {
|
|
187
|
+
raw = fs.readFileSync(p, "utf8");
|
|
188
|
+
} catch {
|
|
189
|
+
return VERIFIED_SURFACE; // no declaration — the CMP default, unchanged
|
|
190
|
+
}
|
|
191
|
+
let parsed;
|
|
192
|
+
try {
|
|
193
|
+
parsed = JSON.parse(raw);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
throw new Error(`${SURFACE_CONFIG_REL} is not valid JSON (${err.message}) — refusing to hash a surface this project failed to declare.`);
|
|
196
|
+
}
|
|
197
|
+
const list = parsed && Array.isArray(parsed.surface) ? parsed.surface.filter((x) => typeof x === "string" && x.trim() !== "") : null;
|
|
198
|
+
if (!list || list.length === 0) {
|
|
199
|
+
throw new Error(`${SURFACE_CONFIG_REL} declares no surface — expected {"surface": ["dir", …]}. Refusing to hash nothing.`);
|
|
200
|
+
}
|
|
201
|
+
return list;
|
|
202
|
+
}
|
|
203
|
+
|
|
150
204
|
// Resolve the verified surface to a flat, sorted list of paths (relative to
|
|
151
205
|
// root, POSIX-style `/` separators) that currently exist on disk.
|
|
152
|
-
function resolveSurfaceFiles(root) {
|
|
206
|
+
function resolveSurfaceFiles(root, VERIFIED_SURFACE) {
|
|
153
207
|
const gitFiles = tryGitLsFiles(root);
|
|
154
208
|
|
|
155
209
|
if (gitFiles) {
|
|
@@ -189,7 +243,20 @@ export function computeInputsHash(root) {
|
|
|
189
243
|
// on iteration order, and ICU collation varies with the machine's locale
|
|
190
244
|
// (e.g. a da_DK machine orders "aa" after "z"; en orders case-insensitively
|
|
191
245
|
// where code units do not) — the same tree must hash identically everywhere.
|
|
192
|
-
const
|
|
246
|
+
const surface = resolveVerifiedSurface(root);
|
|
247
|
+
const files = [...new Set(resolveSurfaceFiles(root, surface))].sort();
|
|
248
|
+
|
|
249
|
+
// A surface that matches NOTHING is a misconfiguration, not a valid hash.
|
|
250
|
+
// Hashing zero files yields a stable, confident-looking digest that attests
|
|
251
|
+
// the empty set — the silent shrink this whole change exists to prevent, in
|
|
252
|
+
// its most extreme form. Refuse, and name what was looked for.
|
|
253
|
+
if (files.length === 0) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`the verified surface matched no files under ${root} — nothing would be attested. ` +
|
|
256
|
+
`Surface: ${surface.join(", ")}. ` +
|
|
257
|
+
`A project whose code lives elsewhere declares its own in ${SURFACE_CONFIG_REL}: {"surface": ["services", "qa", …]}.`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
193
260
|
|
|
194
261
|
const overall = createHash("sha256");
|
|
195
262
|
for (const relPath of files) {
|
|
@@ -45,6 +45,51 @@ export function readReceipt(root, relPath = RECEIPT_REL_PATH) {
|
|
|
45
45
|
* FAIL verdict), so callers don't pay for a hash they don't need.
|
|
46
46
|
* @returns {{valid: boolean, reason: string, profile: (string|undefined), recomputed?: {hash: string, fileCount: number}}}
|
|
47
47
|
*/
|
|
48
|
+
/**
|
|
49
|
+
* Does this receipt's own row-level evidence support its PASS?
|
|
50
|
+
*
|
|
51
|
+
* The receipt is necessarily excluded from the inputs hash it carries — a file
|
|
52
|
+
* cannot hash itself — so steps[] is the only thing between this gate and a text
|
|
53
|
+
* editor, and the top-level verdict is the most editable field on it.
|
|
54
|
+
*
|
|
55
|
+
* Two failures this catches, both observed downstream (payment-blueprint F2/F3):
|
|
56
|
+
* a receipt whose verdict was hand-edited from FAIL to PASS while its rows still
|
|
57
|
+
* said otherwise, and a lane made green by DELETING harness.lock.json, which
|
|
58
|
+
* downgraded harnessIntegrity from FAIL to SKIP and took the lane's verdict with
|
|
59
|
+
* it — a lane vouching for a tree with nothing vouching for the lane.
|
|
60
|
+
*
|
|
61
|
+
* @param {{verdict?: string, steps?: Array<{name?: string, verdict?: string}>}} receipt
|
|
62
|
+
* @returns {{ok: boolean, detail: string}}
|
|
63
|
+
*/
|
|
64
|
+
export function checkLaneVouching(receipt) {
|
|
65
|
+
const steps = Array.isArray(receipt?.steps) ? receipt.steps : null;
|
|
66
|
+
if (!steps || steps.length === 0) {
|
|
67
|
+
return { ok: false, detail: "receipt lists no verify-lane steps — a PASS over nothing attests nothing" };
|
|
68
|
+
}
|
|
69
|
+
const failed = steps.filter((s) => s && (s.verdict === "FAIL" || s.verdict === "ERROR"));
|
|
70
|
+
if (failed.length > 0) {
|
|
71
|
+
const names = failed.map((s) => `${s.name ?? "?"} (${s.verdict})`).join(", ");
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
detail: `the receipt's verdict is PASS but ${failed.length} step(s) did not pass: ${names} — the row is the more specific truth`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const integrity = steps.find((s) => s && s.name === "harnessIntegrity");
|
|
78
|
+
if (!integrity) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
detail: "receipt has no harnessIntegrity row — nothing vouches that the lane's own code is the code that ran",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (integrity.verdict !== "PASS") {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
detail: `harnessIntegrity is ${integrity.verdict}, not PASS — the lane did not vouch for itself, so its PASS over the tree cannot be trusted`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, detail: "lane vouched for itself (harnessIntegrity PASS, no failing rows)" };
|
|
91
|
+
}
|
|
92
|
+
|
|
48
93
|
export function evaluateReceipt(receipt, recompute) {
|
|
49
94
|
const profile = receipt.profile;
|
|
50
95
|
|
|
@@ -83,6 +128,13 @@ export function evaluateReceipt(receipt, recompute) {
|
|
|
83
128
|
};
|
|
84
129
|
}
|
|
85
130
|
|
|
131
|
+
// Did the lane vouch for ITSELF? See checkLaneVouching — the top-level verdict
|
|
132
|
+
// is the most editable field on a file the hash cannot cover.
|
|
133
|
+
const vouching = checkLaneVouching(receipt);
|
|
134
|
+
if (!vouching.ok) {
|
|
135
|
+
return { valid: false, reason: `${vouching.detail} (attesting profile: ${profile ?? "unknown"})`, profile, recomputed };
|
|
136
|
+
}
|
|
137
|
+
|
|
86
138
|
return { valid: true, reason: `receipt is valid — PASS, attesting profile: ${profile ?? "unknown"}`, profile, recomputed };
|
|
87
139
|
}
|
|
88
140
|
|
|
@@ -30,6 +30,78 @@ export const TIERS_SATISFYING = Object.freeze({
|
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
const TAG_LINE_RE = /^(?:\/\/|#)\s*SPEC:/;
|
|
33
|
+
|
|
34
|
+
// A citation is a claim that a TEST covers a clause, so it has to sit on one.
|
|
35
|
+
// Counting the tag wherever it appears makes a red specCoverage curable with a
|
|
36
|
+
// comment and zero assertions — the one escape this gate exists to close. It is
|
|
37
|
+
// not hypothetical: payment-blueprint hit a citation that had drifted onto a
|
|
38
|
+
// class declaration, where it counted for the whole file while testing nothing.
|
|
39
|
+
//
|
|
40
|
+
// So a tag counts only when a test declaration follows it within
|
|
41
|
+
// BINDING_WINDOW non-blank lines. The window is small enough that the tag must
|
|
42
|
+
// be attached to the test, and loose enough for the @DisplayName / annotation
|
|
43
|
+
// stack that idiomatically sits between them.
|
|
44
|
+
export const BINDING_WINDOW = 5;
|
|
45
|
+
|
|
46
|
+
// Kotlin @Test, a backticked test function, and the node:test / Maestro-adjacent
|
|
47
|
+
// `test(` / `it(` call forms. Deliberately syntactic: a citation's binding must
|
|
48
|
+
// be readable without compiling anything.
|
|
49
|
+
const TEST_DECL_RE = /@Test\b|\bfun\s+`[^`]+`\s*\(|\b(?:test|it)\s*\(/;
|
|
50
|
+
|
|
51
|
+
// A tag whose first meaningful line declares a TYPE is documenting that type,
|
|
52
|
+
// not claiming a test — and it must be refused structurally rather than by
|
|
53
|
+
// distance, because a short class body puts a real @Test inside the window and
|
|
54
|
+
// would otherwise launder the citation. This is exactly payment-blueprint's
|
|
55
|
+
// drift: `// SPEC: PP-07` sat on `class PaymentWorkerTest`, three properties
|
|
56
|
+
// above a genuine @Test, and vouched for the whole file.
|
|
57
|
+
const TYPE_DECL_RE = /^(?:@\w+\s+)*(?:public\s+|internal\s+|private\s+|abstract\s+|open\s+|sealed\s+|data\s+|enum\s+)*(?:class|object|interface)\b/;
|
|
58
|
+
|
|
59
|
+
// A YAML flow's own shape counts as its test: a Maestro file IS the test, so a
|
|
60
|
+
// tag in one binds to the flow rather than to a declaration inside it.
|
|
61
|
+
const FLOW_EXTS = [".yaml", ".yml"];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Does a test declaration follow `index` within BINDING_WINDOW non-blank lines,
|
|
65
|
+
* skipping block-comment bodies (a tag inside one is documentation, not a claim)?
|
|
66
|
+
* @param {string[]} lines
|
|
67
|
+
* @param {number} index line the tag sits on
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
export function citationIsBound(lines, index) {
|
|
71
|
+
let seen = 0;
|
|
72
|
+
let inBlockComment = false;
|
|
73
|
+
for (let i = index + 1; i < lines.length && seen < BINDING_WINDOW; i += 1) {
|
|
74
|
+
const line = lines[i].trim();
|
|
75
|
+
if (line === "") continue;
|
|
76
|
+
if (inBlockComment) {
|
|
77
|
+
if (line.includes("*/")) inBlockComment = false;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (line.startsWith("/*")) {
|
|
81
|
+
if (!line.includes("*/")) inBlockComment = true;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (line.startsWith("//") || line.startsWith("*")) continue;
|
|
85
|
+
seen += 1;
|
|
86
|
+
// The FIRST meaningful line decides whether this tag is on a test at all.
|
|
87
|
+
if (seen === 1 && TYPE_DECL_RE.test(line)) return false;
|
|
88
|
+
if (TEST_DECL_RE.test(line)) return true;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Is this tag inside a block comment that began earlier in the file? */
|
|
94
|
+
function insideBlockComment(lines, index) {
|
|
95
|
+
let open = false;
|
|
96
|
+
for (let i = 0; i < index; i += 1) {
|
|
97
|
+
const line = lines[i];
|
|
98
|
+
for (let c = 0; c < line.length - 1; c += 1) {
|
|
99
|
+
if (!open && line[c] === "/" && line[c + 1] === "*") open = true;
|
|
100
|
+
else if (open && line[c] === "*" && line[c + 1] === "/") open = false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return open;
|
|
104
|
+
}
|
|
33
105
|
const TAG_IDS_RE = /SPEC:\s*([A-Z0-9,\s-]+)/;
|
|
34
106
|
const CLAUSE_ID_RE = /^[A-Z][A-Z0-9]*-\d{2,}$/;
|
|
35
107
|
|
|
@@ -105,11 +177,14 @@ export function scanCitations(root) {
|
|
|
105
177
|
const tier = tierForFile(rel);
|
|
106
178
|
fs.readFileSync(f, "utf8")
|
|
107
179
|
.split("\n")
|
|
108
|
-
.forEach((line, i) => {
|
|
180
|
+
.forEach((line, i, lines) => {
|
|
109
181
|
const trimmed = line.trim();
|
|
110
182
|
if (!TAG_LINE_RE.test(trimmed)) return;
|
|
111
183
|
const m = trimmed.match(TAG_IDS_RE);
|
|
112
184
|
if (!m) return;
|
|
185
|
+
// A flow file IS its test; anything else must have a test under the tag.
|
|
186
|
+
const isFlow = FLOW_EXTS.some((ext) => rel.endsWith(ext));
|
|
187
|
+
if (!isFlow && (insideBlockComment(lines, i) || !citationIsBound(lines, i))) return;
|
|
113
188
|
const ids = m[1]
|
|
114
189
|
.split(/[,\s]+/)
|
|
115
190
|
.map((s) => s.trim())
|
|
@@ -1177,6 +1177,15 @@ const stepsForProfile = {
|
|
|
1177
1177
|
// scaffold: what `create-cmp --verify` proves at stamp time — specCoverage,
|
|
1178
1178
|
// the full JVM tier (unit + conformance + golden + UI tests) plus the Android build.
|
|
1179
1179
|
scaffold: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepArchDocMemo, stepSchemaHistory, stepBuild, stepUnitTests],
|
|
1180
|
+
// smoke (docs/GATE-RULES.md Rule 0, docs/PRINCIPLES.md #2): the smallest
|
|
1181
|
+
// end-to-end lane — every pure-Node step through the REAL runner, marker,
|
|
1182
|
+
// receipt and journal, and NO Gradle, no device, no network. Its job is to
|
|
1183
|
+
// prove the framework RETURNS, fast, in both directions, before any real
|
|
1184
|
+
// work is pointed at it. scripts/framework-check.mjs drives it: PASS on a
|
|
1185
|
+
// fresh scaffold, then FAIL BY NAME on one planted spec edit, each bounded
|
|
1186
|
+
// in seconds. Its receipt is refused as done-evidence (qa/receipt-check.mjs)
|
|
1187
|
+
// exactly like --fast: it proves the instrument, never the change.
|
|
1188
|
+
smoke: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepArchDocMemo, stepSchemaHistory],
|
|
1180
1189
|
local: [
|
|
1181
1190
|
// First, always: every verdict below is only worth what the lane issuing
|
|
1182
1191
|
// it is worth.
|
package/template/qa/lib/walk.mjs
CHANGED
|
@@ -512,7 +512,7 @@ export function renderInject(data) {
|
|
|
512
512
|
for (const a of arrivals)
|
|
513
513
|
parts.push(`▲ ARRIVED, UNPLANNED — ${a.label} (${a.status}): ${a.reason ?? "no recorded reason"}. Offer: handle now, or after the current walk lands (recommended: after).`);
|
|
514
514
|
parts.push(
|
|
515
|
-
"Protocol: open every reply with the chat header line above, verbatim. Speak stages as Decide·Design·Contract·Build·Prove·Sign-off — with their plain-words gloss on first mention — and clauses as promises. Declare the chain at kickoff and advance it as you go; if the studio line above says DOWN or not running, restore it (preview tool) or surface it before proceeding. Quiet between headers (one line per stage transition). At any human gate, render the full stop card: stage, what it is in plain words, then the easiest act first (the studio console when it is up; the CLI as fallback), then what comes after. Quote the lane's cost only from the measured figure in the card — never estimate it. Never open a second walk silently.",
|
|
515
|
+
"Protocol: open every reply with the chat header line above, verbatim. Speak stages as Decide·Design·Contract·Build·Prove·Sign-off — with their plain-words gloss on first mention — and clauses as promises. Declare the chain at kickoff and advance it as you go; if the studio line above says DOWN or not running, restore it (preview tool) or surface it before proceeding. Quiet between headers (one line per stage transition). At any human gate, render the full stop card: stage, what it is in plain words, then the easiest act first (the studio console when it is up; the CLI as fallback), then what comes after. Quote the lane's cost only from the measured figure in the card — never estimate it. Never open a second walk silently. Principles that bind THIS turn (docs/PRINCIPLES.md): derived, never claimed — name the command behind any claim; the layer you changed cannot certify itself — run its consumers, stamp a fresh app for a template or harness change; never wait on nothing — every wait is bounded, and if you are blocked, say on what and stop.",
|
|
516
516
|
);
|
|
517
517
|
return parts.join("\n\n");
|
|
518
518
|
}
|
|
@@ -99,7 +99,30 @@ function evaluate() {
|
|
|
99
99
|
profile: receipt.profile,
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
|
-
|
|
102
|
+
// smoke (GATE-RULES Rule 0) runs no Gradle: it proves the framework returns,
|
|
103
|
+
// never that the change is good. Refused like --fast, for the same reason.
|
|
104
|
+
if (receipt.stage === "smoke" || receipt.profile === "smoke") {
|
|
105
|
+
return {
|
|
106
|
+
valid: false,
|
|
107
|
+
reason: "the last verify run was the smoke profile (the framework check — no build, no tests; it proves the instrument, not this change); run the change-stage lane (`node qa/verify.mjs`) before finishing",
|
|
108
|
+
profile: receipt.profile,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// A surface this project cannot resolve is a REFUSAL with an explanation,
|
|
112
|
+
// never an unhandled stack trace: this runs as the Stop hook on every turn
|
|
113
|
+
// end, and a crash there reads as a broken harness rather than as the
|
|
114
|
+
// misconfiguration it is. (evidence-economics S8 follow-up: computeInputsHash
|
|
115
|
+
// now throws rather than returning a confident hash of the empty set.)
|
|
116
|
+
let result;
|
|
117
|
+
try {
|
|
118
|
+
result = evaluateReceipt(receipt, () => computeInputsHash(ROOT));
|
|
119
|
+
} catch (err) {
|
|
120
|
+
return {
|
|
121
|
+
valid: false,
|
|
122
|
+
reason: `cannot verify this receipt — ${err && err.message ? err.message : String(err)}`,
|
|
123
|
+
profile: receipt.profile,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
103
126
|
// Surface the receipt's evidence rung (the ladder — qa/lib/evidence-level.mjs)
|
|
104
127
|
// alongside the verdict: the rung is the receipt's own derived field, read
|
|
105
128
|
// verbatim, never recomputed here. Older receipts without it stay valid.
|
package/template/qa/verify.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// The verify lane — this project's single verification gate.
|
|
3
3
|
//
|
|
4
|
-
// node qa/verify.mjs [--profile scaffold|local|ci|nightly|release] [--fast] [--json]
|
|
4
|
+
// node qa/verify.mjs [--profile smoke|scaffold|local|ci|nightly|release] [--fast] [--json]
|
|
5
5
|
//
|
|
6
6
|
// Runs every verification step this project carries, aggregates a typed
|
|
7
7
|
// PASS/FAIL verdict, and writes the evidence receipt to qa/evidence/latest.json.
|
|
@@ -51,7 +51,7 @@ const ARTIFACTS_DIR = path.join(ROOT, "qa-artifacts");
|
|
|
51
51
|
// killed). Same refusal-over-fabrication stance as qa/approve.mjs, which
|
|
52
52
|
// refuses an unknown artifact by name rather than guessing: an unknown
|
|
53
53
|
// argument here is refused by name, not swallowed into "run everything".
|
|
54
|
-
const USAGE = `node qa/verify.mjs [--profile scaffold|local|ci|nightly|release] [--fast] [--json] [--help]
|
|
54
|
+
const USAGE = `node qa/verify.mjs [--profile smoke|scaffold|local|ci|nightly|release] [--fast] [--json] [--help]
|
|
55
55
|
|
|
56
56
|
The verify lane — this project's single verification gate. Runs every
|
|
57
57
|
verification step this project carries, aggregates a typed PASS/FAIL
|
|
@@ -59,7 +59,7 @@ verdict, and writes the evidence receipt to qa/evidence/latest.json (commit
|
|
|
59
59
|
it with your change — see CLAUDE.md). Exit code: 0 = PASS, 1 = FAIL.
|
|
60
60
|
|
|
61
61
|
Flags:
|
|
62
|
-
--profile <scaffold|local|ci|nightly|release>
|
|
62
|
+
--profile <smoke|scaffold|local|ci|nightly|release>
|
|
63
63
|
which step set to run (default: local)
|
|
64
64
|
--fast INNER LOOP ONLY — run the resolved profile
|
|
65
65
|
minus the device/release tier (releaseBuild,
|
|
@@ -101,6 +101,10 @@ Flags:
|
|
|
101
101
|
running anything
|
|
102
102
|
|
|
103
103
|
Profiles:
|
|
104
|
+
smoke the smallest end-to-end lane: every pure-Node gate through the real
|
|
105
|
+
runner, receipt and journal — no Gradle, no device. Seconds. Proves the
|
|
106
|
+
FRAMEWORK returns, both ways; never the change (its receipt is refused
|
|
107
|
+
as done-evidence). Driven by scripts/framework-check.mjs.
|
|
104
108
|
scaffold spec coverage + build + unit tests (what \`create-cmp --verify\`
|
|
105
109
|
proves at stamp time)
|
|
106
110
|
local everything; device-dependent steps SKIP when no device is
|
|
@@ -321,7 +325,7 @@ const { stepsForProfile, DEVICE_STEPS, FAST_EXCLUDED_NAMES, STEP_FN_BY_NAME } =
|
|
|
321
325
|
|
|
322
326
|
|
|
323
327
|
if (!stepsForProfile[profile]) {
|
|
324
|
-
console.error(`Unknown profile "${profile}" — use scaffold | local | ci | nightly | release.`);
|
|
328
|
+
console.error(`Unknown profile "${profile}" — use smoke | scaffold | local | ci | nightly | release.`);
|
|
325
329
|
process.exit(2);
|
|
326
330
|
}
|
|
327
331
|
|
|
@@ -499,7 +503,7 @@ const inputs = computeInputsHash(ROOT);
|
|
|
499
503
|
// more than its stage allows. scaffold → scaffold, local → change (per commit),
|
|
500
504
|
// ci → merge, nightly → nightly (proves the harness, never a change), release →
|
|
501
505
|
// release. Receipts predating this field are read as their profile's stage.
|
|
502
|
-
const STAGE_OF_PROFILE = { scaffold: "scaffold", local: "change", ci: "merge", nightly: "nightly", release: "release" };
|
|
506
|
+
const STAGE_OF_PROFILE = { smoke: "smoke", scaffold: "scaffold", local: "change", ci: "merge", nightly: "nightly", release: "release" };
|
|
503
507
|
const receipt = {
|
|
504
508
|
schema: "cmp-evidence/1",
|
|
505
509
|
profile,
|