create-cmp-cli 0.22.0 → 0.24.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/package.json +1 -1
- package/packages/harness/src/approve.mjs +37 -8
- package/packages/harness/src/framework-check.mjs +513 -0
- package/packages/harness/src/lib/agent-hold.mjs +234 -0
- package/packages/harness/src/lib/evidence-badge.mjs +4 -4
- package/packages/harness/src/lib/framework-check.mjs +397 -0
- package/packages/harness/src/lib/harness-region.mjs +50 -2
- package/packages/harness/src/lib/inputs-hash.mjs +5 -0
- package/packages/harness/src/lib/steps-cmp.mjs +1 -1
- package/packages/harness/src/plan.mjs +30 -1
- package/packages/harness/src/receipt-check.mjs +15 -2
- package/packages/harness/src/verify.mjs +1 -1
- package/packages/receipts/src/inputs-hash.mjs +5 -0
- package/template/gitignore +1 -0
- package/template/qa/approve.mjs +37 -8
- package/template/qa/framework-check.mjs +513 -0
- package/template/qa/lib/agent-hold.mjs +234 -0
- package/template/qa/lib/evidence-badge.mjs +4 -4
- package/template/qa/lib/framework-check.mjs +397 -0
- package/template/qa/lib/harness-region.mjs +50 -2
- package/template/qa/lib/inputs-hash.mjs +5 -0
- package/template/qa/lib/steps-cmp.mjs +1 -1
- package/template/qa/plan.mjs +30 -1
- package/template/qa/receipt-check.mjs +15 -2
- package/template/qa/verify.mjs +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-cmp-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@create-cmp/harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "The create-cmp verify lane — the machine-owned harness code every stamped app carries byte-identical: evidence receipts, spec coverage, approvals, conformance reporting, golden trees, a11y, and the preview/inspector libs. Dependency-free ESM, vendored into each generated project so the lane runs offline with no install step, and content-hashed so a receipt can name the exact lane that issued it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/verify.mjs",
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// The approvals CLI — thin shell over qa/lib/approvals.mjs.
|
|
3
3
|
//
|
|
4
|
-
// node qa/approve.mjs <artifact>
|
|
5
|
-
//
|
|
4
|
+
// node qa/approve.mjs <artifact> [<artifact> …]
|
|
5
|
+
// records approval (recomputes each artifact's
|
|
6
|
+
// hash now, stamps the time, writes qa/approvals.json).
|
|
7
|
+
// Sign every pending artifact in ONE command: each
|
|
8
|
+
// signature moves the receipt's input hash, so N
|
|
9
|
+
// separate signings cost N lane re-runs.
|
|
6
10
|
// node qa/approve.mjs --status lists every governed artifact + live state
|
|
7
11
|
// (unreviewed / approved / changed-since-approval /
|
|
8
12
|
// reopened, + mode when set) + short hash
|
|
@@ -251,7 +255,27 @@ if (args.length === 0) {
|
|
|
251
255
|
|
|
252
256
|
refuseIfUnresolvable();
|
|
253
257
|
|
|
254
|
-
|
|
258
|
+
// EVERY artifact named in one command — one write, one invalidation.
|
|
259
|
+
//
|
|
260
|
+
// A signature changes qa/approvals.json's `status`, which the approvals gate
|
|
261
|
+
// reads, so it legitimately moves the receipt's input hash. Signed one at a
|
|
262
|
+
// time AFTER a green lane, that means N signatures invalidate the receipt N
|
|
263
|
+
// times and cost N lane re-runs and N bookkeeping commits. Measured in
|
|
264
|
+
// payment-blueprint's log on 2026-09-04: of 21 commits in one session, 8 were
|
|
265
|
+
// "sign the approval" / "bind the receipt" with no product content, and every
|
|
266
|
+
// code change cost two to three commits. It compounds as a repo accumulates
|
|
267
|
+
// governed artifacts, which is what makes a session start fast and grind later.
|
|
268
|
+
//
|
|
269
|
+
// Two halves to the fix: sign them together (here), and sign BEFORE the final
|
|
270
|
+
// lane run so the receipt you keep is already the one that covers the
|
|
271
|
+
// signatures. `--reopen-feature` established the idiom — one recorded change,
|
|
272
|
+
// not N commands.
|
|
273
|
+
const artifactIds = args.filter((a, i) => !a.startsWith("--") && (i === 0 || !args[i - 1].startsWith("--")));
|
|
274
|
+
// An unrecognised flag must still be REFUSED by name, never silently skipped:
|
|
275
|
+
// filtering flags out is how `node qa/approve.mjs --delivr meal` would quietly
|
|
276
|
+
// approve nothing and exit 0. Falling back to the first argument reproduces the
|
|
277
|
+
// exact refusal an unknown verb has always produced.
|
|
278
|
+
if (artifactIds.length === 0) artifactIds.push(args[0]);
|
|
255
279
|
// The signer is REQUIRED, not optional: see approveArtifact's refusal. Parsed
|
|
256
280
|
// here rather than defaulted from git config on purpose — `git config user.name`
|
|
257
281
|
// is whatever the machine says, and an agent running on a developer's laptop
|
|
@@ -267,9 +291,14 @@ if (!approvedBy || approvedBy.startsWith("--")) {
|
|
|
267
291
|
);
|
|
268
292
|
process.exit(1);
|
|
269
293
|
}
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
294
|
+
const results = artifactIds.map((id) => ({ id, result: approveArtifact(ROOT, id, { via: "cli", approvedBy }) }));
|
|
295
|
+
const failed = results.filter((r) => !r.result.ok);
|
|
296
|
+
for (const { id, result } of results) {
|
|
297
|
+
if (result.ok) console.log(`✓ approved ${result.artifact} — hash ${shortHash(result.hash)}, at ${result.approvedAt}`);
|
|
298
|
+
else console.error(`error: ${id}: ${result.reason}`);
|
|
299
|
+
}
|
|
300
|
+
if (results.length > 1) {
|
|
301
|
+
const signed = results.length - failed.length;
|
|
302
|
+
console.log(`\n${signed} signature${signed === 1 ? "" : "s"} in one write. Run the lane AFTER signing, not before — a signature moves the receipt's input hash, so signing after a green run costs you that run.`);
|
|
274
303
|
}
|
|
275
|
-
|
|
304
|
+
if (failed.length) process.exit(1);
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// framework-check.mjs — GATE-RULES Rule 0, in THIS app's own tree.
|
|
3
|
+
//
|
|
4
|
+
// node qa/framework-check.mjs [--bound-ms 10000] [--budget-ms 5000] [--json]
|
|
5
|
+
//
|
|
6
|
+
// Before you point real work at this harness, prove the FRAMEWORK returns: a
|
|
7
|
+
// deterministic PASS and a deterministic FAIL, fast, through the real lane
|
|
8
|
+
// machinery (runner, receipt, Stop hook), with a bound short enough that a hang
|
|
9
|
+
// is obvious rather than patient. No Gradle, no device, no network — `--profile
|
|
10
|
+
// smoke` is every pure-Node gate and nothing else.
|
|
11
|
+
//
|
|
12
|
+
// WHEN TO RUN IT. Whenever you are about to add a gate, and whenever the lane
|
|
13
|
+
// starts behaving oddly. It is also the answer to a question that costs hours
|
|
14
|
+
// when it is answered by hand: *how do I prove this new gate actually catches
|
|
15
|
+
// what it claims?* Add a plant here and run this — seconds — instead of
|
|
16
|
+
// planting a defect, running the full build, reading the red, reverting, and
|
|
17
|
+
// building again. That hand cycle is 30–60 s on a real project, it is paid on
|
|
18
|
+
// every plant forever, and while it happens it is indistinguishable from
|
|
19
|
+
// progress. GATE-RULES Rule 1 calls a calibration "four steps, seconds each";
|
|
20
|
+
// this is the instrument that makes that true, and it reports its own cost so
|
|
21
|
+
// you can see when it stops being true.
|
|
22
|
+
//
|
|
23
|
+
// WHAT IT ASSERTS. The smoke lane returns PASS on this tree; each planted
|
|
24
|
+
// violation makes the lane FAIL and the responsible gate name what it caught;
|
|
25
|
+
// the Stop hook refuses a FAIL receipt and refuses a forged one; and after every
|
|
26
|
+
// plant is reverted the lane returns PASS again — so the plants were the only
|
|
27
|
+
// cause. A direction that does not return inside the bound is KILLED and
|
|
28
|
+
// reported as a hang. The bound is the assertion, not a courtesy timeout.
|
|
29
|
+
//
|
|
30
|
+
// SAFETY. This edits your real tree and puts it back. Every file it may touch is
|
|
31
|
+
// read into memory first and restored in a `finally`, and on SIGINT/SIGTERM
|
|
32
|
+
// too. It refuses to start if any of those files already has uncommitted
|
|
33
|
+
// changes, because a crash mid-plant must never be able to lose your work.
|
|
34
|
+
//
|
|
35
|
+
// Which plants are possible is DERIVED from the tree (qa/lib/framework-check.mjs).
|
|
36
|
+
// A project with no specs, no flows, or no Kotlin tests still gets the region
|
|
37
|
+
// plants; each unavailable plant is reported WITH ITS REASON. Nothing here ever
|
|
38
|
+
// prints PASS because it found nothing to do.
|
|
39
|
+
|
|
40
|
+
import { spawnSync } from "node:child_process";
|
|
41
|
+
import fs from "node:fs";
|
|
42
|
+
import path from "node:path";
|
|
43
|
+
import { fileURLToPath } from "node:url";
|
|
44
|
+
|
|
45
|
+
import {
|
|
46
|
+
DEFAULT_BOUND_MS,
|
|
47
|
+
CALIBRATION_BUDGET_MS,
|
|
48
|
+
PLANT_KINDS,
|
|
49
|
+
selectPlants,
|
|
50
|
+
assessCoverage,
|
|
51
|
+
assessPlantRun,
|
|
52
|
+
assessGreenRun,
|
|
53
|
+
assessCalibrationCost,
|
|
54
|
+
} from "./lib/framework-check.mjs";
|
|
55
|
+
import { listHarnessFiles } from "./lib/harness-region.mjs";
|
|
56
|
+
import { listFlowFiles, scanCitations, walkFiles, DESKTOP_TIERS } from "./lib/spec-coverage.mjs";
|
|
57
|
+
|
|
58
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
59
|
+
const RECEIPT_REL = "qa/evidence/latest.json";
|
|
60
|
+
const SURFACE_REL = "qa/verified-surface.json";
|
|
61
|
+
const README_REL = "README.md";
|
|
62
|
+
const PLANTED_TEST_BASENAME = "CmpFrameworkCheckPlanted.kt";
|
|
63
|
+
|
|
64
|
+
const argv = process.argv.slice(2);
|
|
65
|
+
const flag = (n) => argv.includes(n);
|
|
66
|
+
const opt = (n, d) => (argv.includes(n) ? argv[argv.indexOf(n) + 1] : d);
|
|
67
|
+
|
|
68
|
+
if (flag("--help") || flag("-h")) {
|
|
69
|
+
console.log(
|
|
70
|
+
`node qa/framework-check.mjs [--bound-ms <ms>] [--budget-ms <ms>] [--json]\n` +
|
|
71
|
+
` Proves this app's lane returns a fast deterministic PASS and FAIL through the smoke profile.\n` +
|
|
72
|
+
` --bound-ms per-direction hang bound (default ${DEFAULT_BOUND_MS})\n` +
|
|
73
|
+
` --budget-ms per-cycle calibration budget; a slower cycle is reported (default ${CALIBRATION_BUDGET_MS})\n` +
|
|
74
|
+
` --json print the report as JSON`,
|
|
75
|
+
);
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const BOUND_MS = Number.parseInt(opt("--bound-ms", String(DEFAULT_BOUND_MS)), 10);
|
|
80
|
+
const BUDGET_MS = Number.parseInt(opt("--budget-ms", String(CALIBRATION_BUDGET_MS)), 10);
|
|
81
|
+
const asJson = flag("--json");
|
|
82
|
+
|
|
83
|
+
const t = () => Date.now();
|
|
84
|
+
const out = (s) => {
|
|
85
|
+
if (!asJson) process.stdout.write(`${s}\n`);
|
|
86
|
+
};
|
|
87
|
+
const abs = (rel) => path.join(ROOT, ...rel.split("/"));
|
|
88
|
+
|
|
89
|
+
function die(msg) {
|
|
90
|
+
restoreAll();
|
|
91
|
+
if (asJson) process.stdout.write(`${JSON.stringify({ verdict: "FAIL", reason: msg }, null, 2)}\n`);
|
|
92
|
+
else process.stderr.write(`\nframework check: FAIL — ${msg}\n`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Restore ledger ──────────────────────────────────────────────────────────
|
|
97
|
+
// Captured BEFORE anything is written; replayed in a finally and on a signal.
|
|
98
|
+
// `null` means the file did not exist and must be removed again.
|
|
99
|
+
|
|
100
|
+
/** @type {Map<string, string|null>} */
|
|
101
|
+
const original = new Map();
|
|
102
|
+
/** Directories this run created, deepest first — removed if left empty. */
|
|
103
|
+
const createdDirs = new Set();
|
|
104
|
+
|
|
105
|
+
function remember(rel) {
|
|
106
|
+
if (original.has(rel)) return;
|
|
107
|
+
const p = abs(rel);
|
|
108
|
+
original.set(rel, fs.existsSync(p) ? fs.readFileSync(p, "utf8") : null);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function write(rel, text) {
|
|
112
|
+
remember(rel);
|
|
113
|
+
const p = abs(rel);
|
|
114
|
+
const dir = path.dirname(p);
|
|
115
|
+
if (!fs.existsSync(dir)) {
|
|
116
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
117
|
+
createdDirs.add(dir);
|
|
118
|
+
}
|
|
119
|
+
fs.writeFileSync(p, text);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Repeatable on purpose, not one-shot. The last thing this check does is run the
|
|
123
|
+
// lane once more to prove the revert worked — and that run writes a receipt and
|
|
124
|
+
// refreshes the README badge. A smoke receipt is refused as done-evidence, so
|
|
125
|
+
// leaving it in place would CLOBBER whatever real L1/L2 receipt the tree had
|
|
126
|
+
// with one that proves nothing. Restoring again afterwards is what makes this
|
|
127
|
+
// instrument safe to run on a working tree mid-change.
|
|
128
|
+
function restoreAll() {
|
|
129
|
+
for (const [rel, text] of original) {
|
|
130
|
+
const p = abs(rel);
|
|
131
|
+
try {
|
|
132
|
+
if (text === null) fs.rmSync(p, { force: true });
|
|
133
|
+
else fs.writeFileSync(p, text);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
process.stderr.write(`framework check: COULD NOT RESTORE ${rel} — ${err.message}\n`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
for (const dir of [...createdDirs].sort((a, b) => b.length - a.length)) {
|
|
139
|
+
try {
|
|
140
|
+
if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
|
|
141
|
+
} catch {
|
|
142
|
+
/* not empty, or already gone — either way not ours to force */
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
148
|
+
process.on(sig, () => {
|
|
149
|
+
restoreAll();
|
|
150
|
+
process.exit(130);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Lane runs ───────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
function runSmoke() {
|
|
157
|
+
const started = t();
|
|
158
|
+
const res = spawnSync(process.execPath, [abs("qa/verify.mjs"), "--profile", "smoke", "--json", "--no-journal"], {
|
|
159
|
+
cwd: ROOT,
|
|
160
|
+
encoding: "utf8",
|
|
161
|
+
env: { ...process.env },
|
|
162
|
+
timeout: BOUND_MS,
|
|
163
|
+
killSignal: "SIGKILL",
|
|
164
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
165
|
+
});
|
|
166
|
+
const ms = t() - started;
|
|
167
|
+
if (res.error && res.error.code === "ETIMEDOUT") return { hung: true, ms };
|
|
168
|
+
let receipt = null;
|
|
169
|
+
try {
|
|
170
|
+
const text = res.stdout ?? "";
|
|
171
|
+
receipt = JSON.parse(text.slice(text.indexOf("{")));
|
|
172
|
+
} catch {
|
|
173
|
+
/* reported by the assessor */
|
|
174
|
+
}
|
|
175
|
+
return { hung: false, ms, exit: res.status, receipt, stderr: res.stderr ?? "" };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function hookRefuses() {
|
|
179
|
+
const res = spawnSync(process.execPath, [abs("qa/receipt-check.mjs"), "--hook"], {
|
|
180
|
+
cwd: ROOT,
|
|
181
|
+
encoding: "utf8",
|
|
182
|
+
input: "{}",
|
|
183
|
+
timeout: 5000,
|
|
184
|
+
});
|
|
185
|
+
return { refused: res.status === 2, stderr: res.stderr ?? "" };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Read the tree, decide what can be planted ───────────────────────────────
|
|
189
|
+
|
|
190
|
+
function readSpecs() {
|
|
191
|
+
const dir = path.join(ROOT, "specs");
|
|
192
|
+
if (!fs.existsSync(dir)) return [];
|
|
193
|
+
return fs
|
|
194
|
+
.readdirSync(dir)
|
|
195
|
+
.filter((n) => n.endsWith(".spec.md"))
|
|
196
|
+
.sort()
|
|
197
|
+
.map((n) => ({ rel: `specs/${n}`, text: fs.readFileSync(path.join(dir, n), "utf8") }));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function readFlows() {
|
|
201
|
+
return listFlowFiles(ROOT).map((rel) => ({ rel, text: fs.readFileSync(abs(rel), "utf8") }));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A directory that already holds a desktop-tier test, derived from the tree's
|
|
206
|
+
* own citations rather than guessed from a package convention — an adopted
|
|
207
|
+
* project's source layout is not ours to assume.
|
|
208
|
+
*/
|
|
209
|
+
function findTestDir() {
|
|
210
|
+
let tags = [];
|
|
211
|
+
try {
|
|
212
|
+
tags = scanCitations(ROOT);
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
const desktop = tags.find((tag) => DESKTOP_TIERS.includes(tag.tier));
|
|
217
|
+
if (desktop) return path.dirname(desktop.file);
|
|
218
|
+
// No citations yet: fall back to any directory holding a Kotlin test source.
|
|
219
|
+
const kt = walkFiles(path.join(ROOT, "composeApp/src"), [".kt"]).find((f) => /(commonTest|desktopTest)/.test(f));
|
|
220
|
+
return kt ? path.dirname(path.relative(ROOT, kt)) : null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
out(`framework check: bound=${BOUND_MS}ms per direction, profile=smoke (no Gradle, no device, no network)`);
|
|
224
|
+
|
|
225
|
+
const tree = {
|
|
226
|
+
specs: readSpecs(),
|
|
227
|
+
flows: readFlows(),
|
|
228
|
+
harnessLib: listHarnessFiles(ROOT).filter((rel) => rel.startsWith("qa/lib/")),
|
|
229
|
+
testDir: findTestDir(),
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const { plants, unavailable } = selectPlants(tree);
|
|
233
|
+
const coverage = assessCoverage(plants);
|
|
234
|
+
if (!coverage.ok) die(coverage.reason);
|
|
235
|
+
|
|
236
|
+
for (const u of unavailable) out(` ⓘ ${u.kind.padEnd(24)} not planted — ${u.reason}`);
|
|
237
|
+
|
|
238
|
+
// ── Refuse to start on a dirty tree ─────────────────────────────────────────
|
|
239
|
+
// Everything this may write, named up front. A file with uncommitted changes is
|
|
240
|
+
// a file whose bytes we would be gambling with if the process died mid-plant.
|
|
241
|
+
|
|
242
|
+
const touched = new Set([RECEIPT_REL, SURFACE_REL, README_REL]);
|
|
243
|
+
for (const p of plants) {
|
|
244
|
+
if (p.target.spec) touched.add(p.target.spec);
|
|
245
|
+
if (p.target.flow) touched.add(p.target.flow);
|
|
246
|
+
if (p.target.file) touched.add(p.target.file);
|
|
247
|
+
if (p.target.declaration) touched.add(p.target.declaration);
|
|
248
|
+
if (p.target.testDir) touched.add(`${p.target.testDir}/${PLANTED_TEST_BASENAME}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
{
|
|
252
|
+
const res = spawnSync("git", ["status", "--porcelain", "--", ...touched], {
|
|
253
|
+
cwd: ROOT,
|
|
254
|
+
encoding: "utf8",
|
|
255
|
+
timeout: 10_000,
|
|
256
|
+
});
|
|
257
|
+
// Not a git repo (or no git): the in-memory restore still covers the normal
|
|
258
|
+
// path, so this is a warning, not a refusal — but say so, because the safety
|
|
259
|
+
// net is thinner than the one the message above promises.
|
|
260
|
+
if (res.status !== 0) {
|
|
261
|
+
out(` ⓘ no git here — falling back to in-memory restore only; do not interrupt with SIGKILL`);
|
|
262
|
+
} else {
|
|
263
|
+
const dirty = (res.stdout ?? "")
|
|
264
|
+
.split("\n")
|
|
265
|
+
.map((l) => l.slice(3).trim())
|
|
266
|
+
.filter(Boolean)
|
|
267
|
+
// The receipt and the badge are lane OUTPUT: this run rewrites them and
|
|
268
|
+
// puts them back, and they are routinely dirty mid-change. Refusing on
|
|
269
|
+
// them would make the instrument unrunnable exactly when it is wanted.
|
|
270
|
+
.filter((rel) => rel !== RECEIPT_REL && rel !== README_REL);
|
|
271
|
+
if (dirty.length) {
|
|
272
|
+
die(
|
|
273
|
+
`these files have uncommitted changes and this check plants into them:\n ${dirty.join("\n ")}\n` +
|
|
274
|
+
`Commit or stash them first — a plant that dies mid-run must not be able to lose your work.`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── Making and unmaking a plant ─────────────────────────────────────────────
|
|
281
|
+
// Each plant is the SMALLEST edit that produces its violation, made against
|
|
282
|
+
// real content the tree already has. Planting garbage proves a gate rejects
|
|
283
|
+
// garbage; editing something live proves it was READING.
|
|
284
|
+
|
|
285
|
+
/** Every file a plant writes — the set `revertPlant` puts back. */
|
|
286
|
+
function plantFiles(plant) {
|
|
287
|
+
const rels = [];
|
|
288
|
+
if (plant.target.spec) rels.push(plant.target.spec);
|
|
289
|
+
if (plant.target.flows) rels.push(...plant.target.flows);
|
|
290
|
+
if (plant.target.file) rels.push(plant.target.file);
|
|
291
|
+
if (plant.target.declaration) rels.push(plant.target.declaration);
|
|
292
|
+
if (plant.target.testDir) rels.push(`${plant.target.testDir}/${PLANTED_TEST_BASENAME}`);
|
|
293
|
+
if (plant.target.nestInto) {
|
|
294
|
+
for (const rel of plant.target.flows ?? []) rels.push(`${plant.target.nestInto}/${path.basename(rel)}`);
|
|
295
|
+
}
|
|
296
|
+
return rels;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function read(rel) {
|
|
300
|
+
return fs.readFileSync(abs(rel), "utf8");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Every `# SPEC:` citation line neutralised — the flow stays valid YAML. */
|
|
304
|
+
function stripCitations(text) {
|
|
305
|
+
return text.replace(/^#\s*SPEC:.*$/gm, "# (citation removed by the framework check)");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function applyPlant(plant) {
|
|
309
|
+
const { target } = plant;
|
|
310
|
+
switch (plant.kind) {
|
|
311
|
+
case PLANT_KINDS.ORPHANED_CITATION: {
|
|
312
|
+
// Rename a LIVE clause: every citation of it is suddenly an orphan, and
|
|
313
|
+
// specCoverage must name the id it can no longer find.
|
|
314
|
+
const text = read(target.spec);
|
|
315
|
+
write(target.spec, text.replace(`**${target.clause}**`, `**${target.clause}X**`));
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
case PLANT_KINDS.UNBOUND_CITATION: {
|
|
319
|
+
// A tag on a CLASS declaration with no test inside the binding window:
|
|
320
|
+
// the clause exists, the tag exists, and nothing runs.
|
|
321
|
+
const text = read(target.spec);
|
|
322
|
+
write(target.spec, `${text.trimEnd()}\n- **${target.clause}** — Given a planted clause, Then a class-level tag must not count.\n`);
|
|
323
|
+
write(
|
|
324
|
+
`${target.testDir}/${PLANTED_TEST_BASENAME}`,
|
|
325
|
+
`// Planted by qa/framework-check.mjs — reverted automatically.\n\n// SPEC: ${target.clause}\nclass CmpFrameworkCheckPlanted {\n val a = 1\n val b = 2\n val c = 3\n val d = 4\n val e = 5\n val f = 6\n fun helper() {}\n}\n`,
|
|
326
|
+
);
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
case PLANT_KINDS.TIER_UNMET: {
|
|
330
|
+
// A clause only a device can observe, cited only from the JVM.
|
|
331
|
+
const text = read(target.spec);
|
|
332
|
+
write(
|
|
333
|
+
target.spec,
|
|
334
|
+
`${text.trimEnd()}\n- **${target.clause}** [tier: e2e] — Given a planted device-only clause, Then a JVM citation cannot satisfy it.\n`,
|
|
335
|
+
);
|
|
336
|
+
write(
|
|
337
|
+
`${target.testDir}/${PLANTED_TEST_BASENAME}`,
|
|
338
|
+
`// Planted by qa/framework-check.mjs — reverted automatically.\n\nimport kotlin.test.Test\n\nclass CmpFrameworkCheckPlanted {\n // SPEC: ${target.clause}\n @Test\n fun planted() {}\n}\n`,
|
|
339
|
+
);
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
case PLANT_KINDS.FEATURE_WITHOUT_FLOW: {
|
|
343
|
+
// Every citation in every flow: a real feature with no device journey.
|
|
344
|
+
for (const rel of target.flows) write(rel, stripCitations(read(rel)));
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
case PLANT_KINDS.NESTED_FLOW: {
|
|
348
|
+
// The citations survive, in a subdirectory the lane never executes.
|
|
349
|
+
for (const rel of target.flows) {
|
|
350
|
+
const text = read(rel);
|
|
351
|
+
write(`${target.nestInto}/${path.basename(rel)}`, text);
|
|
352
|
+
write(rel, stripCitations(text));
|
|
353
|
+
}
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
case PLANT_KINDS.NARROWED_SURFACE: {
|
|
357
|
+
// One entry removed from the surface declaration un-attests a whole layer
|
|
358
|
+
// while every checker stays intact. Writing a narrow one where none
|
|
359
|
+
// existed is the same edit: the declaration enters the region unrecorded.
|
|
360
|
+
write(target.declaration, `${JSON.stringify({ surface: ["qa"] }, null, 2)}\n`);
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
case PLANT_KINDS.EDITED_LANE: {
|
|
364
|
+
// One byte in the machine-owned region: the lane issuing verdicts is no
|
|
365
|
+
// longer the lane this app was given.
|
|
366
|
+
write(target.file, `${read(target.file)}\n// planted by the framework check\n`);
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
default:
|
|
370
|
+
die(`unknown plant kind "${plant.kind}" — the selector and the runner have drifted apart`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function revertPlant(plant) {
|
|
375
|
+
for (const rel of [...plantFiles(plant), RECEIPT_REL]) {
|
|
376
|
+
if (!original.has(rel)) continue;
|
|
377
|
+
const text = original.get(rel);
|
|
378
|
+
try {
|
|
379
|
+
if (text === null) fs.rmSync(abs(rel), { force: true });
|
|
380
|
+
else fs.writeFileSync(abs(rel), text);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
die(`could not revert ${rel} — ${err.message}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ── The check ───────────────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
/** @type {Array<{label: string, ms: number}>} */
|
|
390
|
+
const cycles = [];
|
|
391
|
+
|
|
392
|
+
// The lane's own outputs, captured BEFORE the first run writes them. Do this
|
|
393
|
+
// late and you capture the baseline's receipt instead of the tree's real one —
|
|
394
|
+
// and then "restoring" leaves a smoke receipt sitting where an L1/L2 receipt
|
|
395
|
+
// used to be, which is precisely the weaker-evidence swap this harness refuses.
|
|
396
|
+
remember(RECEIPT_REL);
|
|
397
|
+
remember(README_REL);
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
// 1. Baseline — this tree must be green before anything is planted, or every
|
|
401
|
+
// FAIL below is unattributable.
|
|
402
|
+
const base = runSmoke();
|
|
403
|
+
cycles.push({ label: "baseline", ms: base.ms });
|
|
404
|
+
const baseVerdict = assessGreenRun(base, "baseline", BOUND_MS);
|
|
405
|
+
if (!baseVerdict.ok) die(baseVerdict.reason);
|
|
406
|
+
if (base.receipt.stage !== "smoke") die(`receipt names stage "${base.receipt.stage}", expected "smoke"`);
|
|
407
|
+
out(` baseline ${String(base.ms).padStart(5)}ms ✓ ${base.receipt.steps.length} steps, verdict PASS, stage smoke`);
|
|
408
|
+
|
|
409
|
+
// 2. One planted violation per guard, each asserted to FAIL BY NAME, each
|
|
410
|
+
// reverted before the next. A guard that has only ever passed is an unread
|
|
411
|
+
// instrument; this is where each one is read.
|
|
412
|
+
for (const plant of plants) {
|
|
413
|
+
applyPlant(plant);
|
|
414
|
+
const run = runSmoke();
|
|
415
|
+
cycles.push({ label: plant.label, ms: run.ms });
|
|
416
|
+
const verdict = assessPlantRun(run, plant, BOUND_MS);
|
|
417
|
+
if (!verdict.ok) die(verdict.reason);
|
|
418
|
+
|
|
419
|
+
if (plant.hookPattern) {
|
|
420
|
+
// The hook refuses a smoke-stage receipt on its stage and a FAIL receipt
|
|
421
|
+
// on its verdict, before the vouching check runs. The vouching guard
|
|
422
|
+
// exists for the FORGERY — the top-level verdict edited to PASS over rows
|
|
423
|
+
// that say the lane cannot vouch for itself — so that is the receipt
|
|
424
|
+
// presented: same rows, change stage, verdict flipped.
|
|
425
|
+
write(RECEIPT_REL, JSON.stringify({ ...run.receipt, profile: "local", stage: "change", verdict: "PASS" }, null, 2));
|
|
426
|
+
const h = hookRefuses();
|
|
427
|
+
if (!h.refused || !new RegExp(plant.hookPattern, "i").test(h.stderr)) {
|
|
428
|
+
die(`the Stop hook did not refuse the forged "${plant.label}" receipt for the right reason:\n${h.stderr.slice(-400)}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const named = plant.names.length ? ` naming ${plant.names.join(", ")}` : "";
|
|
433
|
+
out(` FAIL: ${plant.label.padEnd(28)} ${String(run.ms).padStart(5)}ms ✓ ${plant.step} FAIL${named}`);
|
|
434
|
+
revertPlant(plant);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// 3. The Stop hook refuses a real FAIL receipt — the framework's last link.
|
|
438
|
+
{
|
|
439
|
+
const first = plants[0];
|
|
440
|
+
applyPlant(first);
|
|
441
|
+
const r = runSmoke();
|
|
442
|
+
revertPlant(first);
|
|
443
|
+
if (!r.receipt || r.receipt.verdict !== "FAIL") die("could not produce a FAIL receipt for the hook check");
|
|
444
|
+
if (!hookRefuses().refused) die("the Stop hook did not refuse a FAIL receipt");
|
|
445
|
+
out(` Stop hook refuses a FAIL receipt ✓`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// 4. And a receipt whose device tier SKIPped for an ENVIRONMENTAL reason: a
|
|
449
|
+
// synthetic row over this tree's own valid hash, so only the skip is new.
|
|
450
|
+
{
|
|
451
|
+
const green = runSmoke();
|
|
452
|
+
if (!green.receipt || green.receipt.verdict !== "PASS") die("could not produce a PASS receipt for the device-tier hook check");
|
|
453
|
+
const planted = {
|
|
454
|
+
...green.receipt,
|
|
455
|
+
profile: "local",
|
|
456
|
+
stage: "change",
|
|
457
|
+
steps: [
|
|
458
|
+
...green.receipt.steps,
|
|
459
|
+
{ name: "e2eSmoke", verdict: "SKIP", skipKind: "environment", reason: "device tier disabled by CMP_DEVICE=none (planted)", durationMs: 0 },
|
|
460
|
+
],
|
|
461
|
+
};
|
|
462
|
+
write(RECEIPT_REL, JSON.stringify(planted, null, 2));
|
|
463
|
+
const hook = hookRefuses();
|
|
464
|
+
if (!hook.refused || !/device tier did not run/.test(hook.stderr)) {
|
|
465
|
+
die(`the Stop hook did not refuse a receipt whose device tier was skipped for an environmental reason:\n${hook.stderr.slice(-400)}`);
|
|
466
|
+
}
|
|
467
|
+
out(` Stop hook refuses a skipped device tier ✓`);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// 5. Everything reverted, and it passes again — the plants were the only cause.
|
|
471
|
+
restoreAll();
|
|
472
|
+
const again = runSmoke();
|
|
473
|
+
cycles.push({ label: "revert → PASS", ms: again.ms });
|
|
474
|
+
const againVerdict = assessGreenRun(again, "revert", BOUND_MS);
|
|
475
|
+
if (!againVerdict.ok) die(againVerdict.reason);
|
|
476
|
+
out(` revert → PASS ${String(again.ms).padStart(5)}ms ✓`);
|
|
477
|
+
} finally {
|
|
478
|
+
restoreAll();
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ── Report ──────────────────────────────────────────────────────────────────
|
|
482
|
+
// The cost is part of the finding. Rule 1 calls a calibration "four steps,
|
|
483
|
+
// seconds each"; a cycle past the budget means the plant is running through the
|
|
484
|
+
// wrong instrument, and that is worth saying while the choice is cheap.
|
|
485
|
+
|
|
486
|
+
const cost = assessCalibrationCost(cycles, BUDGET_MS);
|
|
487
|
+
|
|
488
|
+
if (asJson) {
|
|
489
|
+
process.stdout.write(
|
|
490
|
+
`${JSON.stringify(
|
|
491
|
+
{
|
|
492
|
+
verdict: "PASS",
|
|
493
|
+
plants: plants.map((p) => ({ kind: p.kind, label: p.label, step: p.step })),
|
|
494
|
+
unavailable,
|
|
495
|
+
cycles,
|
|
496
|
+
totalMs: cost.totalMs,
|
|
497
|
+
budgetMs: BUDGET_MS,
|
|
498
|
+
withinBudget: cost.withinBudget,
|
|
499
|
+
note: cost.note,
|
|
500
|
+
},
|
|
501
|
+
null,
|
|
502
|
+
2,
|
|
503
|
+
)}\n`,
|
|
504
|
+
);
|
|
505
|
+
} else {
|
|
506
|
+
out(
|
|
507
|
+
`\nframework check: PASS — the lane returns, both ways, and every guard fails by name: ` +
|
|
508
|
+
`${plants.length} plants, ${cost.totalMs}ms total (bound ${BOUND_MS}ms per direction).`,
|
|
509
|
+
);
|
|
510
|
+
if (!cost.withinBudget) out(` ⚠ ${cost.note}`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
process.exit(0);
|