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
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// agent-hold.mjs — "an agent is working in this tree right now."
|
|
2
|
+
//
|
|
3
|
+
// TWO PROBLEMS, ONE MISSING FACT. Both were reported from payment-blueprint's
|
|
4
|
+
// adoption on 2026-09-04, and both are the same absence:
|
|
5
|
+
//
|
|
6
|
+
// 1. LIVENESS. There was no way to answer "is the agent working, or wedged?"
|
|
7
|
+
// without filesystem archaeology. The lead architect guessed twice with two
|
|
8
|
+
// separately broken instruments — a `find` that excluded `build/` (the only
|
|
9
|
+
// directory a proof run writes to) and a `find -newermt` that reported zero
|
|
10
|
+
// writes in 45 minutes while `ls -lT` showed one at 10 — and on the first
|
|
11
|
+
// guess killed a healthy agent mid-proof. An instrument that cannot see the
|
|
12
|
+
// thing it exists to detect is worse than no instrument: it is confidently
|
|
13
|
+
// wrong, which is the same failure class GATE-RULES Rule 1 exists for.
|
|
14
|
+
//
|
|
15
|
+
// 2. FALSE ALARMS. The Stop hook fires identically whether a receipt is stale
|
|
16
|
+
// because nobody ran the lane or because a subagent is mid-commit on a
|
|
17
|
+
// half-adopted port. It fired ~15 times in one evening while the correct
|
|
18
|
+
// action every time was to WAIT. Anthropic's tool-design guidance is
|
|
19
|
+
// explicit that an error must communicate "specific and actionable
|
|
20
|
+
// improvements"; an alarm whose advice is wrong every time it fires trains
|
|
21
|
+
// its reader to ignore it, which is strictly worse than silence.
|
|
22
|
+
//
|
|
23
|
+
// WHAT THIS IS NOT. It is not a lock — nothing waits on it, nothing is excluded
|
|
24
|
+
// by it. It is not a second journal: the flight recorder still owns lane history
|
|
25
|
+
// that belongs in the repo. It is a DECLARATION with an expiry, in the same
|
|
26
|
+
// ephemeral, gitignored, hash-excluded family as qa/.plan.json and
|
|
27
|
+
// qa/.request.json — because a fact about who is typing must never be able to
|
|
28
|
+
// invalidate a receipt.
|
|
29
|
+
//
|
|
30
|
+
// A HOLD CHANGES THE ADVICE, NEVER THE VERDICT. The Stop hook still refuses:
|
|
31
|
+
// no receipt yet means not done, and a file an agent writes about itself must
|
|
32
|
+
// never be able to end a turn — that would be turning the gate off by writing a
|
|
33
|
+
// file, which is the attack the whole harness exists to refuse. This follows the
|
|
34
|
+
// precedent already set for a lane in flight (qa/receipt-check.mjs): same
|
|
35
|
+
// refusal, different instruction. "Run the lane" is wrong advice when the tree
|
|
36
|
+
// is mid-edit and would not compile, and a gate that tells you to do the thing
|
|
37
|
+
// you are already doing trains you to stop reading it.
|
|
38
|
+
//
|
|
39
|
+
// THE ASYMMETRY THAT KEEPS IT HONEST. A hold EXPLAINS the absence of fresh
|
|
40
|
+
// evidence. It never explains CONTRADICTING evidence. A red receipt, a forged
|
|
41
|
+
// receipt, a skipped device tier: for those the hold is not the reason and
|
|
42
|
+
// saying so would mislead. Only two refusals are explicable by a hold — "no
|
|
43
|
+
// receipt yet" and "the tree moved since a PASSing receipt" — exactly the two
|
|
44
|
+
// states a working agent legitimately produces, and nothing else.
|
|
45
|
+
//
|
|
46
|
+
// SINGLE SOURCE OF TRUTH: packages/harness/src/lib/agent-hold.mjs in the
|
|
47
|
+
// create-cmp repo. The copy in a generated project's qa/lib/ is vendored
|
|
48
|
+
// byte-identical at scaffold time — edit the package source, then run
|
|
49
|
+
// `node scripts/sync-harness.mjs`.
|
|
50
|
+
|
|
51
|
+
import fs from "node:fs";
|
|
52
|
+
import path from "node:path";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Ephemeral, gitignored, and excluded from the receipt's hashed input surface
|
|
56
|
+
* (qa/lib/inputs-hash.mjs EXCLUDED_PREFIXES) — the same family as .plan.json.
|
|
57
|
+
*/
|
|
58
|
+
export const HOLD_REL = "qa/.agent-hold.json";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A heartbeat older than this is a crashed writer, not a live agent. The same
|
|
62
|
+
* bound every other marker consumer in this lane applies (qa/lib/plan.mjs), for
|
|
63
|
+
* the same reason: a process that dies leaves its file behind, so freshness —
|
|
64
|
+
* never presence — is what makes a marker mean anything.
|
|
65
|
+
*/
|
|
66
|
+
export const HEARTBEAT_FRESH_MS = 5 * 60 * 1000;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A hold this old is a wedge, not work. Past the ceiling the hook resumes
|
|
70
|
+
* blocking even while heartbeats keep arriving: an agent that has held the tree
|
|
71
|
+
* for three quarters of an hour is exactly the case the human needed to see, and
|
|
72
|
+
* a heartbeat proves the process is alive, never that it is making progress.
|
|
73
|
+
*/
|
|
74
|
+
export const HOLD_CEILING_MS = 45 * 60 * 1000;
|
|
75
|
+
|
|
76
|
+
const MAX_TEXT = 200;
|
|
77
|
+
|
|
78
|
+
const clip = (s, n = MAX_TEXT) => (typeof s === "string" ? s.trim().slice(0, n) : "");
|
|
79
|
+
|
|
80
|
+
/** Fail-soft like every other status reader here: unreadable reads as absent. */
|
|
81
|
+
export function readHold(root) {
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(root, ...HOLD_REL.split("/")), "utf8"));
|
|
84
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function writeJson(root, value) {
|
|
91
|
+
try {
|
|
92
|
+
const p = path.join(root, ...HOLD_REL.split("/"));
|
|
93
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
94
|
+
fs.writeFileSync(p, `${JSON.stringify(value, null, 2)}\n`);
|
|
95
|
+
return { ok: true, hold: value };
|
|
96
|
+
} catch (err) {
|
|
97
|
+
return { ok: false, error: err.message };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Claim the tree. `holder` is a name a human will read in an alarm — an agent
|
|
103
|
+
* or session label, not a UUID: Anthropic's tool guidance is that agents (and
|
|
104
|
+
* the people reading after them) do far better with natural-language
|
|
105
|
+
* identifiers than with opaque ids, and this string's whole job is to be read
|
|
106
|
+
* at 3am by someone deciding whether to kill a process.
|
|
107
|
+
*/
|
|
108
|
+
export function claimHold(root, { holder, note = "", now = Date.now() } = {}) {
|
|
109
|
+
const name = clip(holder, 80) || "an agent";
|
|
110
|
+
const existing = readHold(root);
|
|
111
|
+
const at = existing && assessHold(existing, now).held ? existing.at : new Date(now).toISOString();
|
|
112
|
+
return writeJson(root, {
|
|
113
|
+
holder: name,
|
|
114
|
+
note: clip(note),
|
|
115
|
+
at,
|
|
116
|
+
heartbeatAt: new Date(now).toISOString(),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Still here, still working. Optionally re-states what "here" means — an agent
|
|
122
|
+
* that only says "alive" is barely better than the `find` that started this.
|
|
123
|
+
*/
|
|
124
|
+
export function beatHold(root, { note, now = Date.now() } = {}) {
|
|
125
|
+
const existing = readHold(root);
|
|
126
|
+
if (!existing) return { ok: false, error: "no hold to beat — claim one first" };
|
|
127
|
+
return writeJson(root, {
|
|
128
|
+
...existing,
|
|
129
|
+
note: note === undefined ? existing.note : clip(note),
|
|
130
|
+
heartbeatAt: new Date(now).toISOString(),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function releaseHold(root) {
|
|
135
|
+
try {
|
|
136
|
+
fs.rmSync(path.join(root, ...HOLD_REL.split("/")), { force: true });
|
|
137
|
+
return { ok: true };
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return { ok: false, error: err.message };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Is a hold in force, and what does it say?
|
|
145
|
+
*
|
|
146
|
+
* Every negative branch names WHY, because this feeds an alarm's text: "no
|
|
147
|
+
* agent holds the tree" and "an agent claimed it 50 minutes ago and is past the
|
|
148
|
+
* ceiling" call for opposite actions by the human reading them.
|
|
149
|
+
*
|
|
150
|
+
* @param {object|null} hold
|
|
151
|
+
* @param {number} now
|
|
152
|
+
* @returns {{held: boolean, reason: string, holder?: string, note?: string,
|
|
153
|
+
* heldMs?: number, sinceBeatMs?: number, expired?: boolean}}
|
|
154
|
+
*/
|
|
155
|
+
export function assessHold(hold, now = Date.now()) {
|
|
156
|
+
if (!hold || typeof hold !== "object") return { held: false, reason: "no agent holds the tree" };
|
|
157
|
+
const at = Date.parse(hold.at);
|
|
158
|
+
const beat = Date.parse(hold.heartbeatAt ?? hold.at);
|
|
159
|
+
if (Number.isNaN(at) || Number.isNaN(beat)) return { held: false, reason: "the hold file has no readable timestamp" };
|
|
160
|
+
|
|
161
|
+
const heldMs = Math.max(0, now - at);
|
|
162
|
+
const sinceBeatMs = Math.max(0, now - beat);
|
|
163
|
+
const holder = clip(hold.holder, 80) || "an agent";
|
|
164
|
+
const note = clip(hold.note);
|
|
165
|
+
const base = { holder, note, heldMs, sinceBeatMs };
|
|
166
|
+
|
|
167
|
+
if (sinceBeatMs > HEARTBEAT_FRESH_MS) {
|
|
168
|
+
return {
|
|
169
|
+
...base,
|
|
170
|
+
held: false,
|
|
171
|
+
expired: true,
|
|
172
|
+
reason: `${holder} last checked in ${formatAge(sinceBeatMs)} — that is a crashed writer, not a live agent`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (heldMs > HOLD_CEILING_MS) {
|
|
176
|
+
return {
|
|
177
|
+
...base,
|
|
178
|
+
held: false,
|
|
179
|
+
expired: true,
|
|
180
|
+
reason: `${holder} has held the tree for ${formatAge(heldMs)}, past the ${formatAge(HOLD_CEILING_MS)} ceiling — a heartbeat proves the process is alive, not that it is progressing`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return { ...base, held: true, reason: `${holder} has held the tree for ${formatAge(heldMs)}` };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The ONE line an alarm prints instead of demanding a lane run. It says who,
|
|
188
|
+
* since when, what they said they were doing, and what the reader should do —
|
|
189
|
+
* "specific and actionable", which the alarm it replaces was not.
|
|
190
|
+
*/
|
|
191
|
+
export function describeHold(assessment) {
|
|
192
|
+
if (!assessment?.held) return null;
|
|
193
|
+
const what = assessment.note ? ` (${assessment.note})` : "";
|
|
194
|
+
return (
|
|
195
|
+
`${assessment.holder} has held this tree for ${formatAge(assessment.heldMs)}${what} — staleness is expected while it works. ` +
|
|
196
|
+
`Wait for it rather than starting a lane on a half-edited tree; \`node qa/plan.mjs --release\` if it is gone.`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Does a hold EXPLAIN this refusal? (It never lifts it — see the header.)
|
|
202
|
+
*
|
|
203
|
+
* The whitelist is the safety property, and it is deliberately two entries
|
|
204
|
+
* long. A hold explains why fresh evidence is ABSENT — no receipt yet, or the
|
|
205
|
+
* tree has moved under one — because those are the two states a working agent
|
|
206
|
+
* legitimately produces. It never explains a receipt that says something is
|
|
207
|
+
* wrong: for a FAIL, a forgery, a skipped device tier or an unreadable surface
|
|
208
|
+
* the hold is simply not the cause, and offering it as one would send the
|
|
209
|
+
* reader to wait for an agent when the actual problem is a red test. Inverting
|
|
210
|
+
* this to a blacklist would mean every refusal added later is treated as
|
|
211
|
+
* agent-explicable by default, which is how an alarm starts lying.
|
|
212
|
+
*
|
|
213
|
+
* @param {{valid: boolean, reason?: string}} result
|
|
214
|
+
* @param {object|null} receipt
|
|
215
|
+
* @returns {boolean}
|
|
216
|
+
*/
|
|
217
|
+
export function holdExplains(result, receipt) {
|
|
218
|
+
if (!result || result.valid) return false;
|
|
219
|
+
// No receipt at all: the agent has not finished enough to run the lane.
|
|
220
|
+
if (receipt === null || receipt === undefined) return true;
|
|
221
|
+
// The tree moved under a receipt that itself PASSED — the signature of an
|
|
222
|
+
// agent mid-edit. A receipt that was not a PASS is contradicting evidence and
|
|
223
|
+
// is never excused, whatever moved since.
|
|
224
|
+
return receipt.verdict === "PASS" && /^source changed since the receipt/.test(String(result.reason ?? ""));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** "40s" / "12 min" / "1h 5m" — freshness a human can weigh at a glance. */
|
|
228
|
+
export function formatAge(ms) {
|
|
229
|
+
if (!(ms >= 0)) return "an unknown time";
|
|
230
|
+
if (ms < 90_000) return `${Math.round(ms / 1000)}s`;
|
|
231
|
+
if (ms < 90 * 60_000) return `${Math.round(ms / 60_000)} min`;
|
|
232
|
+
const h = Math.floor(ms / 3_600_000);
|
|
233
|
+
return `${h}h ${Math.round((ms - h * 3_600_000) / 60_000)}m`;
|
|
234
|
+
}
|
|
@@ -149,10 +149,10 @@ export function updateReadmeBadge(root) {
|
|
|
149
149
|
// The same rule for the two other receipts qa/receipt-check.mjs refuses as
|
|
150
150
|
// done-evidence: smoke (Rule 0 — proves the framework, never the change) and
|
|
151
151
|
// nightly (proves the harness and the tree's invariants). Both derive no
|
|
152
|
-
// rung, and a smoke run —
|
|
153
|
-
//
|
|
154
|
-
// 2026-09-03 by deriving the affected filter on a fresh app:
|
|
155
|
-
// the dirty file. Receipts predating `stage` are read by profile.
|
|
152
|
+
// rung, and a smoke run — qa/framework-check.mjs runs several, and restores
|
|
153
|
+
// this file afterwards — was rewriting a true L1 badge to "rung unrecorded".
|
|
154
|
+
// Found on 2026-09-03 by deriving the affected filter on a fresh app:
|
|
155
|
+
// README.md was the dirty file. Receipts predating `stage` are read by profile.
|
|
156
156
|
const stage = receipt && (typeof receipt.stage === "string" ? receipt.stage : receipt.profile);
|
|
157
157
|
if (stage === "smoke" || stage === "nightly") {
|
|
158
158
|
return { changed: false, reason: `${stage} run — refused as done-evidence, so the badge is left as it stands` };
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// framework-check.mjs (lib) — Rule 0's instrument, aimed at an app's OWN tree.
|
|
2
|
+
//
|
|
3
|
+
// GATE-RULES Rule 0 says: before any real work is pointed at the harness, prove
|
|
4
|
+
// the FRAMEWORK returns — a deterministic PASS and a deterministic FAIL, fast,
|
|
5
|
+
// through the real lane machinery, with a bound short enough that a hang is
|
|
6
|
+
// obvious rather than patient.
|
|
7
|
+
//
|
|
8
|
+
// create-cmp's own `scripts/framework-check.mjs` proves that for the ENGINE: it
|
|
9
|
+
// stamps a scratch app and reads the lane it just shipped. That script has never
|
|
10
|
+
// existed inside a generated project, and it never can — it needs `bin/create-
|
|
11
|
+
// cmp.mjs` and a tree to stamp. Meanwhile the lane that DOES ship names it four
|
|
12
|
+
// times (verify.mjs, steps-cmp.mjs, evidence-badge.mjs, USAGE.md), pointing every
|
|
13
|
+
// adopter at a path they do not have.
|
|
14
|
+
//
|
|
15
|
+
// That dangling reference has a measured cost. payment-blueprint read those
|
|
16
|
+
// comments, could not find the file, and hand-built its own copy with nine
|
|
17
|
+
// plants; then, months later, briefed a whole wave to prove new gates by hand —
|
|
18
|
+
// plant, `./gradlew`, confirm red, revert, build again, 30–60 s per cycle — and
|
|
19
|
+
// burned ~38 minutes reproducing exactly what the missing instrument does in
|
|
20
|
+
// seconds. Their own diagnosis was "I failed to check what already existed". The
|
|
21
|
+
// truer reading is narrower and is ours: the harness advertised a tool it never
|
|
22
|
+
// handed over, so there was nothing in their tree to find.
|
|
23
|
+
//
|
|
24
|
+
// This module is the half of that instrument an app can run against itself. The
|
|
25
|
+
// plants are DERIVED from the tree rather than hardcoded, because an adopted
|
|
26
|
+
// project is not a stamped Compose app: it may have no `specs/`, no `qa/e2e/`,
|
|
27
|
+
// no Kotlin test source at all. A plant whose target is absent is reported as
|
|
28
|
+
// unavailable WITH ITS REASON and does not silently vanish — a framework check
|
|
29
|
+
// that skips everything and prints PASS is the failure this exists to refuse.
|
|
30
|
+
//
|
|
31
|
+
// Pure by construction: every function here takes data and returns data. The IO
|
|
32
|
+
// — reading the tree, writing the plant, running the lane, reverting in a
|
|
33
|
+
// `finally` — lives in the runner (qa/framework-check.mjs), so the decisions
|
|
34
|
+
// this file makes are unit-testable without a scaffold.
|
|
35
|
+
//
|
|
36
|
+
// SINGLE SOURCE OF TRUTH: packages/harness/src/lib/framework-check.mjs in the
|
|
37
|
+
// create-cmp repo. The copy in a generated project's qa/lib/ is vendored
|
|
38
|
+
// byte-identical at scaffold time — edit the package source, then run
|
|
39
|
+
// `node scripts/sync-harness.mjs`.
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Per-direction bound. Rule 0's whole claim is about SPEED of refusal, so the
|
|
43
|
+
* default is small on purpose: the smoke profile is every pure-Node gate and no
|
|
44
|
+
* Gradle, which returns in around a second on a real tree. A direction that
|
|
45
|
+
* does not return inside the bound is killed and reported as a hang — the bound
|
|
46
|
+
* IS the assertion, never a courtesy timeout waited out.
|
|
47
|
+
*/
|
|
48
|
+
export const DEFAULT_BOUND_MS = 10_000;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Every plant this instrument knows how to make. The kinds are named so tests
|
|
52
|
+
* (and a report) can talk about them without matching prose.
|
|
53
|
+
*/
|
|
54
|
+
export const PLANT_KINDS = Object.freeze({
|
|
55
|
+
ORPHANED_CITATION: "orphaned-citation",
|
|
56
|
+
UNBOUND_CITATION: "unbound-citation",
|
|
57
|
+
TIER_UNMET: "tier-unmet",
|
|
58
|
+
FEATURE_WITHOUT_FLOW: "feature-without-flow",
|
|
59
|
+
NESTED_FLOW: "flow-the-lane-never-runs",
|
|
60
|
+
NARROWED_SURFACE: "narrowed-surface",
|
|
61
|
+
EDITED_LANE: "edited-lane",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The plants that need nothing but a lane. `harnessIntegrity` reads the machine-
|
|
66
|
+
* owned region, which exists in every project that has a lane at all — so these
|
|
67
|
+
* two are the floor. If even these cannot run, the tree has no harness to check
|
|
68
|
+
* and the instrument must say so rather than report a vacuous PASS.
|
|
69
|
+
*/
|
|
70
|
+
export const FLOOR_KINDS = Object.freeze([PLANT_KINDS.NARROWED_SURFACE, PLANT_KINDS.EDITED_LANE]);
|
|
71
|
+
|
|
72
|
+
/** A clause id at the head of a spec list item: `- **HOME-02** — …`. */
|
|
73
|
+
const CLAUSE_RE = /^-\s+\*\*([A-Z][A-Z0-9]*-\d{2,})\*\*/m;
|
|
74
|
+
|
|
75
|
+
/** `# SPEC: HOME-02` in a flow file — the citation an e2e journey carries. */
|
|
76
|
+
const FLOW_CITATION_RE = /^#\s*SPEC:\s*([A-Z][A-Z0-9]*-\d{2,})/m;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The first clause id in a spec, or null. Used to pick something real to plant
|
|
80
|
+
* against: a clause that already exists and is already cited is the only kind
|
|
81
|
+
* whose removal proves a gate READS, rather than proving a gate rejects garbage.
|
|
82
|
+
* @param {string} text
|
|
83
|
+
* @returns {string|null}
|
|
84
|
+
*/
|
|
85
|
+
export function firstClauseId(text) {
|
|
86
|
+
if (typeof text !== "string") return null;
|
|
87
|
+
const m = text.match(CLAUSE_RE);
|
|
88
|
+
return m ? m[1] : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The clause a flow cites, or null.
|
|
93
|
+
* @param {string} text
|
|
94
|
+
* @returns {string|null}
|
|
95
|
+
*/
|
|
96
|
+
export function flowCitation(text) {
|
|
97
|
+
if (typeof text !== "string") return null;
|
|
98
|
+
const m = text.match(FLOW_CITATION_RE);
|
|
99
|
+
return m ? m[1] : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The clause-id PREFIX ("HOME" from "HOME-02"), used to mint planted ids that
|
|
104
|
+
* cannot collide with a real clause: a spec's own family with a number far above
|
|
105
|
+
* anything hand-authored.
|
|
106
|
+
* @param {string} clause
|
|
107
|
+
* @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
export function clauseFamily(clause) {
|
|
110
|
+
const m = String(clause ?? "").match(/^([A-Z][A-Z0-9]*)-/);
|
|
111
|
+
return m ? m[1] : "SPEC";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Decide which plants this tree can support, and say WHY each unavailable one
|
|
116
|
+
* is unavailable.
|
|
117
|
+
*
|
|
118
|
+
* @param {{specs?: Array<{rel: string, text: string}>,
|
|
119
|
+
* flows?: Array<{rel: string, text: string}>,
|
|
120
|
+
* harnessLib?: string[],
|
|
121
|
+
* testDir?: string|null}} tree
|
|
122
|
+
* @returns {{plants: Array<{kind: string, label: string, step: string,
|
|
123
|
+
* names: string[], target: object}>,
|
|
124
|
+
* unavailable: Array<{kind: string, reason: string}>}}
|
|
125
|
+
*/
|
|
126
|
+
export function selectPlants(tree) {
|
|
127
|
+
const specs = Array.isArray(tree?.specs) ? tree.specs : [];
|
|
128
|
+
const flows = Array.isArray(tree?.flows) ? tree.flows : [];
|
|
129
|
+
const harnessLib = Array.isArray(tree?.harnessLib) ? tree.harnessLib : [];
|
|
130
|
+
const testDir = tree?.testDir ?? null;
|
|
131
|
+
|
|
132
|
+
const plants = [];
|
|
133
|
+
const unavailable = [];
|
|
134
|
+
const skip = (kind, reason) => unavailable.push({ kind, reason });
|
|
135
|
+
|
|
136
|
+
// ── Spec-derived plants ──────────────────────────────────────────────────
|
|
137
|
+
const spec = specs.find((s) => firstClauseId(s?.text));
|
|
138
|
+
const clause = spec ? firstClauseId(spec.text) : null;
|
|
139
|
+
|
|
140
|
+
if (!spec) {
|
|
141
|
+
const why = specs.length
|
|
142
|
+
? `no clause of the form "- **ID-NN**" in ${specs.length} spec file(s)`
|
|
143
|
+
: "no spec files — nothing declares behavior to plant against";
|
|
144
|
+
for (const kind of [PLANT_KINDS.ORPHANED_CITATION, PLANT_KINDS.UNBOUND_CITATION, PLANT_KINDS.TIER_UNMET]) skip(kind, why);
|
|
145
|
+
} else {
|
|
146
|
+
// Renaming a live clause orphans every citation of it: specCoverage must
|
|
147
|
+
// name the id it can no longer find.
|
|
148
|
+
plants.push({
|
|
149
|
+
kind: PLANT_KINDS.ORPHANED_CITATION,
|
|
150
|
+
label: "orphaned citation",
|
|
151
|
+
step: "specCoverage",
|
|
152
|
+
names: [clause],
|
|
153
|
+
target: { spec: spec.rel, clause },
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const family = clauseFamily(clause);
|
|
157
|
+
// A tag on a CLASS with no test inside the binding window: the clause
|
|
158
|
+
// exists, the tag exists, and nothing runs. The two remaining spec plants
|
|
159
|
+
// need somewhere to put that Kotlin, so they hang on a test source dir.
|
|
160
|
+
if (!testDir) {
|
|
161
|
+
const why = "no Kotlin test source directory — a planted citation has nowhere to live";
|
|
162
|
+
skip(PLANT_KINDS.UNBOUND_CITATION, why);
|
|
163
|
+
skip(PLANT_KINDS.TIER_UNMET, why);
|
|
164
|
+
} else {
|
|
165
|
+
plants.push({
|
|
166
|
+
kind: PLANT_KINDS.UNBOUND_CITATION,
|
|
167
|
+
label: "unbound citation",
|
|
168
|
+
step: "specCoverage",
|
|
169
|
+
names: [`${family}-99`],
|
|
170
|
+
target: { spec: spec.rel, clause: `${family}-99`, testDir },
|
|
171
|
+
});
|
|
172
|
+
plants.push({
|
|
173
|
+
kind: PLANT_KINDS.TIER_UNMET,
|
|
174
|
+
label: "tier unmet",
|
|
175
|
+
step: "specCoverage",
|
|
176
|
+
names: [`${family}-98`],
|
|
177
|
+
target: { spec: spec.rel, clause: `${family}-98`, testDir },
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Flow-derived plants ──────────────────────────────────────────────────
|
|
183
|
+
// Both strip EVERY citation from every flow, not just one line. e2eCoverage
|
|
184
|
+
// asks whether a screen feature has any device journey at all, so removing a
|
|
185
|
+
// single `# SPEC:` from a flow that carries several leaves the feature
|
|
186
|
+
// covered and the gate — correctly — green. A plant that does not actually
|
|
187
|
+
// produce the violation is worse than no plant: it reads as a calibrated
|
|
188
|
+
// gate while proving nothing.
|
|
189
|
+
const citingFlows = flows.filter((f) => flowCitation(f?.text));
|
|
190
|
+
if (!citingFlows.length) {
|
|
191
|
+
const why = flows.length
|
|
192
|
+
? `no "# SPEC:" citation in ${flows.length} flow file(s) — e2eCoverage has nothing to lose`
|
|
193
|
+
: "no qa/e2e flows — this project declares no device journeys";
|
|
194
|
+
skip(PLANT_KINDS.FEATURE_WITHOUT_FLOW, why);
|
|
195
|
+
skip(PLANT_KINDS.NESTED_FLOW, why);
|
|
196
|
+
} else {
|
|
197
|
+
const rels = citingFlows.map((f) => f.rel);
|
|
198
|
+
// A real feature with a screen and a spec and no device journey at all.
|
|
199
|
+
plants.push({
|
|
200
|
+
kind: PLANT_KINDS.FEATURE_WITHOUT_FLOW,
|
|
201
|
+
label: "feature without a flow",
|
|
202
|
+
step: "e2eCoverage",
|
|
203
|
+
names: [],
|
|
204
|
+
// FAIL BY NAME, without knowing this project's feature names: the gate
|
|
205
|
+
// must name the feature it caught, in the [brackets] its reason uses.
|
|
206
|
+
reasonPattern: String.raw`\[[^\]\s]+\]`,
|
|
207
|
+
target: { flows: rels },
|
|
208
|
+
});
|
|
209
|
+
// The citations move into a subdirectory Maestro's directory run never
|
|
210
|
+
// executes. The tags exist, the YAML is real, and nothing runs it — which
|
|
211
|
+
// must read exactly like having no journey.
|
|
212
|
+
plants.push({
|
|
213
|
+
kind: PLANT_KINDS.NESTED_FLOW,
|
|
214
|
+
label: "flow the lane never runs",
|
|
215
|
+
step: "e2eCoverage",
|
|
216
|
+
names: [],
|
|
217
|
+
reasonPattern: String.raw`\[[^\]\s]+\]`,
|
|
218
|
+
target: { flows: rels, nestInto: "qa/e2e/wip" },
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Region plants — the floor ────────────────────────────────────────────
|
|
223
|
+
// A narrowed declaration un-attests a whole layer while every checker stays
|
|
224
|
+
// intact (payment-blueprint's planted proof); an edited lane cannot vouch for
|
|
225
|
+
// its own verdict. Both are read by harnessIntegrity, which needs only a lane.
|
|
226
|
+
plants.push({
|
|
227
|
+
kind: PLANT_KINDS.NARROWED_SURFACE,
|
|
228
|
+
label: "narrowed surface declaration",
|
|
229
|
+
step: "harnessIntegrity",
|
|
230
|
+
names: ["unrecorded"],
|
|
231
|
+
hookPattern: "harnessIntegrity|vouch",
|
|
232
|
+
target: { declaration: "qa/verified-surface.json" },
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const spine = harnessLib.find((rel) => rel.endsWith("/spec-coverage.mjs")) ?? harnessLib[0] ?? null;
|
|
236
|
+
if (!spine) {
|
|
237
|
+
skip(PLANT_KINDS.EDITED_LANE, "no machine-owned lane files found under qa/lib — there is no region to edit");
|
|
238
|
+
} else {
|
|
239
|
+
plants.push({
|
|
240
|
+
kind: PLANT_KINDS.EDITED_LANE,
|
|
241
|
+
label: "edited lane cannot vouch",
|
|
242
|
+
step: "harnessIntegrity",
|
|
243
|
+
names: ["modified"],
|
|
244
|
+
hookPattern: "harnessIntegrity|vouch",
|
|
245
|
+
target: { file: spine },
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return { plants, unavailable };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Is this set of plants enough to make a claim at all?
|
|
254
|
+
*
|
|
255
|
+
* The refusal is the point. An instrument that finds nothing to plant and
|
|
256
|
+
* prints PASS has proven that it ran, not that the framework returns — the
|
|
257
|
+
* "green with gaps" the harness exists to refuse, applied to itself.
|
|
258
|
+
*
|
|
259
|
+
* @param {Array<{kind: string}>} plants
|
|
260
|
+
* @returns {{ok: true}|{ok: false, reason: string}}
|
|
261
|
+
*/
|
|
262
|
+
export function assessCoverage(plants) {
|
|
263
|
+
const kinds = new Set((plants ?? []).map((p) => p?.kind));
|
|
264
|
+
const missingFloor = FLOOR_KINDS.filter((k) => !kinds.has(k));
|
|
265
|
+
if (missingFloor.length === FLOOR_KINDS.length) {
|
|
266
|
+
return {
|
|
267
|
+
ok: false,
|
|
268
|
+
reason:
|
|
269
|
+
"no plant could be made at all — this tree has no machine-owned lane to check. " +
|
|
270
|
+
"Run this from a project root whose qa/lib/ carries the vendored harness.",
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
if (missingFloor.length) {
|
|
274
|
+
return { ok: false, reason: `the region plants are the floor and ${missingFloor.join(", ")} could not be made` };
|
|
275
|
+
}
|
|
276
|
+
return { ok: true };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Judge one planted run. Every branch here is a distinct framework defect and
|
|
281
|
+
* says which one it is: a hang, a lane that produced no receipt, a guard that
|
|
282
|
+
* did not fail, or a guard that failed WITHOUT NAMING what it caught.
|
|
283
|
+
*
|
|
284
|
+
* "FAIL BY NAME" is not decoration. A gate that fails with a generic message
|
|
285
|
+
* costs the reader the diagnosis every time it fires, and — worse — cannot be
|
|
286
|
+
* told apart from a gate failing for an unrelated reason, which is how a
|
|
287
|
+
* calibration passes on a gate that was never actually read.
|
|
288
|
+
*
|
|
289
|
+
* @param {{hung?: boolean, ms?: number, exit?: number|null,
|
|
290
|
+
* receipt?: {verdict?: string, steps?: Array<object>}|null,
|
|
291
|
+
* stderr?: string}} run
|
|
292
|
+
* @param {{label: string, step: string, names?: string[]}} plant
|
|
293
|
+
* @param {number} boundMs
|
|
294
|
+
* @returns {{ok: true}|{ok: false, reason: string}}
|
|
295
|
+
*/
|
|
296
|
+
export function assessPlantRun(run, plant, boundMs) {
|
|
297
|
+
const label = plant?.label ?? "(unnamed plant)";
|
|
298
|
+
if (run?.hung) {
|
|
299
|
+
return { ok: false, reason: `"${label}" did not return inside ${boundMs}ms — the framework HANGS on a failing input` };
|
|
300
|
+
}
|
|
301
|
+
const receipt = run?.receipt;
|
|
302
|
+
if (!receipt) {
|
|
303
|
+
const tail = String(run?.stderr ?? "").slice(-600);
|
|
304
|
+
return { ok: false, reason: `"${label}" returned no receipt (exit ${run?.exit ?? "?"})${tail ? `:\n${tail}` : ""}` };
|
|
305
|
+
}
|
|
306
|
+
const row = (receipt.steps ?? []).find((s) => s?.name === plant.step);
|
|
307
|
+
if (receipt.verdict !== "FAIL" || !row || row.verdict !== "FAIL") {
|
|
308
|
+
return {
|
|
309
|
+
ok: false,
|
|
310
|
+
reason: `planted "${label}" and the lane said ${receipt.verdict} (${plant.step}: ${row ? row.verdict : "no row"}) — the guard did not FAIL BY NAME`,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
const reason = String(row.reason ?? "");
|
|
314
|
+
for (const name of plant.names ?? []) {
|
|
315
|
+
if (!reason.includes(name)) {
|
|
316
|
+
return { ok: false, reason: `${plant.step} FAILed on "${label}" but did not NAME ${name}:\n${reason}` };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// Some gates name something the selector cannot know in advance — a feature
|
|
320
|
+
// this project happens to have. The pattern is how those still assert FAIL BY
|
|
321
|
+
// NAME instead of settling for "it went red".
|
|
322
|
+
if (plant.reasonPattern && !new RegExp(plant.reasonPattern).test(reason)) {
|
|
323
|
+
return { ok: false, reason: `${plant.step} FAILed on "${label}" but named nothing matching /${plant.reasonPattern}/:\n${reason}` };
|
|
324
|
+
}
|
|
325
|
+
return { ok: true };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Judge the baseline (and the post-revert re-run): a tree that cannot go green
|
|
330
|
+
* on its own has nothing to plant against, and every FAIL below would be
|
|
331
|
+
* unattributable.
|
|
332
|
+
*
|
|
333
|
+
* @param {{hung?: boolean, receipt?: object|null, exit?: number|null, stderr?: string}} run
|
|
334
|
+
* @param {string} phase "baseline" or "revert"
|
|
335
|
+
* @param {number} boundMs
|
|
336
|
+
* @returns {{ok: true}|{ok: false, reason: string}}
|
|
337
|
+
*/
|
|
338
|
+
export function assessGreenRun(run, phase, boundMs) {
|
|
339
|
+
if (run?.hung) {
|
|
340
|
+
return { ok: false, reason: `the ${phase} run did not return inside ${boundMs}ms — the framework HANGS on a passing input` };
|
|
341
|
+
}
|
|
342
|
+
if (!run?.receipt) {
|
|
343
|
+
const tail = String(run?.stderr ?? "").slice(-600);
|
|
344
|
+
return { ok: false, reason: `the ${phase} run returned no receipt (exit ${run?.exit ?? "?"})${tail ? `:\n${tail}` : ""}` };
|
|
345
|
+
}
|
|
346
|
+
if (run.receipt.verdict !== "PASS") {
|
|
347
|
+
const bad = (run.receipt.steps ?? [])
|
|
348
|
+
.filter((s) => s?.verdict === "FAIL" || s?.verdict === "ERROR")
|
|
349
|
+
.map((s) => `${s.name}: ${String(s.reason ?? "").split("\n")[0]}`)
|
|
350
|
+
.join("; ");
|
|
351
|
+
const suffix =
|
|
352
|
+
phase === "revert"
|
|
353
|
+
? " — the plants were not the only cause, or a revert did not restore the tree"
|
|
354
|
+
: " — fix the tree before calibrating anything against it";
|
|
355
|
+
return { ok: false, reason: `the ${phase} run is ${run.receipt.verdict} (${bad || "no failing row named"})${suffix}` };
|
|
356
|
+
}
|
|
357
|
+
return { ok: true };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Rule 1's stated bound, made checkable.
|
|
362
|
+
*
|
|
363
|
+
* Rule 1 says a calibration is "four steps, seconds each". That sentence has
|
|
364
|
+
* always been prose, and prose does not refuse: payment-blueprint calibrated
|
|
365
|
+
* through a 30–60 s composite Gradle build, which violated the rule on its own
|
|
366
|
+
* terms from the first cycle, and nothing noticed for three occurrences. The
|
|
367
|
+
* cost is not the single cycle — it is per-instance cost times every instance,
|
|
368
|
+
* which is how 38 minutes disappears into something that looks like rigour.
|
|
369
|
+
*
|
|
370
|
+
* A calibration cycle slower than this is not wrong, but it is a finding: it
|
|
371
|
+
* means the plant is being run through the wrong instrument, and the report
|
|
372
|
+
* must say so while the choice is still cheap to change.
|
|
373
|
+
*/
|
|
374
|
+
export const CALIBRATION_BUDGET_MS = 5_000;
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* @param {Array<{label: string, ms: number}>} cycles
|
|
378
|
+
* @param {number} [budgetMs]
|
|
379
|
+
* @returns {{withinBudget: boolean, slowest: {label: string, ms: number}|null,
|
|
380
|
+
* totalMs: number, note: string|null}}
|
|
381
|
+
*/
|
|
382
|
+
export function assessCalibrationCost(cycles, budgetMs = CALIBRATION_BUDGET_MS) {
|
|
383
|
+
const rows = Array.isArray(cycles) ? cycles.filter((c) => Number.isFinite(c?.ms)) : [];
|
|
384
|
+
const totalMs = rows.reduce((n, c) => n + c.ms, 0);
|
|
385
|
+
if (!rows.length) return { withinBudget: true, slowest: null, totalMs: 0, note: null };
|
|
386
|
+
const slowest = rows.reduce((a, b) => (b.ms > a.ms ? b : a));
|
|
387
|
+
if (slowest.ms <= budgetMs) return { withinBudget: true, slowest, totalMs, note: null };
|
|
388
|
+
return {
|
|
389
|
+
withinBudget: false,
|
|
390
|
+
slowest,
|
|
391
|
+
totalMs,
|
|
392
|
+
note:
|
|
393
|
+
`slowest calibration cycle "${slowest.label}" took ${slowest.ms}ms against a ${budgetMs}ms budget. ` +
|
|
394
|
+
`GATE-RULES Rule 1 calls a calibration "four steps, seconds each" — a cycle past that is being run ` +
|
|
395
|
+
`through the wrong instrument, and the cost is paid on every plant forever.`,
|
|
396
|
+
};
|
|
397
|
+
}
|