create-cmp-cli 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-cmp.mjs +33 -3
- package/package.json +10 -4
- package/packages/harness/package.json +18 -8
- package/packages/harness/src/approve.mjs +46 -18
- package/packages/harness/src/console/console-data.mjs +117 -0
- package/packages/harness/src/console/console-evidence.mjs +122 -0
- package/packages/harness/src/console/console-overview.mjs +642 -0
- package/packages/harness/src/console/console-shell.mjs +1139 -0
- package/packages/harness/src/console/console-tabs.mjs +2828 -0
- package/packages/harness/src/console/contrast.mjs +74 -0
- package/packages/harness/src/console/preview-service.mjs +1384 -0
- package/packages/harness/src/framework-check.mjs +747 -0
- package/packages/harness/src/lib/a11y.mjs +1 -1
- package/packages/harness/src/lib/affected-tests.mjs +99 -44
- package/packages/harness/src/lib/agent-hold.mjs +234 -0
- package/packages/harness/src/lib/approvals.mjs +177 -350
- package/packages/harness/src/lib/audit-cadence.mjs +1 -1
- package/packages/harness/src/lib/comments.mjs +18 -4
- package/packages/harness/src/lib/determinism.mjs +275 -14
- package/packages/harness/src/lib/evidence-badge.mjs +38 -6
- package/packages/harness/src/lib/evidence-ladder.mjs +202 -0
- package/packages/harness/src/lib/evidence-level.mjs +144 -59
- package/packages/harness/src/lib/feature-brief.mjs +53 -16
- package/packages/harness/src/lib/flight-recorder.mjs +72 -8
- package/packages/harness/src/lib/framework-check.mjs +778 -0
- package/packages/harness/src/lib/harness-lock.mjs +87 -19
- package/packages/harness/src/lib/harness-manifest.mjs +208 -0
- package/packages/harness/src/lib/harness-region.mjs +106 -2
- package/packages/harness/src/lib/harness-source.mjs +142 -0
- package/packages/harness/src/lib/inputs-hash.mjs +180 -22
- package/packages/harness/src/lib/lane-markers.mjs +78 -0
- package/packages/harness/src/lib/lane-narrator.mjs +4 -2
- package/packages/harness/src/lib/lane-runner.mjs +72 -6
- package/packages/harness/src/lib/plan.mjs +44 -11
- package/packages/harness/src/lib/plant-calibration.mjs +135 -0
- package/packages/harness/src/lib/profile-loader.mjs +323 -0
- package/packages/harness/src/lib/profiles/cmp/affected.mjs +70 -0
- package/packages/harness/src/lib/profiles/cmp/android-checks.mjs +56 -0
- package/packages/harness/src/lib/profiles/cmp/artifacts.mjs +381 -0
- package/packages/harness/src/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
- package/packages/harness/src/lib/profiles/cmp/console-copy.mjs +29 -0
- package/packages/harness/src/lib/profiles/cmp/declarations.mjs +181 -0
- package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
- package/packages/harness/src/lib/profiles/cmp/index.mjs +47 -0
- package/packages/harness/src/lib/profiles/cmp/ladder.mjs +67 -0
- package/packages/harness/src/lib/profiles/cmp/maestro.mjs +113 -0
- package/packages/harness/src/lib/profiles/cmp/plants.mjs +95 -0
- package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/reachability.mjs +1 -1
- package/packages/harness/src/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +230 -28
- package/packages/harness/src/lib/receipt-validate.mjs +84 -16
- package/packages/harness/src/lib/spec-coverage.mjs +179 -92
- package/packages/harness/src/lib/spec-model.mjs +262 -0
- package/packages/harness/src/lib/step-cache.mjs +11 -2
- package/packages/harness/src/lib/step-outcomes.mjs +84 -151
- package/packages/harness/src/lib/walk.mjs +1 -1
- package/packages/harness/src/plan.mjs +30 -1
- package/packages/harness/src/preview-gallery.mjs +1 -1
- package/packages/harness/src/receipt-check.mjs +95 -26
- package/packages/harness/src/record-audit.mjs +1 -1
- package/packages/harness/src/scaffold-feature.mjs +10 -2
- package/packages/harness/src/verify.mjs +222 -142
- package/packages/harness/src/watch.mjs +146 -33
- package/packages/receipts/package.json +1 -1
- package/packages/receipts/src/index.mjs +2 -2
- package/packages/receipts/src/inputs-hash.mjs +180 -22
- package/packages/receipts/src/receipt-validate.mjs +84 -16
- package/src/commands/attach.mjs +128 -4
- package/src/commands/harden.mjs +7 -1
- package/src/commands/upgrade.mjs +7 -0
- package/src/lib/minimal.mjs +23 -1
- package/src/scaffold.mjs +8 -0
- package/template/.claude/settings.json +2 -2
- package/template/.claude/skills/add-feature/SKILL.md +7 -3
- package/template/.claude/skills/add-repository/SKILL.md +2 -2
- package/template/.claude/skills/add-screen/SKILL.md +8 -4
- package/template/.githooks/pre-push +9 -0
- package/template/CLAUDE.md +55 -186
- package/template/docs/TESTING.md +1 -1
- package/template/gitignore +4 -0
- package/template/qa/approve.mjs +46 -18
- package/template/qa/comments.json +1 -1
- package/template/qa/evidence/schema.json +258 -34
- package/template/qa/framework-check.mjs +747 -0
- package/template/qa/harness-manifest.json +10 -0
- package/template/qa/lib/a11y.mjs +1 -1
- package/template/qa/lib/affected-tests.mjs +99 -44
- package/template/qa/lib/agent-hold.mjs +234 -0
- package/template/qa/lib/approvals.mjs +177 -350
- package/template/qa/lib/audit-cadence.mjs +1 -1
- package/template/qa/lib/comments.mjs +18 -4
- package/template/qa/lib/determinism.mjs +275 -14
- package/template/qa/lib/evidence-badge.mjs +38 -6
- package/template/qa/lib/evidence-ladder.mjs +202 -0
- package/template/qa/lib/evidence-level.mjs +144 -59
- package/template/qa/lib/feature-brief.mjs +53 -16
- package/template/qa/lib/flight-recorder.mjs +72 -8
- package/template/qa/lib/framework-check.mjs +778 -0
- package/template/qa/lib/harness-lock.mjs +87 -19
- package/template/qa/lib/harness-manifest.mjs +208 -0
- package/template/qa/lib/harness-region.mjs +106 -2
- package/template/qa/lib/harness-source.mjs +142 -0
- package/template/qa/lib/inputs-hash.mjs +180 -22
- package/template/qa/lib/lane-markers.mjs +78 -0
- package/template/qa/lib/lane-narrator.mjs +4 -2
- package/template/qa/lib/lane-runner.mjs +72 -6
- package/template/qa/lib/plan.mjs +44 -11
- package/template/qa/lib/plant-calibration.mjs +135 -0
- package/template/qa/lib/profile-loader.mjs +323 -0
- package/template/qa/lib/profiles/cmp/affected.mjs +70 -0
- package/template/qa/lib/profiles/cmp/android-checks.mjs +56 -0
- package/template/qa/lib/profiles/cmp/artifacts.mjs +381 -0
- package/template/qa/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
- package/template/qa/lib/profiles/cmp/console-copy.mjs +29 -0
- package/template/qa/lib/profiles/cmp/declarations.mjs +181 -0
- package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
- package/template/qa/lib/profiles/cmp/index.mjs +47 -0
- package/template/qa/lib/profiles/cmp/ladder.mjs +67 -0
- package/template/qa/lib/profiles/cmp/maestro.mjs +113 -0
- package/template/qa/lib/profiles/cmp/plants.mjs +95 -0
- package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/reachability.mjs +1 -1
- package/template/qa/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +230 -28
- package/template/qa/lib/receipt-validate.mjs +84 -16
- package/template/qa/lib/spec-coverage.mjs +179 -92
- package/template/qa/lib/spec-model.mjs +262 -0
- package/template/qa/lib/step-cache.mjs +11 -2
- package/template/qa/lib/step-outcomes.mjs +84 -151
- package/template/qa/lib/walk.mjs +1 -1
- package/template/qa/plan.mjs +30 -1
- package/template/qa/preview-gallery.mjs +1 -1
- package/template/qa/receipt-check.mjs +95 -26
- package/template/qa/record-audit.mjs +1 -1
- package/template/qa/scaffold-feature.mjs +10 -2
- package/template/qa/verified-surface.json +17 -0
- package/template/qa/verify.mjs +222 -142
- package/template/qa/watch.mjs +146 -33
- /package/packages/harness/src/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
- /package/packages/harness/src/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
- /package/packages/harness/src/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
- /package/packages/harness/src/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
- /package/packages/harness/src/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
- /package/template/qa/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
- /package/template/qa/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
- /package/template/qa/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
- /package/template/qa/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
- /package/template/qa/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
|
@@ -0,0 +1,747 @@
|
|
|
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 test sources 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 os from "node:os";
|
|
43
|
+
import path from "node:path";
|
|
44
|
+
import { fileURLToPath } from "node:url";
|
|
45
|
+
|
|
46
|
+
import {
|
|
47
|
+
DEFAULT_BOUND_MS,
|
|
48
|
+
CALIBRATION_BUDGET_MS,
|
|
49
|
+
PLANT_KINDS,
|
|
50
|
+
selectPlants,
|
|
51
|
+
assessCoverage,
|
|
52
|
+
assessPlantRun,
|
|
53
|
+
assessGreenRun,
|
|
54
|
+
assessCalibrationCost,
|
|
55
|
+
assessBadgeFloor,
|
|
56
|
+
plantRow,
|
|
57
|
+
} from "./lib/framework-check.mjs";
|
|
58
|
+
import { plantCalibration } from "./lib/plant-calibration.mjs";
|
|
59
|
+
import { evidenceLadderFor } from "./lib/evidence-ladder.mjs";
|
|
60
|
+
import { listHarnessFiles } from "./lib/harness-region.mjs";
|
|
61
|
+
import { checkHarnessIntegrity, describeIntegrity, writeHarnessLock } from "./lib/harness-lock.mjs";
|
|
62
|
+
import { listFlowFiles, scanCitations, walkFiles } from "./lib/spec-coverage.mjs";
|
|
63
|
+
import { resolveSpecModel } from "./lib/spec-model.mjs";
|
|
64
|
+
import { resolveHarnessManifest } from "./lib/harness-manifest.mjs";
|
|
65
|
+
import { loadProfileSync } from "./lib/profile-loader.mjs";
|
|
66
|
+
|
|
67
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
68
|
+
const RECEIPT_REL = "qa/evidence/latest.json";
|
|
69
|
+
const SURFACE_REL = "qa/verified-surface.json";
|
|
70
|
+
const README_REL = "README.md";
|
|
71
|
+
|
|
72
|
+
const argv = process.argv.slice(2);
|
|
73
|
+
const flag = (n) => argv.includes(n);
|
|
74
|
+
const opt = (n, d) => (argv.includes(n) ? argv[argv.indexOf(n) + 1] : d);
|
|
75
|
+
|
|
76
|
+
if (flag("--help") || flag("-h")) {
|
|
77
|
+
console.log(
|
|
78
|
+
`node qa/framework-check.mjs [--bound-ms <ms>] [--budget-ms <ms>] [--json]\n` +
|
|
79
|
+
` Proves this app's lane returns a fast deterministic PASS and FAIL through the smoke profile.\n` +
|
|
80
|
+
` --bound-ms per-direction hang bound (default ${DEFAULT_BOUND_MS})\n` +
|
|
81
|
+
` --budget-ms per-cycle calibration budget; a slower cycle is reported (default ${CALIBRATION_BUDGET_MS})\n` +
|
|
82
|
+
` --json print the report as JSON`,
|
|
83
|
+
);
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const BOUND_MS = Number.parseInt(opt("--bound-ms", String(DEFAULT_BOUND_MS)), 10);
|
|
88
|
+
const BUDGET_MS = Number.parseInt(opt("--budget-ms", String(CALIBRATION_BUDGET_MS)), 10);
|
|
89
|
+
const asJson = flag("--json");
|
|
90
|
+
|
|
91
|
+
const t = () => Date.now();
|
|
92
|
+
const out = (s) => {
|
|
93
|
+
if (!asJson) process.stdout.write(`${s}\n`);
|
|
94
|
+
};
|
|
95
|
+
const abs = (rel) => path.join(ROOT, ...rel.split("/"));
|
|
96
|
+
|
|
97
|
+
function die(msg) {
|
|
98
|
+
restoreAll();
|
|
99
|
+
if (asJson) process.stdout.write(`${JSON.stringify({ verdict: "FAIL", reason: msg }, null, 2)}\n`);
|
|
100
|
+
else process.stderr.write(`\nframework check: FAIL — ${msg}\n`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Restore ledger ──────────────────────────────────────────────────────────
|
|
105
|
+
// Captured BEFORE anything is written; replayed in a finally and on a signal.
|
|
106
|
+
// `null` means the file did not exist and must be removed again.
|
|
107
|
+
|
|
108
|
+
/** @type {Map<string, string|null>} */
|
|
109
|
+
const original = new Map();
|
|
110
|
+
/** Directories this run created, deepest first — removed if left empty. */
|
|
111
|
+
const createdDirs = new Set();
|
|
112
|
+
|
|
113
|
+
function remember(rel) {
|
|
114
|
+
if (original.has(rel)) return;
|
|
115
|
+
const p = abs(rel);
|
|
116
|
+
original.set(rel, fs.existsSync(p) ? fs.readFileSync(p, "utf8") : null);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function write(rel, text) {
|
|
120
|
+
remember(rel);
|
|
121
|
+
const p = abs(rel);
|
|
122
|
+
const dir = path.dirname(p);
|
|
123
|
+
if (!fs.existsSync(dir)) {
|
|
124
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
125
|
+
createdDirs.add(dir);
|
|
126
|
+
}
|
|
127
|
+
fs.writeFileSync(p, text);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Repeatable on purpose, not one-shot. The last thing this check does is run the
|
|
131
|
+
// lane once more to prove the revert worked — and that run writes a receipt and
|
|
132
|
+
// refreshes the README badge. A smoke receipt is refused as done-evidence, so
|
|
133
|
+
// leaving it in place would CLOBBER whatever real L1/L2 receipt the tree had
|
|
134
|
+
// with one that proves nothing. Restoring again afterwards is what makes this
|
|
135
|
+
// instrument safe to run on a working tree mid-change.
|
|
136
|
+
function restoreAll() {
|
|
137
|
+
for (const [rel, text] of original) {
|
|
138
|
+
const p = abs(rel);
|
|
139
|
+
try {
|
|
140
|
+
if (text === null) fs.rmSync(p, { force: true });
|
|
141
|
+
else fs.writeFileSync(p, text);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
process.stderr.write(`framework check: COULD NOT RESTORE ${rel} — ${err.message}\n`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
for (const dir of [...createdDirs].sort((a, b) => b.length - a.length)) {
|
|
147
|
+
try {
|
|
148
|
+
if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
|
|
149
|
+
} catch {
|
|
150
|
+
/* not empty, or already gone — either way not ours to force */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
156
|
+
process.on(sig, () => {
|
|
157
|
+
restoreAll();
|
|
158
|
+
process.exit(130);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Lane runs ───────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
function runSmoke() {
|
|
165
|
+
const started = t();
|
|
166
|
+
const res = spawnSync(process.execPath, [abs("qa/verify.mjs"), "--profile", "smoke", "--json", "--no-journal"], {
|
|
167
|
+
cwd: ROOT,
|
|
168
|
+
encoding: "utf8",
|
|
169
|
+
env: { ...process.env },
|
|
170
|
+
timeout: BOUND_MS,
|
|
171
|
+
killSignal: "SIGKILL",
|
|
172
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
173
|
+
});
|
|
174
|
+
const ms = t() - started;
|
|
175
|
+
if (res.error && res.error.code === "ETIMEDOUT") return { hung: true, ms };
|
|
176
|
+
let receipt = null;
|
|
177
|
+
try {
|
|
178
|
+
const text = res.stdout ?? "";
|
|
179
|
+
receipt = JSON.parse(text.slice(text.indexOf("{")));
|
|
180
|
+
} catch {
|
|
181
|
+
/* reported by the assessor */
|
|
182
|
+
}
|
|
183
|
+
return { hung: false, ms, exit: res.status, receipt, stderr: res.stderr ?? "" };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function hookRefuses() {
|
|
187
|
+
const res = spawnSync(process.execPath, [abs("qa/receipt-check.mjs"), "--hook"], {
|
|
188
|
+
cwd: ROOT,
|
|
189
|
+
encoding: "utf8",
|
|
190
|
+
input: "{}",
|
|
191
|
+
timeout: 5000,
|
|
192
|
+
});
|
|
193
|
+
return { refused: res.status === 2, stderr: res.stderr ?? "" };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Read the tree, decide what can be planted ───────────────────────────────
|
|
197
|
+
|
|
198
|
+
function readSpecs() {
|
|
199
|
+
if (!SPEC_MODEL) return [];
|
|
200
|
+
const dir = path.join(ROOT, ...SPEC_MODEL.specsDir.split("/"));
|
|
201
|
+
if (!fs.existsSync(dir)) return [];
|
|
202
|
+
return fs
|
|
203
|
+
.readdirSync(dir)
|
|
204
|
+
.filter((n) => n.endsWith(".spec.md"))
|
|
205
|
+
.sort()
|
|
206
|
+
.map((n) => ({ rel: `${SPEC_MODEL.specsDir}/${n}`, text: fs.readFileSync(path.join(dir, n), "utf8") }));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The scanner's model — the profile's layout and tiers. This runner plants
|
|
210
|
+
// into specs, flows and test sources, so it has to know where they are; it
|
|
211
|
+
// asks the profile, never a constant. A tree with no usable profile has no
|
|
212
|
+
// spec plants to make, and says so per plant rather than guessing.
|
|
213
|
+
const SPEC_MODEL_RESULT = resolveSpecModel(ROOT);
|
|
214
|
+
// REFUSE FIRST. With no manifest there is no layout, so every plant that reads
|
|
215
|
+
// the tree reported an honest-looking absence of files that exist: a project
|
|
216
|
+
// with two specs and nine clauses was told "no spec files — nothing declares
|
|
217
|
+
// behavior to plant against", four lines before the manifest refusal explained
|
|
218
|
+
// the real problem. That is the exact failure harness-manifest.mjs names in its
|
|
219
|
+
// own header as the reason there is no default profile, reproduced inside the
|
|
220
|
+
// instrument built to prove the lane returns. A reader who cannot know the
|
|
221
|
+
// layout says so and stops, rather than describing a tree it cannot see.
|
|
222
|
+
if (!SPEC_MODEL_RESULT.ok) {
|
|
223
|
+
process.stderr.write(`\n${SPEC_MODEL_RESULT.reason}\n`);
|
|
224
|
+
process.exit(2);
|
|
225
|
+
}
|
|
226
|
+
const SPEC_MODEL = SPEC_MODEL_RESULT.model;
|
|
227
|
+
|
|
228
|
+
// The SOURCE this profile's planted citations live in. A citation must sit on
|
|
229
|
+
// a test, so two of the plants have to WRITE a test — in this stack's
|
|
230
|
+
// language, with its test-declaration syntax, at a path it compiles. Everything
|
|
231
|
+
// else about a plant is stack-free: the clause grammar and the citation marker
|
|
232
|
+
// are the core scanner's, and which plants a tree can support is derived from
|
|
233
|
+
// the tree. A profile that declares none simply ships without those two, said
|
|
234
|
+
// out loud per plant (§5.2: no plants, no badge).
|
|
235
|
+
const PROFILE_MODULE = (() => {
|
|
236
|
+
const manifest = resolveHarnessManifest(ROOT);
|
|
237
|
+
if (!manifest.ok) return null;
|
|
238
|
+
const loaded = loadProfileSync(ROOT, manifest.manifest.profile);
|
|
239
|
+
return loaded.ok ? loaded.profile : null;
|
|
240
|
+
})();
|
|
241
|
+
const PLANT_DECL = PROFILE_MODULE?.plants ?? null;
|
|
242
|
+
|
|
243
|
+
// The SOURCE half of that declaration, which the two citation plants cannot do
|
|
244
|
+
// without. Kept separate from the declaration itself because the other half —
|
|
245
|
+
// which step observes which plant (`observedBy`) — costs a pack nothing to give
|
|
246
|
+
// and must not be withheld by the absence of a Kotlin/Python/Go snippet it has
|
|
247
|
+
// not written yet. Requiring all of it or none of it would mean a pack that
|
|
248
|
+
// names its steps but ships no plant source still had cmp's spellings asserted
|
|
249
|
+
// against it, which is the defect this reads the declaration to avoid.
|
|
250
|
+
//
|
|
251
|
+
// THE TEST FOR "ENOUGH TO PLANT WITH" USED TO BE THREE `typeof`s WRITTEN HERE.
|
|
252
|
+
// It moved to qa/lib/plant-calibration.mjs the day the RUNG came to depend on
|
|
253
|
+
// the same answer (NORTH-STAR §8.9 — a profile with no calibrated plants earns
|
|
254
|
+
// no rung): the grader and the instrument asking that question separately is
|
|
255
|
+
// how the same word ends up meaning two things in one tree, which is the
|
|
256
|
+
// `flowCitation`/`scanCitations` episode this file already carries.
|
|
257
|
+
const PROFILE_PLANTS = plantCalibration(PLANT_DECL).ok ? PLANT_DECL : null;
|
|
258
|
+
const PLANTED_TEST_REL = (testDir) => `${testDir}/${PROFILE_PLANTS.testFileBasename}`;
|
|
259
|
+
|
|
260
|
+
function readFlows() {
|
|
261
|
+
if (!SPEC_MODEL) return [];
|
|
262
|
+
return listFlowFiles(ROOT, SPEC_MODEL).map((rel) => ({ rel, text: fs.readFileSync(abs(rel), "utf8") }));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* A directory that already holds a host-only-tier test, derived from the
|
|
267
|
+
* tree's own citations rather than guessed from a package convention — an
|
|
268
|
+
* adopted project's source layout is not ours to assume.
|
|
269
|
+
*/
|
|
270
|
+
function findTestDir() {
|
|
271
|
+
if (!SPEC_MODEL) return null;
|
|
272
|
+
let tags = [];
|
|
273
|
+
try {
|
|
274
|
+
tags = scanCitations(ROOT, SPEC_MODEL);
|
|
275
|
+
} catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
const hostOnly = SPEC_MODEL.tiers.hostOnly;
|
|
279
|
+
const cited = tags.find((tag) => hostOnly.includes(tag.tier));
|
|
280
|
+
if (cited) return path.dirname(cited.file);
|
|
281
|
+
// No citations yet: fall back to any source under a citation root that the
|
|
282
|
+
// profile's own tiering places on a host-only tier.
|
|
283
|
+
for (const rootRel of SPEC_MODEL.citationRoots) {
|
|
284
|
+
const hit = walkFiles(path.join(ROOT, ...rootRel.split("/")), SPEC_MODEL.citationExts).find((f) =>
|
|
285
|
+
hostOnly.includes(SPEC_MODEL.tiers.forFile(path.relative(ROOT, f))),
|
|
286
|
+
);
|
|
287
|
+
if (hit) return path.dirname(path.relative(ROOT, hit));
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// `smoke` is the lane's cheapest profile: the steps that need no build tool, no
|
|
293
|
+
// device and no network. Naming the build tool here ("no Gradle") was one
|
|
294
|
+
// stack's word in the instrument that exists to prove this file has none — a
|
|
295
|
+
// small lie, but in the one place that can least afford it.
|
|
296
|
+
out(`framework check: bound=${BOUND_MS}ms per direction, profile=smoke (host-only steps: no build tool, no device, no network)`);
|
|
297
|
+
|
|
298
|
+
// With no plant material the instrument cannot write a test, so the two plants
|
|
299
|
+
// that need one are unavailable — reported by name, never quietly dropped.
|
|
300
|
+
const tree = {
|
|
301
|
+
specs: readSpecs(),
|
|
302
|
+
flows: readFlows(),
|
|
303
|
+
harnessLib: listHarnessFiles(ROOT).filter((rel) => rel.startsWith("qa/lib/")),
|
|
304
|
+
testDir: PROFILE_PLANTS ? findTestDir() : null,
|
|
305
|
+
// So the skip can name the REAL cause instead of blaming the tree.
|
|
306
|
+
plantsDeclared: Boolean(PROFILE_PLANTS),
|
|
307
|
+
// No `?? "e2e"` anywhere: the tier a plant declares is the profile's or absent.
|
|
308
|
+
unmeetableTier: PROFILE_PLANTS?.unmeetableTier ?? null,
|
|
309
|
+
// WHICH STEP CATCHES WHICH PLANT is the pack's word, exactly as the tier
|
|
310
|
+
// above is. There is no `?? "specCoverage"` here for the same reason there is
|
|
311
|
+
// no `?? "e2e"` there: a default is another stack's name asserted against
|
|
312
|
+
// this one, and lib/framework-check.mjs `observingStep` records what that
|
|
313
|
+
// cost. A pack that names none gets an assertion over the lane.
|
|
314
|
+
observedBy: PLANT_DECL?.observedBy ?? null,
|
|
315
|
+
flowsDir: SPEC_MODEL && SPEC_MODEL.flows ? SPEC_MODEL.flows.dir : null,
|
|
316
|
+
// What a citation LOOKS like is the profile's, never `#`. Without this the
|
|
317
|
+
// selector falls back to DEFAULT_GRAMMAR, which accepts `//` and `#` and so
|
|
318
|
+
// happens to serve C-family and YAML journeys — and silently refuses to
|
|
319
|
+
// select either flow plant for a stack whose comments start `--`, `;` or `%`.
|
|
320
|
+
// A plant that is never selected is a gate nobody is calibrating.
|
|
321
|
+
grammar: SPEC_MODEL ? SPEC_MODEL.grammar : null,
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const { plants, unavailable } = selectPlants(tree);
|
|
325
|
+
const coverage = assessCoverage(plants);
|
|
326
|
+
if (!coverage.ok) die(coverage.reason);
|
|
327
|
+
|
|
328
|
+
for (const u of unavailable) out(` ⓘ ${u.kind.padEnd(24)} not planted — ${u.reason}`);
|
|
329
|
+
|
|
330
|
+
// A plant whose step this pack has not named is neither unavailable nor broken:
|
|
331
|
+
// it asserts over the LANE instead of over one row, which is a real gate and a
|
|
332
|
+
// blunter one (qa/lib/framework-check.mjs `observingStep`). It is said out loud
|
|
333
|
+
// for the same reason every unavailable plant says why — and for one more. The
|
|
334
|
+
// whole episode behind `observedBy` is that a requirement nobody can discover is
|
|
335
|
+
// a requirement nobody meets: `harnessIntegrity` was undiscoverable and cost an
|
|
336
|
+
// adopter a lane that could never mint a valid receipt (NORTH-STAR §9.1). A
|
|
337
|
+
// declaration that only appears in a refusal repeats that, one level quieter. So
|
|
338
|
+
// the instrument names it while everything is GREEN, which is the only moment
|
|
339
|
+
// the reader is not already debugging something else.
|
|
340
|
+
for (const p of plants) {
|
|
341
|
+
if (p.step || p.vouching) continue;
|
|
342
|
+
out(
|
|
343
|
+
` ⓘ ${p.kind.padEnd(24)} asserted over the whole lane — this profile's \`plants.observedBy\` names no step ` +
|
|
344
|
+
`for it; naming one narrows the assertion to that row`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ── The badge floor, planted (NORTH-STAR §8.9, §6.7; GATE-RULES Rule 1) ─────
|
|
349
|
+
// "A profile with no calibrated plants earns no rung" is a guarantee, and until
|
|
350
|
+
// 2026-09-08 nothing checked it: two scratch adopters differing in exactly one
|
|
351
|
+
// export — one shipping `plants`, one shipping none — both earned L1. The floor
|
|
352
|
+
// now lives in the grader (qa/lib/evidence-level.mjs), and a floor nobody
|
|
353
|
+
// watches fail is a floor that quietly stops being there, so this plants the
|
|
354
|
+
// violation it exists to catch and asserts the refusal.
|
|
355
|
+
//
|
|
356
|
+
// It runs BEFORE the dirty-tree check because it touches nothing: no file, no
|
|
357
|
+
// lane, no receipt. That is also its limit and the line below says so — it
|
|
358
|
+
// proves what the grader WOULD give this ladder, not that a lane ran.
|
|
359
|
+
const badgeFloor = (() => {
|
|
360
|
+
const started = t();
|
|
361
|
+
const resolved = evidenceLadderFor(PROFILE_MODULE);
|
|
362
|
+
const assessed = resolved.ok
|
|
363
|
+
? assessBadgeFloor({ ladder: resolved.ladder, plants: PLANT_DECL })
|
|
364
|
+
: { available: false, reason: resolved.reason };
|
|
365
|
+
return { ...assessed, ms: t() - started };
|
|
366
|
+
})();
|
|
367
|
+
if (badgeFloor.ok === false) die(badgeFloor.reason);
|
|
368
|
+
out(
|
|
369
|
+
badgeFloor.available === false
|
|
370
|
+
? ` ⓘ ${"badge floor".padEnd(24)} not planted — ${badgeFloor.reason}`
|
|
371
|
+
: ` badge floor ${String(badgeFloor.ms).padStart(5)}ms ✓ this profile's ladder grades ${badgeFloor.rung} with its plants and NOTHING without them (grader only — no lane run)`,
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
// ── Refuse to start on a dirty tree ─────────────────────────────────────────
|
|
375
|
+
// Everything this may write, named up front. A file with uncommitted changes is
|
|
376
|
+
// a file whose bytes we would be gambling with if the process died mid-plant.
|
|
377
|
+
|
|
378
|
+
const touched = new Set([RECEIPT_REL, SURFACE_REL, README_REL]);
|
|
379
|
+
for (const p of plants) {
|
|
380
|
+
if (p.target.spec) touched.add(p.target.spec);
|
|
381
|
+
if (p.target.flow) touched.add(p.target.flow);
|
|
382
|
+
if (p.target.file) touched.add(p.target.file);
|
|
383
|
+
if (p.target.declaration) touched.add(p.target.declaration);
|
|
384
|
+
if (p.target.testDir) touched.add(PLANTED_TEST_REL(p.target.testDir));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
{
|
|
388
|
+
const res = spawnSync("git", ["status", "--porcelain", "--", ...touched], {
|
|
389
|
+
cwd: ROOT,
|
|
390
|
+
encoding: "utf8",
|
|
391
|
+
timeout: 10_000,
|
|
392
|
+
});
|
|
393
|
+
// Not a git repo (or no git): the in-memory restore still covers the normal
|
|
394
|
+
// path, so this is a warning, not a refusal — but say so, because the safety
|
|
395
|
+
// net is thinner than the one the message above promises.
|
|
396
|
+
if (res.status !== 0) {
|
|
397
|
+
out(` ⓘ no git here — falling back to in-memory restore only; do not interrupt with SIGKILL`);
|
|
398
|
+
} else {
|
|
399
|
+
const dirty = (res.stdout ?? "")
|
|
400
|
+
.split("\n")
|
|
401
|
+
.map((l) => l.slice(3).trim())
|
|
402
|
+
.filter(Boolean)
|
|
403
|
+
// The receipt and the badge are lane OUTPUT: this run rewrites them and
|
|
404
|
+
// puts them back, and they are routinely dirty mid-change. Refusing on
|
|
405
|
+
// them would make the instrument unrunnable exactly when it is wanted.
|
|
406
|
+
.filter((rel) => rel !== RECEIPT_REL && rel !== README_REL);
|
|
407
|
+
if (dirty.length) {
|
|
408
|
+
die(
|
|
409
|
+
`these files have uncommitted changes and this check plants into them:\n ${dirty.join("\n ")}\n` +
|
|
410
|
+
`Commit or stash them first — a plant that dies mid-run must not be able to lose your work.`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ── Making and unmaking a plant ─────────────────────────────────────────────
|
|
417
|
+
// Each plant is the SMALLEST edit that produces its violation, made against
|
|
418
|
+
// real content the tree already has. Planting garbage proves a gate rejects
|
|
419
|
+
// garbage; editing something live proves it was READING.
|
|
420
|
+
|
|
421
|
+
/** Every file a plant writes — the set `revertPlant` puts back. */
|
|
422
|
+
function plantFiles(plant) {
|
|
423
|
+
const rels = [];
|
|
424
|
+
if (plant.target.spec) rels.push(plant.target.spec);
|
|
425
|
+
if (plant.target.flows) rels.push(...plant.target.flows);
|
|
426
|
+
if (plant.target.file) rels.push(plant.target.file);
|
|
427
|
+
if (plant.target.declaration) rels.push(plant.target.declaration);
|
|
428
|
+
if (plant.target.testDir) rels.push(PLANTED_TEST_REL(plant.target.testDir));
|
|
429
|
+
if (plant.target.nestInto) {
|
|
430
|
+
for (const rel of plant.target.flows ?? []) rels.push(`${plant.target.nestInto}/${path.basename(rel)}`);
|
|
431
|
+
}
|
|
432
|
+
return rels;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function read(rel) {
|
|
436
|
+
return fs.readFileSync(abs(rel), "utf8");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Every citation line REMOVED — in whatever comment syntax this stack writes.
|
|
441
|
+
*
|
|
442
|
+
* This replaced a line that neutralised `^#\s*SPEC:` and wrote a `#` comment in
|
|
443
|
+
* its place, which is one stack's syntax twice over. It was survivable only
|
|
444
|
+
* while the plant selector was ALSO keyed to `#`: a `//`-citing project had
|
|
445
|
+
* both flow plants skipped, silently, and the two failures cancelled. Now that
|
|
446
|
+
* the selector reads the profile's declared marker, this one would be reached
|
|
447
|
+
* with a marker it cannot match — the citation would stay, the plant would not
|
|
448
|
+
* bite, and the instrument would report a framework defect that is its own.
|
|
449
|
+
*
|
|
450
|
+
* Removing the whole line rather than rewriting it is what makes this
|
|
451
|
+
* language-free: deleting a comment is safe in YAML, in every C-family flow, in
|
|
452
|
+
* Python and in Lua, whereas writing a replacement comment requires knowing the
|
|
453
|
+
* syntax. The marker is matched on the TRIMMED line, agreeing with
|
|
454
|
+
* `scanCitations` and with the selector about indented citations.
|
|
455
|
+
*/
|
|
456
|
+
function stripCitations(text, marker = SPEC_MODEL?.grammar?.citationMarker ?? /\bSPEC:/) {
|
|
457
|
+
// A profile could declare its marker with /g, which makes `.test()` stateful
|
|
458
|
+
// across calls. Rebuild without flags rather than trust the declaration.
|
|
459
|
+
const probe = new RegExp(marker.source);
|
|
460
|
+
return text
|
|
461
|
+
.split("\n")
|
|
462
|
+
.filter((line) => !probe.test(line.trim()))
|
|
463
|
+
.join("\n");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function applyPlant(plant) {
|
|
467
|
+
const { target } = plant;
|
|
468
|
+
switch (plant.kind) {
|
|
469
|
+
case PLANT_KINDS.ORPHANED_CITATION: {
|
|
470
|
+
// Rename a LIVE clause: every citation of it is suddenly an orphan, and
|
|
471
|
+
// specCoverage must name the id it can no longer find.
|
|
472
|
+
const text = read(target.spec);
|
|
473
|
+
write(target.spec, text.replace(`**${target.clause}**`, `**${target.clause}X**`));
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
case PLANT_KINDS.UNBOUND_CITATION: {
|
|
477
|
+
// A tag on a CLASS declaration with no test inside the binding window:
|
|
478
|
+
// the clause exists, the tag exists, and nothing runs.
|
|
479
|
+
const text = read(target.spec);
|
|
480
|
+
write(target.spec, `${text.trimEnd()}\n- **${target.clause}** — Given a planted clause, Then a class-level tag must not count.\n`);
|
|
481
|
+
write(PLANTED_TEST_REL(target.testDir), PROFILE_PLANTS.unboundCitationSource(target.clause));
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
case PLANT_KINDS.TIER_UNMET: {
|
|
485
|
+
// A clause only a device can observe, cited only from the JVM.
|
|
486
|
+
const text = read(target.spec);
|
|
487
|
+
write(
|
|
488
|
+
target.spec,
|
|
489
|
+
`${text.trimEnd()}\n- **${target.clause}** [tier: ${target.unmeetableTier}] — Given a planted clause only the target can observe, Then a host-tier citation cannot satisfy it.\n`,
|
|
490
|
+
);
|
|
491
|
+
write(PLANTED_TEST_REL(target.testDir), PROFILE_PLANTS.tierUnmetCitationSource(target.clause));
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
case PLANT_KINDS.FEATURE_WITHOUT_FLOW: {
|
|
495
|
+
// Every citation in every flow: a real feature with no device journey.
|
|
496
|
+
for (const rel of target.flows) write(rel, stripCitations(read(rel)));
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
case PLANT_KINDS.NESTED_FLOW: {
|
|
500
|
+
// The citations survive, in a subdirectory the lane never executes.
|
|
501
|
+
for (const rel of target.flows) {
|
|
502
|
+
const text = read(rel);
|
|
503
|
+
write(`${target.nestInto}/${path.basename(rel)}`, text);
|
|
504
|
+
write(rel, stripCitations(text));
|
|
505
|
+
}
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
case PLANT_KINDS.NARROWED_SURFACE: {
|
|
509
|
+
// One entry removed from the surface declaration un-attests a whole layer
|
|
510
|
+
// while every checker stays intact. Writing a narrow one where none
|
|
511
|
+
// existed is the same edit: the declaration enters the region unrecorded.
|
|
512
|
+
write(target.declaration, `${JSON.stringify({ surface: ["qa"] }, null, 2)}\n`);
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
case PLANT_KINDS.EDITED_LANE: {
|
|
516
|
+
// One byte in the machine-owned region: the lane issuing verdicts is no
|
|
517
|
+
// longer the lane this app was given.
|
|
518
|
+
write(target.file, `${read(target.file)}\n// planted by the framework check\n`);
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
default:
|
|
522
|
+
die(`unknown plant kind "${plant.kind}" — the selector and the runner have drifted apart`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function revertPlant(plant) {
|
|
527
|
+
for (const rel of [...plantFiles(plant), RECEIPT_REL]) {
|
|
528
|
+
if (!original.has(rel)) continue;
|
|
529
|
+
const text = original.get(rel);
|
|
530
|
+
try {
|
|
531
|
+
if (text === null) fs.rmSync(abs(rel), { force: true });
|
|
532
|
+
else fs.writeFileSync(abs(rel), text);
|
|
533
|
+
} catch (err) {
|
|
534
|
+
die(`could not revert ${rel} — ${err.message}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ── The check ───────────────────────────────────────────────────────────────
|
|
540
|
+
|
|
541
|
+
/** @type {Array<{label: string, ms: number}>} */
|
|
542
|
+
const cycles = [];
|
|
543
|
+
|
|
544
|
+
// The lane's own outputs, captured BEFORE the first run writes them. Do this
|
|
545
|
+
// late and you capture the baseline's receipt instead of the tree's real one —
|
|
546
|
+
// and then "restoring" leaves a smoke receipt sitting where an L1/L2 receipt
|
|
547
|
+
// used to be, which is precisely the weaker-evidence swap this harness refuses.
|
|
548
|
+
remember(RECEIPT_REL);
|
|
549
|
+
remember(README_REL);
|
|
550
|
+
|
|
551
|
+
try {
|
|
552
|
+
// 1. Baseline — this tree must be green before anything is planted, or every
|
|
553
|
+
// FAIL below is unattributable.
|
|
554
|
+
const base = runSmoke();
|
|
555
|
+
cycles.push({ label: "baseline", ms: base.ms });
|
|
556
|
+
const baseVerdict = assessGreenRun(base, "baseline", BOUND_MS);
|
|
557
|
+
if (!baseVerdict.ok) die(baseVerdict.reason);
|
|
558
|
+
if (base.receipt.stage !== "smoke") die(`receipt names stage "${base.receipt.stage}", expected "smoke"`);
|
|
559
|
+
out(` baseline ${String(base.ms).padStart(5)}ms ✓ ${base.receipt.steps.length} steps, verdict PASS, stage smoke`);
|
|
560
|
+
|
|
561
|
+
// 2. One planted violation per guard, each asserted to FAIL BY NAME, each
|
|
562
|
+
// reverted before the next. A guard that has only ever passed is an unread
|
|
563
|
+
// instrument; this is where each one is read.
|
|
564
|
+
for (const plant of plants) {
|
|
565
|
+
applyPlant(plant);
|
|
566
|
+
const run = runSmoke();
|
|
567
|
+
cycles.push({ label: plant.label, ms: run.ms });
|
|
568
|
+
const verdict = assessPlantRun(run, plant, BOUND_MS);
|
|
569
|
+
if (!verdict.ok) die(verdict.reason);
|
|
570
|
+
|
|
571
|
+
if (plant.hookPattern) {
|
|
572
|
+
// The hook refuses a smoke-stage receipt on its stage and a FAIL receipt
|
|
573
|
+
// on its verdict, before the vouching check runs. The vouching guard
|
|
574
|
+
// exists for the FORGERY — the top-level verdict edited to PASS over rows
|
|
575
|
+
// that say the lane cannot vouch for itself — so that is the receipt
|
|
576
|
+
// presented: same rows, change stage, verdict flipped.
|
|
577
|
+
write(RECEIPT_REL, JSON.stringify({ ...run.receipt, profile: "local", stage: "change", verdict: "PASS" }, null, 2));
|
|
578
|
+
const h = hookRefuses();
|
|
579
|
+
if (!h.refused || !new RegExp(plant.hookPattern, "i").test(h.stderr)) {
|
|
580
|
+
die(`the Stop hook did not refuse the forged "${plant.label}" receipt for the right reason:\n${h.stderr.slice(-400)}`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// NAME THE ROW THAT ACTUALLY WENT RED, not the one the plant asked for.
|
|
585
|
+
// For a plant whose step this pack did not declare those are different
|
|
586
|
+
// strings — the plant asked for nothing — and "undefined FAIL naming
|
|
587
|
+
// CART-01" is a line that makes a working run look broken. It is also the
|
|
588
|
+
// more useful line in every case: on a pack the reader does not know, this
|
|
589
|
+
// is where they learn which of their own gates is calibrated.
|
|
590
|
+
const named = plant.names.length ? ` naming ${plant.names.join(", ")}` : "";
|
|
591
|
+
const observed = plantRow(run.receipt.steps, plant)?.name ?? plant.step ?? "the lane";
|
|
592
|
+
out(` FAIL: ${plant.label.padEnd(28)} ${String(run.ms).padStart(5)}ms ✓ ${observed} FAIL${named}`);
|
|
593
|
+
revertPlant(plant);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// 3. The Stop hook refuses a real FAIL receipt — the framework's last link.
|
|
597
|
+
{
|
|
598
|
+
const first = plants[0];
|
|
599
|
+
applyPlant(first);
|
|
600
|
+
const r = runSmoke();
|
|
601
|
+
if (!r.receipt || r.receipt.verdict !== "FAIL") die("could not produce a FAIL receipt for the hook check");
|
|
602
|
+
// ASK THE HOOK BEFORE REVERTING. `revertPlant` restores RECEIPT_REL from
|
|
603
|
+
// what was on disk when this run started, so reverting first meant the hook
|
|
604
|
+
// was never shown the FAIL receipt the lane had just written. On a tree with
|
|
605
|
+
// no prior receipt the revert DELETED it and the hook refused because there
|
|
606
|
+
// was no receipt at all — green, for a reason that has nothing to do with
|
|
607
|
+
// this assertion. On a tree where the lane had already run, the revert put
|
|
608
|
+
// the earlier PASS back and the check failed on a working lane. Neither
|
|
609
|
+
// outcome tested "the Stop hook refuses a FAIL receipt". A check that cannot
|
|
610
|
+
// fail for its own reason is the exact defect this instrument exists to
|
|
611
|
+
// catch, which is why it is worth the four lines to say so. Found by the
|
|
612
|
+
// first non-Compose adoption, 2026-09-04. (`die` is safe here: every touched
|
|
613
|
+
// file is restored in the outer finally, plant reverted or not.)
|
|
614
|
+
if (!hookRefuses().refused) die("the Stop hook did not refuse a FAIL receipt");
|
|
615
|
+
revertPlant(first);
|
|
616
|
+
out(` Stop hook refuses a FAIL receipt ✓`);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// 4. And a receipt whose device tier SKIPped for an ENVIRONMENTAL reason: a
|
|
620
|
+
// synthetic row over this tree's own valid hash, so only the skip is new.
|
|
621
|
+
{
|
|
622
|
+
const green = runSmoke();
|
|
623
|
+
if (!green.receipt || green.receipt.verdict !== "PASS") {
|
|
624
|
+
// NAME THE STEPS. "could not produce a PASS receipt" told a reader that
|
|
625
|
+
// something was wrong and nothing about what, in the one place where the
|
|
626
|
+
// instrument has the whole receipt in hand. Evidence-or-silence applies to
|
|
627
|
+
// the instrument's own refusals too.
|
|
628
|
+
const bad = (green.receipt?.steps ?? [])
|
|
629
|
+
.filter((s) => s.verdict === "FAIL" || s.verdict === "ERROR")
|
|
630
|
+
.map((s) => `${s.name}: ${s.reason ?? s.verdict}`);
|
|
631
|
+
die(
|
|
632
|
+
`could not produce a PASS receipt for the device-tier hook check` +
|
|
633
|
+
(bad.length ? ` — ${bad.join("; ")}` : green.receipt ? ` (verdict ${green.receipt.verdict}, no failing step)` : " (no receipt at all)"),
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
const planted = {
|
|
637
|
+
...green.receipt,
|
|
638
|
+
profile: "local",
|
|
639
|
+
stage: "change",
|
|
640
|
+
steps: [
|
|
641
|
+
...green.receipt.steps,
|
|
642
|
+
{ name: "e2eSmoke", verdict: "SKIP", skipKind: "environment", reason: "device tier disabled by CMP_DEVICE=none (planted)", durationMs: 0 },
|
|
643
|
+
],
|
|
644
|
+
};
|
|
645
|
+
write(RECEIPT_REL, JSON.stringify(planted, null, 2));
|
|
646
|
+
const hook = hookRefuses();
|
|
647
|
+
// Stage 0 PR 6c: the refusal is stack-free — ANY step that skipped for an
|
|
648
|
+
// environmental reason blocks done, whatever it is called.
|
|
649
|
+
if (!hook.refused || !/a tier did not run/.test(hook.stderr)) {
|
|
650
|
+
die(`the Stop hook did not refuse a receipt whose tier was skipped for an environmental reason:\n${hook.stderr.slice(-400)}`);
|
|
651
|
+
}
|
|
652
|
+
out(` Stop hook refuses a skipped tier ✓`);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// 4b. THE VACUITY FLOOR (ADR-0010) — the one plant that cannot be made in
|
|
656
|
+
// this tree.
|
|
657
|
+
//
|
|
658
|
+
// Its violation is the ABSENCE of the engine, so planting it here would
|
|
659
|
+
// delete the files this instrument is running from: the lane would fail to
|
|
660
|
+
// START rather than fail by name, and a plant that proves the tree is broken
|
|
661
|
+
// proves nothing about the gate. So the region is built in a scratch
|
|
662
|
+
// directory, locked, and the refusal is watched there.
|
|
663
|
+
//
|
|
664
|
+
// The exception is granted for one reason and holds only while that reason
|
|
665
|
+
// does: this is the only gate whose violation removes the instrument that
|
|
666
|
+
// would observe it. A future gate wanting an out-of-tree plant must show the
|
|
667
|
+
// same property, not cite this one.
|
|
668
|
+
{
|
|
669
|
+
const started = Date.now();
|
|
670
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "vacuity-floor-"));
|
|
671
|
+
try {
|
|
672
|
+
fs.mkdirSync(path.join(scratch, "qa"), { recursive: true });
|
|
673
|
+
// A region of declarations and nothing else — ADR-0008's probe, exactly.
|
|
674
|
+
fs.writeFileSync(path.join(scratch, "qa", "verified-surface.json"), '{"surface":["src"]}\n');
|
|
675
|
+
fs.writeFileSync(path.join(scratch, "qa", "harness-manifest.json"), '{"schema":"harness-manifest/1"}\n');
|
|
676
|
+
writeHarnessLock(scratch, { version: "0.0.0-plant" });
|
|
677
|
+
const seen = checkHarnessIntegrity(scratch);
|
|
678
|
+
if (!seen.vacuous || seen.engineFiles !== 0) {
|
|
679
|
+
die(`the vacuity floor did not see an engine-free region: engineFiles=${seen.engineFiles}, vacuous=${seen.vacuous}`);
|
|
680
|
+
}
|
|
681
|
+
if (seen.status !== "intact") {
|
|
682
|
+
die(`the plant is only meaningful while the region reads intact — it read "${seen.status}", so the floor is being proved against the wrong state`);
|
|
683
|
+
}
|
|
684
|
+
// FAILING BY NAME is the requirement (§8.8), and the name has to reach a
|
|
685
|
+
// human. `describeIntegrity` is the shared voice every caller renders, so
|
|
686
|
+
// the plant watches THAT refuse — a flag nobody prints would be a gate
|
|
687
|
+
// nobody sees.
|
|
688
|
+
const said = describeIntegrity(seen);
|
|
689
|
+
if (!/not a lane/.test(said) || /files verified/.test(said)) {
|
|
690
|
+
die(`the vacuity floor is invisible to every caller that renders an integrity result: describeIntegrity said "${said}"`);
|
|
691
|
+
}
|
|
692
|
+
const ms = Date.now() - started;
|
|
693
|
+
cycles.push({ label: "vacuity floor", ms });
|
|
694
|
+
out(` FAIL: region holds no engine ${String(ms).padStart(5)}ms ✓ intact but vacuous, and it SAYS so — ${seen.fileCount} file(s), 0 engine`);
|
|
695
|
+
} finally {
|
|
696
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// 5. Everything reverted, and it passes again — the plants were the only cause.
|
|
701
|
+
restoreAll();
|
|
702
|
+
const again = runSmoke();
|
|
703
|
+
cycles.push({ label: "revert → PASS", ms: again.ms });
|
|
704
|
+
const againVerdict = assessGreenRun(again, "revert", BOUND_MS);
|
|
705
|
+
if (!againVerdict.ok) die(againVerdict.reason);
|
|
706
|
+
out(` revert → PASS ${String(again.ms).padStart(5)}ms ✓`);
|
|
707
|
+
} finally {
|
|
708
|
+
restoreAll();
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ── Report ──────────────────────────────────────────────────────────────────
|
|
712
|
+
// The cost is part of the finding. Rule 1 calls a calibration "four steps,
|
|
713
|
+
// seconds each"; a cycle past the budget means the plant is running through the
|
|
714
|
+
// wrong instrument, and that is worth saying while the choice is cheap.
|
|
715
|
+
|
|
716
|
+
const cost = assessCalibrationCost(cycles, BUDGET_MS);
|
|
717
|
+
|
|
718
|
+
if (asJson) {
|
|
719
|
+
process.stdout.write(
|
|
720
|
+
`${JSON.stringify(
|
|
721
|
+
{
|
|
722
|
+
verdict: "PASS",
|
|
723
|
+
plants: plants.map((p) => ({ kind: p.kind, label: p.label, step: p.step })),
|
|
724
|
+
unavailable,
|
|
725
|
+
// Reported beside the tree plants, never counted as one: it plants into
|
|
726
|
+
// the GRADER rather than into the tree, and a reader comparing plant
|
|
727
|
+
// counts across runs must not see this one move the number.
|
|
728
|
+
badgeFloor,
|
|
729
|
+
cycles,
|
|
730
|
+
totalMs: cost.totalMs,
|
|
731
|
+
budgetMs: BUDGET_MS,
|
|
732
|
+
withinBudget: cost.withinBudget,
|
|
733
|
+
note: cost.note,
|
|
734
|
+
},
|
|
735
|
+
null,
|
|
736
|
+
2,
|
|
737
|
+
)}\n`,
|
|
738
|
+
);
|
|
739
|
+
} else {
|
|
740
|
+
out(
|
|
741
|
+
`\nframework check: PASS — the lane returns, both ways, and every guard fails by name: ` +
|
|
742
|
+
`${plants.length} plants, ${cost.totalMs}ms total (bound ${BOUND_MS}ms per direction).`,
|
|
743
|
+
);
|
|
744
|
+
if (!cost.withinBudget) out(` ⚠ ${cost.note}`);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
process.exit(0);
|