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,778 @@
|
|
|
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
|
+
// The core's fallback grammar, for the ONE thing this file reads out of a flow:
|
|
42
|
+
// the citation marker. Importing it rather than re-declaring the pattern is the
|
|
43
|
+
// point — a second copy of "what a citation looks like" is how the selector and
|
|
44
|
+
// the scanner came to disagree about the same file (see `flowCitation`). The
|
|
45
|
+
// import performs no IO; every function below still takes data and returns data.
|
|
46
|
+
// The badge floor's two halves, imported for the same reason as the grammar
|
|
47
|
+
// above: the plant below calibrates THE grader the lane runs and THE definition
|
|
48
|
+
// of plant material the runner plants from, not a re-statement of either.
|
|
49
|
+
import { evidenceLevel } from "./evidence-level.mjs";
|
|
50
|
+
import { plantCalibration } from "./plant-calibration.mjs";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Per-direction bound. Rule 0's whole claim is about SPEED of refusal, so the
|
|
54
|
+
* default is small on purpose: the smoke profile is every pure-Node gate and no
|
|
55
|
+
* Gradle, which returns in around a second on a real tree. A direction that
|
|
56
|
+
* does not return inside the bound is killed and reported as a hang — the bound
|
|
57
|
+
* IS the assertion, never a courtesy timeout waited out.
|
|
58
|
+
*/
|
|
59
|
+
export const DEFAULT_BOUND_MS = 10_000;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Every plant this instrument knows how to make. The kinds are named so tests
|
|
63
|
+
* (and a report) can talk about them without matching prose.
|
|
64
|
+
*/
|
|
65
|
+
export const PLANT_KINDS = Object.freeze({
|
|
66
|
+
ORPHANED_CITATION: "orphaned-citation",
|
|
67
|
+
UNBOUND_CITATION: "unbound-citation",
|
|
68
|
+
TIER_UNMET: "tier-unmet",
|
|
69
|
+
FEATURE_WITHOUT_FLOW: "feature-without-flow",
|
|
70
|
+
NESTED_FLOW: "flow-the-lane-never-runs",
|
|
71
|
+
NARROWED_SURFACE: "narrowed-surface",
|
|
72
|
+
EDITED_LANE: "edited-lane",
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The plants that need nothing but a lane. The step that reads the machine-owned
|
|
77
|
+
* region exists in every project that has a lane at all — so these two are the
|
|
78
|
+
* floor. If even these cannot run, the tree has no harness to check and the
|
|
79
|
+
* instrument must say so rather than report a vacuous PASS.
|
|
80
|
+
*/
|
|
81
|
+
export const FLOOR_KINDS = Object.freeze([PLANT_KINDS.NARROWED_SURFACE, PLANT_KINDS.EDITED_LANE]);
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* How the two floor plants say which receipt row they are about.
|
|
85
|
+
*
|
|
86
|
+
* THE ROW THAT VOUCHES IS THE ROW CARRYING THE VOUCHING DATA, not the row with
|
|
87
|
+
* a particular name — the principle qa/lib/receipt-validate.mjs settled in
|
|
88
|
+
* `checkLaneVouching` one layer out, mirrored here rather than re-invented.
|
|
89
|
+
* These two plants used to declare `step: "harnessIntegrity"` as a literal.
|
|
90
|
+
* That is a name the cmp pack chose for its own step; REQUIRED_EXPORTS never
|
|
91
|
+
* mentions it and a profile author has no way to discover it. On a pack that
|
|
92
|
+
* spells its self-check `harness_integrity`, both floor plants looked for a row
|
|
93
|
+
* that does not exist and the instrument reported "the guard did not FAIL BY
|
|
94
|
+
* NAME" — about a guard that had failed, by name, on the row immediately beside
|
|
95
|
+
* it. An adopter's Rule 0 check therefore fails on a working lane, and the
|
|
96
|
+
* message sends them into the harness instead of into the spelling.
|
|
97
|
+
*
|
|
98
|
+
* `vouching` says: find the row whose `harness` object carries the integrity
|
|
99
|
+
* check's own findings (the field the receipt schema documents, written by
|
|
100
|
+
* every row that performs it). `step` stays as the FALLBACK name, for receipts
|
|
101
|
+
* written before rows carried one — the same two-step lookup checkLaneVouching
|
|
102
|
+
* uses, and the same reason.
|
|
103
|
+
*
|
|
104
|
+
* `hookPattern` is matched against the Stop hook's refusal when the plant's
|
|
105
|
+
* receipt is forged to PASS. It named `harnessIntegrity` too, and the hook's
|
|
106
|
+
* refusal quotes the failing ROW'S name — so on that same pack the hook refused
|
|
107
|
+
* correctly and the instrument called the correct refusal a framework defect.
|
|
108
|
+
* The alternatives here are the core's own two wordings for this class of
|
|
109
|
+
* refusal (qa/lib/receipt-validate.mjs `checkLaneVouching`: the failing-rows
|
|
110
|
+
* branch and the did-not-vouch branch), neither of which is any pack's to spell.
|
|
111
|
+
*/
|
|
112
|
+
const VOUCHING_STEP = Object.freeze({
|
|
113
|
+
step: "harnessIntegrity",
|
|
114
|
+
vouching: true,
|
|
115
|
+
hookPattern: "vouch|the row is the more specific truth",
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Which of THIS pack's steps observes the violation a plant makes.
|
|
120
|
+
*
|
|
121
|
+
* THE SECOND HALF OF THE SAME LESSON, and the reason a fix applied to instances
|
|
122
|
+
* comes back. `VOUCHING_STEP` above took the literal `harnessIntegrity` out of
|
|
123
|
+
* the two floor plants because it is a name the cmp pack chose (NORTH-STAR
|
|
124
|
+
* §9.1). The other five plants kept theirs — `specCoverage` on the three spec
|
|
125
|
+
* plants, `e2eCoverage` on the two flow ones — and those are the same string in
|
|
126
|
+
* the same position, doing the same damage: a name the profile protocol never
|
|
127
|
+
* mentions, that REQUIRED_EXPORTS does not ask for and a profile author has no
|
|
128
|
+
* way to discover, asserted by the core against every profile there will ever
|
|
129
|
+
* be.
|
|
130
|
+
*
|
|
131
|
+
* Measured rather than reasoned about, because §9.1's lesson is that a stack
|
|
132
|
+
* assumption naming no stack is found only by running. A `harness init`
|
|
133
|
+
* skeleton with its two steps renamed to snake_case and NOTHING else changed:
|
|
134
|
+
* the lane FAILs, `spec_coverage` names the orphaned citation exactly as it
|
|
135
|
+
* should, and this instrument stops at the first plant with
|
|
136
|
+
*
|
|
137
|
+
* planted "orphaned citation" and the lane said FAIL (specCoverage: no row)
|
|
138
|
+
* — the guard did not FAIL BY NAME
|
|
139
|
+
*
|
|
140
|
+
* That is worse than the floor-plant case rather than equal to it, because the
|
|
141
|
+
* spec plants run FIRST and a plant failure aborts the run: on such a tree the
|
|
142
|
+
* two plants that were already fixed never execute at all, and the adopter's
|
|
143
|
+
* Rule 0 check accuses their working lane while pointing at the harness.
|
|
144
|
+
*
|
|
145
|
+
* Three ways to fix it; only one is honest here.
|
|
146
|
+
*
|
|
147
|
+
* THE DATA SELECTOR `vouching` USES IS NOT AVAILABLE. It works because the CORE
|
|
148
|
+
* computes the integrity findings and the row carries them — `harness`, the one
|
|
149
|
+
* part of that row the receipt schema documents — so every pack that performs
|
|
150
|
+
* the check answers to it whether it declares anything or not. There is no
|
|
151
|
+
* equivalent for spec or journey coverage. What cmp's coverage row carries is
|
|
152
|
+
* `details` ({clauses, withdrawn, tags, files}), written by cmp's own step
|
|
153
|
+
* function; the generated skeleton writes a different shape ({clauses,
|
|
154
|
+
* citations}) from the same core scanner; and nothing documents either as a
|
|
155
|
+
* contract. Inventing a field now would put an undocumented requirement on
|
|
156
|
+
* every future pack, silently unmet by any pack that omits it — the same
|
|
157
|
+
* undiscoverable literal wearing different clothes.
|
|
158
|
+
*
|
|
159
|
+
* WIDENING TO "ANY ROW THAT WENT RED" IS NOT IT EITHER. The kept plant in
|
|
160
|
+
* test/framework-check-agnostic.test.mjs builds the receipt that refutes it: the
|
|
161
|
+
* integrity row FAILs quoting the planted clause id while the coverage gate sits
|
|
162
|
+
* PASS beside it. A lookup that accepts that reads a broken lock as a working
|
|
163
|
+
* spec gate, which is the mix-up this whole instrument exists to catch.
|
|
164
|
+
*
|
|
165
|
+
* SO THE PACK SAYS — through the declaration that already exists for this
|
|
166
|
+
* instrument. `plants` is NORTH-STAR §6's eighth declaration, "the Rule 0/1
|
|
167
|
+
* violations the instrument runs forever", and its endpoint in
|
|
168
|
+
* AGNOSTIC-HARNESS-ARCHITECTURE.md §5.1 is a `plants(tree)` that returns the
|
|
169
|
+
* plants themselves. Which step asserts a plant is part of that plant, so it
|
|
170
|
+
* belongs there and no tenth declaration is invented to hold it. The KEY is a
|
|
171
|
+
* plant kind — PLANT_KINDS, the core's vocabulary — and the VALUE is the pack's
|
|
172
|
+
* spelling, so neither side has to learn the other's words.
|
|
173
|
+
*
|
|
174
|
+
* AND THE DEFAULT IS NOT cmp's SPELLING. That is the distinction §9.1 drew for
|
|
175
|
+
* `compileStepName`: "the distinction is the KEY's presence, not its value — a
|
|
176
|
+
* pack that declares none short-circuits on NOTHING rather than inheriting
|
|
177
|
+
* another stack's step name." Here a pack that declares no step for a kind gets
|
|
178
|
+
* null, and `plantRow` then holds the LANE instead of a row nobody named: the
|
|
179
|
+
* assertion widens honestly, still refuses a lane that stayed green or went red
|
|
180
|
+
* without naming what was planted, and reports the row it actually read.
|
|
181
|
+
* Declaring narrows it back to exactly one row; declaring nothing never inherits
|
|
182
|
+
* the wrong one.
|
|
183
|
+
*
|
|
184
|
+
* @param {{observedBy?: Record<string, string>}} tree
|
|
185
|
+
* @param {string} kind one of PLANT_KINDS
|
|
186
|
+
* @returns {string|null} this pack's name for the step, or null if it named none
|
|
187
|
+
*/
|
|
188
|
+
function observingStep(tree, kind) {
|
|
189
|
+
const declared = tree?.observedBy?.[kind];
|
|
190
|
+
return typeof declared === "string" && declared.length > 0 ? declared : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A clause id at the head of a spec list item: `- **HOME-02** — …`. */
|
|
194
|
+
const CLAUSE_RE = /^-\s+\*\*([A-Z][A-Z0-9]*-\d{2,})\*\*/m;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The ids on a citation line, once the MARKER has already matched it:
|
|
198
|
+
* `# SPEC: HOME-02, HOME-03 — …`. The id grammar is the core's (it is the same
|
|
199
|
+
* one scanCitations uses); the marker in front of it is the profile's.
|
|
200
|
+
*/
|
|
201
|
+
const CITATION_IDS_RE = /SPEC:\s*([A-Z0-9,\s-]+)/;
|
|
202
|
+
const CLAUSE_ID_RE = /^[A-Z][A-Z0-9]*-\d{2,}$/;
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The first clause id in a spec, or null. Used to pick something real to plant
|
|
206
|
+
* against: a clause that already exists and is already cited is the only kind
|
|
207
|
+
* whose removal proves a gate READS, rather than proving a gate rejects garbage.
|
|
208
|
+
* @param {string} text
|
|
209
|
+
* @returns {string|null}
|
|
210
|
+
*/
|
|
211
|
+
export function firstClauseId(text) {
|
|
212
|
+
if (typeof text !== "string") return null;
|
|
213
|
+
const m = text.match(CLAUSE_RE);
|
|
214
|
+
return m ? m[1] : null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The clause a flow cites, or null.
|
|
219
|
+
*
|
|
220
|
+
* THE MARKER IS THE PROFILE'S. This matched `^#\s*SPEC:` and nothing else,
|
|
221
|
+
* which is true of Maestro YAML and false of a stack whose journeys are .ts,
|
|
222
|
+
* .kt, .rb or anything C-family. The cost was not a refusal: a project whose
|
|
223
|
+
* journeys cite with `//` was told `no "# SPEC:" citation in N flow file(s)`,
|
|
224
|
+
* both e2eCoverage plants stood down, and the check printed PASS with the
|
|
225
|
+
* reason folded into an ⓘ line — a gate reported as calibrated that had never
|
|
226
|
+
* been read. And the disagreement was internal: the core's own fallback marker
|
|
227
|
+
* (spec-model.mjs `DEFAULT_GRAMMAR.citationMarker`) has always accepted `//`,
|
|
228
|
+
* so qa/lib/spec-coverage.mjs `scanCitations` was counting the very citation
|
|
229
|
+
* this function could not see, in the same file, in the same tree.
|
|
230
|
+
*
|
|
231
|
+
* Same shape as scanCitations: test the profile's marker against the trimmed
|
|
232
|
+
* line, then read the ids after it. A profile that declares no `grammar` gets
|
|
233
|
+
* the same fallback the scan uses, so the two answers cannot drift apart again.
|
|
234
|
+
*
|
|
235
|
+
* @param {string} text
|
|
236
|
+
* @param {{citationMarker?: RegExp}} [grammar] the SpecModel's grammar
|
|
237
|
+
* @returns {string|null}
|
|
238
|
+
*/
|
|
239
|
+
export function flowCitation(text, grammar) {
|
|
240
|
+
if (typeof text !== "string") return null;
|
|
241
|
+
// The marker is the profile's; with none declared nothing can be a citation — and the lane refuses that profile before this runs.
|
|
242
|
+
const MARKER = grammar?.citationMarker;
|
|
243
|
+
if (!(MARKER instanceof RegExp)) return null;
|
|
244
|
+
for (const line of text.split("\n")) {
|
|
245
|
+
const trimmed = line.trim();
|
|
246
|
+
if (!MARKER.test(trimmed)) continue;
|
|
247
|
+
const m = trimmed.match(CITATION_IDS_RE);
|
|
248
|
+
if (!m) continue;
|
|
249
|
+
const id = m[1]
|
|
250
|
+
.split(/[,\s]+/)
|
|
251
|
+
.map((s) => s.trim())
|
|
252
|
+
.find((s) => CLAUSE_ID_RE.test(s));
|
|
253
|
+
if (id) return id;
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The clause-id PREFIX ("HOME" from "HOME-02"), used to mint planted ids that
|
|
260
|
+
* cannot collide with a real clause: a spec's own family with a number far above
|
|
261
|
+
* anything hand-authored.
|
|
262
|
+
* @param {string} clause
|
|
263
|
+
* @returns {string}
|
|
264
|
+
*/
|
|
265
|
+
export function clauseFamily(clause) {
|
|
266
|
+
const m = String(clause ?? "").match(/^([A-Z][A-Z0-9]*)-/);
|
|
267
|
+
return m ? m[1] : "SPEC";
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Decide which plants this tree can support, and say WHY each unavailable one
|
|
272
|
+
* is unavailable.
|
|
273
|
+
*
|
|
274
|
+
* `grammar` is the SpecModel's (qa/lib/spec-model.mjs): the caller passes the
|
|
275
|
+
* profile's, and a caller that passes none gets the same fallback the coverage
|
|
276
|
+
* scan uses. It decides one thing here — what a citation in a journey looks
|
|
277
|
+
* like — and getting it from the profile is what stops this instrument from
|
|
278
|
+
* disagreeing with the gate it is calibrating (see `flowCitation`).
|
|
279
|
+
*
|
|
280
|
+
* `observedBy` is the profile's `plants.observedBy` — plant kind → the name
|
|
281
|
+
* THIS pack gives the step that catches that kind of violation. A kind it does
|
|
282
|
+
* not name gets a null `step`, which is an assertion over the lane rather than
|
|
283
|
+
* over one row, never cmp's spelling by default (see `observingStep`).
|
|
284
|
+
*
|
|
285
|
+
* @param {{specs?: Array<{rel: string, text: string}>,
|
|
286
|
+
* flows?: Array<{rel: string, text: string}>,
|
|
287
|
+
* harnessLib?: string[],
|
|
288
|
+
* testDir?: string|null,
|
|
289
|
+
* plantsDeclared?: boolean,
|
|
290
|
+
* unmeetableTier?: string,
|
|
291
|
+
* observedBy?: Record<string, string>,
|
|
292
|
+
* grammar?: {citationMarker?: RegExp}}} tree
|
|
293
|
+
* @returns {{plants: Array<{kind: string, label: string, step: string|null,
|
|
294
|
+
* names: string[], target: object}>,
|
|
295
|
+
* unavailable: Array<{kind: string, reason: string}>}}
|
|
296
|
+
*/
|
|
297
|
+
export function selectPlants(tree) {
|
|
298
|
+
const specs = Array.isArray(tree?.specs) ? tree.specs : [];
|
|
299
|
+
const flows = Array.isArray(tree?.flows) ? tree.flows : [];
|
|
300
|
+
const harnessLib = Array.isArray(tree?.harnessLib) ? tree.harnessLib : [];
|
|
301
|
+
const testDir = tree?.testDir ?? null;
|
|
302
|
+
// Where this stack keeps the flow-shaped citation files the lane executes
|
|
303
|
+
// (the profile's `layout.flows.dir`). The nested-flow plant must land INSIDE
|
|
304
|
+
// it — a subdirectory the runner walks past — so the directory cannot be a
|
|
305
|
+
// constant here. Derived from the flows themselves when the caller does not
|
|
306
|
+
// say, so a caller with flows always gets the plant.
|
|
307
|
+
const flowsDir = typeof tree?.flowsDir === "string" && tree.flowsDir
|
|
308
|
+
? tree.flowsDir
|
|
309
|
+
: (flows.find((f) => typeof f?.rel === "string" && f.rel.includes("/"))?.rel.replace(/\/[^/]*$/, "") ?? null);
|
|
310
|
+
|
|
311
|
+
const plants = [];
|
|
312
|
+
const unavailable = [];
|
|
313
|
+
const skip = (kind, reason) => unavailable.push({ kind, reason });
|
|
314
|
+
|
|
315
|
+
// ── Spec-derived plants ──────────────────────────────────────────────────
|
|
316
|
+
const spec = specs.find((s) => firstClauseId(s?.text));
|
|
317
|
+
const clause = spec ? firstClauseId(spec.text) : null;
|
|
318
|
+
|
|
319
|
+
if (!spec) {
|
|
320
|
+
const why = specs.length
|
|
321
|
+
? `no clause of the form "- **ID-NN**" in ${specs.length} spec file(s)`
|
|
322
|
+
: "no spec files — nothing declares behavior to plant against";
|
|
323
|
+
for (const kind of [PLANT_KINDS.ORPHANED_CITATION, PLANT_KINDS.UNBOUND_CITATION, PLANT_KINDS.TIER_UNMET]) skip(kind, why);
|
|
324
|
+
} else {
|
|
325
|
+
// Renaming a live clause orphans every citation of it: whichever step reads
|
|
326
|
+
// clause↔test citations must name the id it can no longer find. WHICH step
|
|
327
|
+
// that is is the pack's to spell and never this file's — see observingStep.
|
|
328
|
+
plants.push({
|
|
329
|
+
kind: PLANT_KINDS.ORPHANED_CITATION,
|
|
330
|
+
label: "orphaned citation",
|
|
331
|
+
step: observingStep(tree, PLANT_KINDS.ORPHANED_CITATION),
|
|
332
|
+
names: [clause],
|
|
333
|
+
target: { spec: spec.rel, clause },
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const family = clauseFamily(clause);
|
|
337
|
+
// A tag on a CLASS with no test inside the binding window: the clause
|
|
338
|
+
// exists, the tag exists, and nothing runs. The two remaining spec plants
|
|
339
|
+
// need somewhere to put that Kotlin, so they hang on a test source dir.
|
|
340
|
+
if (!testDir) {
|
|
341
|
+
// TWO different causes reached this branch and the message named only
|
|
342
|
+
// one of them, wrongly: a profile that declares no `plants` never even
|
|
343
|
+
// looks for a test directory (framework-check.mjs passes testDir: null),
|
|
344
|
+
// so an adopter with two perfectly good test directories was told they
|
|
345
|
+
// had none. Worse, the plant this silently skips is `tier-unmet` — the
|
|
346
|
+
// one that calibrates the gate the whole instrument exists to prove —
|
|
347
|
+
// and the run still printed PASS. Saying which cause it is turns an
|
|
348
|
+
// invisible gap into a one-line fix. (And the core names no language.)
|
|
349
|
+
const why = tree?.plantsDeclared === false
|
|
350
|
+
? "this profile declares no plants — add a `plants` export { testFileBasename, unboundCitationSource, tierUnmetCitationSource, unmeetableTier }"
|
|
351
|
+
: "no test source directory found — a planted citation has nowhere to live";
|
|
352
|
+
skip(PLANT_KINDS.UNBOUND_CITATION, why);
|
|
353
|
+
skip(PLANT_KINDS.TIER_UNMET, why);
|
|
354
|
+
} else {
|
|
355
|
+
plants.push({
|
|
356
|
+
kind: PLANT_KINDS.UNBOUND_CITATION,
|
|
357
|
+
label: "unbound citation",
|
|
358
|
+
step: observingStep(tree, PLANT_KINDS.UNBOUND_CITATION),
|
|
359
|
+
names: [`${family}-99`],
|
|
360
|
+
target: { spec: spec.rel, clause: `${family}-99`, testDir },
|
|
361
|
+
});
|
|
362
|
+
// The tier this plant declares must be the PROFILE'S. The core used to
|
|
363
|
+
// fall back to `?? "e2e"` at the write site — a CMP tier name, asserted
|
|
364
|
+
// by the spine into any stack's spec file. A profile that ships plants
|
|
365
|
+
// but names no unmeetable tier would then plant a clause tagged with a
|
|
366
|
+
// tier it does not define, and the resulting FAIL would be right for
|
|
367
|
+
// entirely the wrong reason. There is no default; there is a skip.
|
|
368
|
+
if (!tree?.unmeetableTier) {
|
|
369
|
+
skip(
|
|
370
|
+
PLANT_KINDS.TIER_UNMET,
|
|
371
|
+
"this profile's plants declare no `unmeetableTier` — name a tier in `tiers.satisfying` that a host-tier test cannot satisfy",
|
|
372
|
+
);
|
|
373
|
+
} else {
|
|
374
|
+
plants.push({
|
|
375
|
+
kind: PLANT_KINDS.TIER_UNMET,
|
|
376
|
+
label: "tier unmet",
|
|
377
|
+
step: observingStep(tree, PLANT_KINDS.TIER_UNMET),
|
|
378
|
+
names: [`${family}-98`],
|
|
379
|
+
target: { spec: spec.rel, clause: `${family}-98`, testDir, unmeetableTier: tree.unmeetableTier },
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ── Flow-derived plants ──────────────────────────────────────────────────
|
|
386
|
+
// Both strip EVERY citation from every flow, not just one line. e2eCoverage
|
|
387
|
+
// asks whether a screen feature has any device journey at all, so removing a
|
|
388
|
+
// single citation from a flow that carries several leaves the feature
|
|
389
|
+
// covered and the gate — correctly — green. A plant that does not actually
|
|
390
|
+
// produce the violation is worse than no plant: it reads as a calibrated
|
|
391
|
+
// gate while proving nothing.
|
|
392
|
+
//
|
|
393
|
+
// WHICH flows cite is decided by the profile's citation marker, never by `#`:
|
|
394
|
+
// see `flowCitation`. The skip below prints the marker it used, because "no
|
|
395
|
+
// citation in 3 flow files" over three flows that all carry one is a sentence
|
|
396
|
+
// that sends the reader to the flows instead of to the grammar.
|
|
397
|
+
const grammar = tree?.grammar ?? null;
|
|
398
|
+
const marker = grammar?.citationMarker instanceof RegExp ? grammar.citationMarker : null;
|
|
399
|
+
const citingFlows = marker ? flows.filter((f) => flowCitation(f?.text, grammar)) : [];
|
|
400
|
+
if (!citingFlows.length) {
|
|
401
|
+
const why = !marker
|
|
402
|
+
? "the profile declares no grammar.citationMarker — the plant selector cannot read a citation (the lane refuses this profile for the same reason)"
|
|
403
|
+
: flows.length
|
|
404
|
+
? `no citation matching /${marker.source}/ in ${flows.length} flow file(s) — the journey-coverage gate has nothing to lose`
|
|
405
|
+
: `no flows${flowsDir ? ` under ${flowsDir}` : ""} — this project declares no journeys`;
|
|
406
|
+
skip(PLANT_KINDS.FEATURE_WITHOUT_FLOW, why);
|
|
407
|
+
skip(PLANT_KINDS.NESTED_FLOW, why);
|
|
408
|
+
} else {
|
|
409
|
+
const rels = citingFlows.map((f) => f.rel);
|
|
410
|
+
// A real feature with a screen and a spec and no device journey at all.
|
|
411
|
+
plants.push({
|
|
412
|
+
kind: PLANT_KINDS.FEATURE_WITHOUT_FLOW,
|
|
413
|
+
label: "feature without a flow",
|
|
414
|
+
step: observingStep(tree, PLANT_KINDS.FEATURE_WITHOUT_FLOW),
|
|
415
|
+
names: [],
|
|
416
|
+
// FAIL BY NAME, without knowing this project's feature names: the gate
|
|
417
|
+
// must name the feature it caught, in the [brackets] its reason uses.
|
|
418
|
+
reasonPattern: String.raw`\[[^\]\s]+\]`,
|
|
419
|
+
target: { flows: rels },
|
|
420
|
+
});
|
|
421
|
+
// The citations move into a subdirectory of the flows directory that the
|
|
422
|
+
// lane's own directory run does not descend into. The tags exist, the flow
|
|
423
|
+
// is real, and nothing executes it — which must read exactly like having no
|
|
424
|
+
// journey at all.
|
|
425
|
+
plants.push({
|
|
426
|
+
kind: PLANT_KINDS.NESTED_FLOW,
|
|
427
|
+
label: "flow the lane never runs",
|
|
428
|
+
step: observingStep(tree, PLANT_KINDS.NESTED_FLOW),
|
|
429
|
+
names: [],
|
|
430
|
+
reasonPattern: String.raw`\[[^\]\s]+\]`,
|
|
431
|
+
target: { flows: rels, nestInto: `${flowsDir}/wip` },
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── Region plants — the floor ────────────────────────────────────────────
|
|
436
|
+
// A narrowed declaration un-attests a whole layer while every checker stays
|
|
437
|
+
// intact (payment-blueprint's planted proof); an edited lane cannot vouch for
|
|
438
|
+
// its own verdict. Both are read by the step that vouches for the lane, which
|
|
439
|
+
// needs only a lane — see `vouching` below for how that row is found.
|
|
440
|
+
plants.push({
|
|
441
|
+
kind: PLANT_KINDS.NARROWED_SURFACE,
|
|
442
|
+
label: "narrowed surface declaration",
|
|
443
|
+
...VOUCHING_STEP,
|
|
444
|
+
// Assert the FILE, not an internal state word. "unrecorded" only holds when
|
|
445
|
+
// the declaration is absent from the lock — true for a freshly stamped app,
|
|
446
|
+
// false for a repo whose lock was taken after `harness init` wrote the
|
|
447
|
+
// surface, where the identical edit reads as "modified". Both are the same
|
|
448
|
+
// correct refusal; pinning one of them made the instrument fail on a lane
|
|
449
|
+
// that was working. What the plant actually cares about is that the refusal
|
|
450
|
+
// NAMES what it refused over, which is now true in either state.
|
|
451
|
+
names: ["qa/verified-surface.json"],
|
|
452
|
+
target: { declaration: "qa/verified-surface.json" },
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const spine = harnessLib.find((rel) => rel.endsWith("/spec-coverage.mjs")) ?? harnessLib[0] ?? null;
|
|
456
|
+
if (!spine) {
|
|
457
|
+
skip(PLANT_KINDS.EDITED_LANE, "no machine-owned lane files found under qa/lib — there is no region to edit");
|
|
458
|
+
} else {
|
|
459
|
+
plants.push({
|
|
460
|
+
kind: PLANT_KINDS.EDITED_LANE,
|
|
461
|
+
label: "edited lane cannot vouch",
|
|
462
|
+
...VOUCHING_STEP,
|
|
463
|
+
// "modified" is the integrity check's own status word (qa/lib/harness-
|
|
464
|
+
// region.mjs), not a step name and not a stack's — every pack's row
|
|
465
|
+
// reports it, because the core computes it.
|
|
466
|
+
names: ["modified"],
|
|
467
|
+
target: { file: spine },
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return { plants, unavailable };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Is this set of plants enough to make a claim at all?
|
|
476
|
+
*
|
|
477
|
+
* The refusal is the point. An instrument that finds nothing to plant and
|
|
478
|
+
* prints PASS has proven that it ran, not that the framework returns — the
|
|
479
|
+
* "green with gaps" the harness exists to refuse, applied to itself.
|
|
480
|
+
*
|
|
481
|
+
* @param {Array<{kind: string}>} plants
|
|
482
|
+
* @returns {{ok: true}|{ok: false, reason: string}}
|
|
483
|
+
*/
|
|
484
|
+
export function assessCoverage(plants) {
|
|
485
|
+
const kinds = new Set((plants ?? []).map((p) => p?.kind));
|
|
486
|
+
const missingFloor = FLOOR_KINDS.filter((k) => !kinds.has(k));
|
|
487
|
+
if (missingFloor.length === FLOOR_KINDS.length) {
|
|
488
|
+
return {
|
|
489
|
+
ok: false,
|
|
490
|
+
reason:
|
|
491
|
+
"no plant could be made at all — this tree has no machine-owned lane to check. " +
|
|
492
|
+
"Run this from a project root whose qa/lib/ carries the vendored harness.",
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
if (missingFloor.length) {
|
|
496
|
+
return { ok: false, reason: `the region plants are the floor and ${missingFloor.join(", ")} could not be made` };
|
|
497
|
+
}
|
|
498
|
+
return { ok: true };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* THE BADGE FLOOR'S KEPT PLANT — "a profile with no calibrated plants earns no
|
|
503
|
+
* rung" (NORTH-STAR.md §8.9), watched failing rather than assumed to work.
|
|
504
|
+
*
|
|
505
|
+
* WHY IT IS HERE AND NOT ONLY IN THE HARNESS'S OWN SUITE. GATE-RULES Rule 1 is
|
|
506
|
+
* explicit about where a plant goes — "Add the plant to `qa/framework-check.mjs`
|
|
507
|
+
* and run that" — and the reason is this gate's own subject: the floor exists to
|
|
508
|
+
* protect a claim made on EVERY adopter's receipt, so the plant that proves it
|
|
509
|
+
* still bites has to run in every adopter's tree, forever, not once in ours.
|
|
510
|
+
* A gate proved only in the repository that wrote it is a gate proved on the one
|
|
511
|
+
* profile it was written against, which is how every wrong verdict in
|
|
512
|
+
* NORTH-STAR.md §9.1 survived a green suite.
|
|
513
|
+
*
|
|
514
|
+
* THE VIOLATION PLANTED is the twin `scripts/stage2-gate.mjs` built: this
|
|
515
|
+
* profile's own ladder and a lane whose rows all PASS, graded with the `plants`
|
|
516
|
+
* declaration REMOVED. Before 2026-09-08 that earned `L1` — measured on two
|
|
517
|
+
* scratch adopters differing in exactly one export. It must now earn nothing,
|
|
518
|
+
* and the same rows WITH the declaration must still earn their rung, or the
|
|
519
|
+
* plant would go green on a grader that had simply stopped grading anyone.
|
|
520
|
+
* Those two halves are the same pair criteria F and G of the stage-2 gate make,
|
|
521
|
+
* and they are made here in microseconds instead of two adopted trees.
|
|
522
|
+
*
|
|
523
|
+
* NO TREE IS TOUCHED AND NO LANE IS RUN. The rows are synthesised from the
|
|
524
|
+
* ladder's own step names, so this asserts over the GRADER — which is the only
|
|
525
|
+
* thing the floor lives in. That is also its limit, and it is stated in the
|
|
526
|
+
* runner's own output rather than hidden here: it proves the rung a lane WOULD
|
|
527
|
+
* be given, not that a lane was run.
|
|
528
|
+
*
|
|
529
|
+
* @param {{ladder: object|null|undefined, plants: object|null|undefined}} profile
|
|
530
|
+
* the profile's resolved ladder (qa/lib/evidence-ladder.mjs) and its `plants`
|
|
531
|
+
* declaration — both exactly as the lane runner reads them
|
|
532
|
+
* @returns {{ok: true, rung: string}
|
|
533
|
+
* |{ok: false, reason: string}
|
|
534
|
+
* |{available: false, reason: string}} `available: false` is neither a pass
|
|
535
|
+
* nor a failure: it is a plant this tree cannot make, reported with its cause
|
|
536
|
+
* the same way every unavailable plant is.
|
|
537
|
+
*/
|
|
538
|
+
export function assessBadgeFloor({ ladder, plants } = {}) {
|
|
539
|
+
const calibrated = plantCalibration(plants);
|
|
540
|
+
if (!calibrated.ok) {
|
|
541
|
+
return { available: false, reason: `${calibrated.reason} — there is no declaration to strip, and no rung to lose` };
|
|
542
|
+
}
|
|
543
|
+
if (!ladder || typeof ladder !== "object") {
|
|
544
|
+
return {
|
|
545
|
+
available: false,
|
|
546
|
+
reason:
|
|
547
|
+
"this profile declares no `ladder` a reader can see without starting a lane, so no rung exists either way " +
|
|
548
|
+
"(a ladder declared only on the object `steps(ctx)` returns is invisible here — qa/lib/evidence-ladder.mjs)",
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
const names = [...new Set([...(ladder.l0Required ?? []), ...(ladder.l1Required ?? [])])];
|
|
552
|
+
const rows = names.map((name) => ({ name, verdict: "PASS" }));
|
|
553
|
+
const earned = evidenceLevel(rows, null, { mode: "full", ladder, plants });
|
|
554
|
+
if (!earned) {
|
|
555
|
+
return {
|
|
556
|
+
available: false,
|
|
557
|
+
reason:
|
|
558
|
+
`this profile's ladder grants no rung even with ${names.length ? names.join(", ") : "every declared step"} PASSing, ` +
|
|
559
|
+
"so there is no rung for the floor to withhold",
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
// THE PLANT: the same rows, the same ladder, and no plant material.
|
|
563
|
+
const stripped = evidenceLevel(rows, null, { mode: "full", ladder, plants: undefined });
|
|
564
|
+
if (stripped) {
|
|
565
|
+
return {
|
|
566
|
+
ok: false,
|
|
567
|
+
reason:
|
|
568
|
+
`PLANTED the profile's own ladder with its \`plants\` declaration removed and the lane still earned ` +
|
|
569
|
+
`${stripped.rung} · ${stripped.name} — the badge floor is not being applied. NORTH-STAR.md §8.9: "a profile ` +
|
|
570
|
+
`with no calibrated plants earns no rung"; §6.7: a profile ships plants this instrument can run, or it ships ` +
|
|
571
|
+
`without a badge. The grader is qa/lib/evidence-level.mjs and the floor it asks is qa/lib/plant-calibration.mjs`,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
return { ok: true, rung: earned.rung };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* What "FAIL BY NAME" means for one plant, in ONE place.
|
|
579
|
+
*
|
|
580
|
+
* Two callers need this answer — `plantRow`, to pick the row a plant with no
|
|
581
|
+
* declared step is about, and `assessPlantRun`, to say which part of the
|
|
582
|
+
* naming is missing — and a second copy of "what counts as naming" is exactly
|
|
583
|
+
* how `flowCitation` and `scanCitations` came to disagree about the same file
|
|
584
|
+
* in the same tree. So there is one, and the caller that needs a sentence gets
|
|
585
|
+
* the offending part back rather than re-deriving it.
|
|
586
|
+
*
|
|
587
|
+
* @param {{reason?: string}} row
|
|
588
|
+
* @param {{names?: string[], reasonPattern?: string}} plant
|
|
589
|
+
* @returns {{kind: "name"|"pattern", want: string, reason: string}|null} null when the row names everything asked of it
|
|
590
|
+
*/
|
|
591
|
+
function missingName(row, plant) {
|
|
592
|
+
const reason = String(row?.reason ?? "");
|
|
593
|
+
for (const name of plant?.names ?? []) {
|
|
594
|
+
if (!reason.includes(name)) return { kind: "name", want: name, reason };
|
|
595
|
+
}
|
|
596
|
+
if (plant?.reasonPattern && !new RegExp(plant.reasonPattern).test(reason)) {
|
|
597
|
+
return { kind: "pattern", want: plant.reasonPattern, reason };
|
|
598
|
+
}
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* The receipt row a plant's assertion is about — found three ways, in the order
|
|
604
|
+
* of how much each one can be trusted about a pack nobody here has met.
|
|
605
|
+
*
|
|
606
|
+
* BY ITS DATA, for the two floor plants. Their assertion is about the step that
|
|
607
|
+
* VOUCHES FOR THE LANE, whatever the pack calls it, and the core computes the
|
|
608
|
+
* findings that row carries — so `harness` identifies it on every pack, with
|
|
609
|
+
* the name only as the pre-`harness` fallback. See VOUCHING_STEP.
|
|
610
|
+
*
|
|
611
|
+
* BY THE NAME THE PACK GAVE IT, when the pack declared one for this plant kind
|
|
612
|
+
* (`plants.observedBy`). This is the sharp case and the one to prefer: a
|
|
613
|
+
* different row failing instead is the mix-up the instrument exists to catch,
|
|
614
|
+
* and only a named row can catch it.
|
|
615
|
+
*
|
|
616
|
+
* BY THE LANE, when the pack declared nothing. There is no honest third source
|
|
617
|
+
* for another pack's step name (see `observingStep`), so the assertion becomes
|
|
618
|
+
* "some row went red naming what was planted" — the row carrying the plant's own
|
|
619
|
+
* fingerprint. Only FAIL rows can satisfy it: an ERROR is a step that fell over,
|
|
620
|
+
* not a gate that read something and refused, and treating the two alike is how
|
|
621
|
+
* a crash comes to read as a calibrated gate. When no FAIL row names the plant,
|
|
622
|
+
* a red row is returned anyway rather than null, because `assessPlantRun` then
|
|
623
|
+
* prints that row's reason — "this is what your lane actually said" is a far
|
|
624
|
+
* better sentence to hand an adopter than "no row" — and the FAIL rows are
|
|
625
|
+
* offered ahead of the ERROR ones, or a step that blew up alongside a gate that
|
|
626
|
+
* fired correctly would be reported as the finding. This branch is weaker than
|
|
627
|
+
* a declared name and is meant to be: it is what a pack gets for not saying,
|
|
628
|
+
* and it still bites.
|
|
629
|
+
*
|
|
630
|
+
* @param {Array<object>} steps
|
|
631
|
+
* @param {{step?: string|null, vouching?: boolean, names?: string[], reasonPattern?: string}} plant
|
|
632
|
+
* @returns {object|null}
|
|
633
|
+
*/
|
|
634
|
+
export function plantRow(steps, plant) {
|
|
635
|
+
const rows = Array.isArray(steps) ? steps : [];
|
|
636
|
+
const byName = plant?.step ? (rows.find((s) => s && s.name === plant.step) ?? null) : null;
|
|
637
|
+
if (plant?.vouching) return rows.find((s) => s && s.harness && typeof s.harness === "object") ?? byName;
|
|
638
|
+
if (plant?.step) return byName;
|
|
639
|
+
const failed = rows.filter((s) => s && s.verdict === "FAIL");
|
|
640
|
+
return failed.find((s) => !missingName(s, plant)) ?? failed[0] ?? rows.find((s) => s && s.verdict === "ERROR") ?? null;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Judge one planted run. Every branch here is a distinct framework defect and
|
|
645
|
+
* says which one it is: a hang, a lane that produced no receipt, a guard that
|
|
646
|
+
* did not fail, or a guard that failed WITHOUT NAMING what it caught.
|
|
647
|
+
*
|
|
648
|
+
* "FAIL BY NAME" is not decoration. A gate that fails with a generic message
|
|
649
|
+
* costs the reader the diagnosis every time it fires, and — worse — cannot be
|
|
650
|
+
* told apart from a gate failing for an unrelated reason, which is how a
|
|
651
|
+
* calibration passes on a gate that was never actually read.
|
|
652
|
+
*
|
|
653
|
+
* Every message below names the row it ACTUALLY READ, not the row the plant
|
|
654
|
+
* asked for: on a pack whose steps are spelled differently those are two
|
|
655
|
+
* different strings, and reporting the request instead of the finding is how a
|
|
656
|
+
* lookup miss reads as a gate defect.
|
|
657
|
+
*
|
|
658
|
+
* @param {{hung?: boolean, ms?: number, exit?: number|null,
|
|
659
|
+
* receipt?: {verdict?: string, steps?: Array<object>}|null,
|
|
660
|
+
* stderr?: string}} run
|
|
661
|
+
* @param {{label: string, kind?: string, step?: string|null, names?: string[], vouching?: boolean}} plant
|
|
662
|
+
* @param {number} boundMs
|
|
663
|
+
* @returns {{ok: true}|{ok: false, reason: string}}
|
|
664
|
+
*/
|
|
665
|
+
export function assessPlantRun(run, plant, boundMs) {
|
|
666
|
+
const label = plant?.label ?? "(unnamed plant)";
|
|
667
|
+
if (run?.hung) {
|
|
668
|
+
return { ok: false, reason: `"${label}" did not return inside ${boundMs}ms — the framework HANGS on a failing input` };
|
|
669
|
+
}
|
|
670
|
+
const receipt = run?.receipt;
|
|
671
|
+
if (!receipt) {
|
|
672
|
+
const tail = String(run?.stderr ?? "").slice(-600);
|
|
673
|
+
return { ok: false, reason: `"${label}" returned no receipt (exit ${run?.exit ?? "?"})${tail ? `:\n${tail}` : ""}` };
|
|
674
|
+
}
|
|
675
|
+
const row = plantRow(receipt.steps, plant);
|
|
676
|
+
const rowName = row?.name ?? plant.step ?? "the lane";
|
|
677
|
+
if (receipt.verdict !== "FAIL" || !row || row.verdict !== "FAIL") {
|
|
678
|
+
// A missing row says what was looked for. "harnessIntegrity: no row" over a
|
|
679
|
+
// receipt whose vouching row is called something else is a true sentence
|
|
680
|
+
// that points at the wrong thing. The third branch is the pack that named
|
|
681
|
+
// no step for this kind: "no row" would be the same wrong sentence again,
|
|
682
|
+
// so it says what the lookup actually was AND names the declaration that
|
|
683
|
+
// would make it sharp — an adopter cannot fix a requirement nobody states.
|
|
684
|
+
const found = row
|
|
685
|
+
? `${rowName}: ${row.verdict}`
|
|
686
|
+
: plant.vouching
|
|
687
|
+
? `no row carries a \`harness\` object and none is named ${plant.step}`
|
|
688
|
+
: plant.step
|
|
689
|
+
? `${plant.step}: no row`
|
|
690
|
+
: `no row on this receipt went red at all, and this profile's \`plants.observedBy\` names no step for ${plant.kind ?? "this plant"}`;
|
|
691
|
+
return {
|
|
692
|
+
ok: false,
|
|
693
|
+
reason: `planted "${label}" and the lane said ${receipt.verdict} (${found}) — the guard did not FAIL BY NAME`,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
// Some gates name something the selector cannot know in advance — a feature
|
|
697
|
+
// this project happens to have. `reasonPattern` is how those still assert
|
|
698
|
+
// FAIL BY NAME instead of settling for "it went red"; `missingName` holds
|
|
699
|
+
// both halves so the row lookup above cannot drift from the judgement here.
|
|
700
|
+
const missing = missingName(row, plant);
|
|
701
|
+
if (missing) {
|
|
702
|
+
return missing.kind === "name"
|
|
703
|
+
? { ok: false, reason: `${rowName} FAILed on "${label}" but did not NAME ${missing.want}:\n${missing.reason}` }
|
|
704
|
+
: { ok: false, reason: `${rowName} FAILed on "${label}" but named nothing matching /${missing.want}/:\n${missing.reason}` };
|
|
705
|
+
}
|
|
706
|
+
return { ok: true };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Judge the baseline (and the post-revert re-run): a tree that cannot go green
|
|
711
|
+
* on its own has nothing to plant against, and every FAIL below would be
|
|
712
|
+
* unattributable.
|
|
713
|
+
*
|
|
714
|
+
* @param {{hung?: boolean, receipt?: object|null, exit?: number|null, stderr?: string}} run
|
|
715
|
+
* @param {string} phase "baseline" or "revert"
|
|
716
|
+
* @param {number} boundMs
|
|
717
|
+
* @returns {{ok: true}|{ok: false, reason: string}}
|
|
718
|
+
*/
|
|
719
|
+
export function assessGreenRun(run, phase, boundMs) {
|
|
720
|
+
if (run?.hung) {
|
|
721
|
+
return { ok: false, reason: `the ${phase} run did not return inside ${boundMs}ms — the framework HANGS on a passing input` };
|
|
722
|
+
}
|
|
723
|
+
if (!run?.receipt) {
|
|
724
|
+
const tail = String(run?.stderr ?? "").slice(-600);
|
|
725
|
+
return { ok: false, reason: `the ${phase} run returned no receipt (exit ${run?.exit ?? "?"})${tail ? `:\n${tail}` : ""}` };
|
|
726
|
+
}
|
|
727
|
+
if (run.receipt.verdict !== "PASS") {
|
|
728
|
+
const bad = (run.receipt.steps ?? [])
|
|
729
|
+
.filter((s) => s?.verdict === "FAIL" || s?.verdict === "ERROR")
|
|
730
|
+
.map((s) => `${s.name}: ${String(s.reason ?? "").split("\n")[0]}`)
|
|
731
|
+
.join("; ");
|
|
732
|
+
const suffix =
|
|
733
|
+
phase === "revert"
|
|
734
|
+
? " — the plants were not the only cause, or a revert did not restore the tree"
|
|
735
|
+
: " — fix the tree before calibrating anything against it";
|
|
736
|
+
return { ok: false, reason: `the ${phase} run is ${run.receipt.verdict} (${bad || "no failing row named"})${suffix}` };
|
|
737
|
+
}
|
|
738
|
+
return { ok: true };
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Rule 1's stated bound, made checkable.
|
|
743
|
+
*
|
|
744
|
+
* Rule 1 says a calibration is "four steps, seconds each". That sentence has
|
|
745
|
+
* always been prose, and prose does not refuse: payment-blueprint calibrated
|
|
746
|
+
* through a 30–60 s composite Gradle build, which violated the rule on its own
|
|
747
|
+
* terms from the first cycle, and nothing noticed for three occurrences. The
|
|
748
|
+
* cost is not the single cycle — it is per-instance cost times every instance,
|
|
749
|
+
* which is how 38 minutes disappears into something that looks like rigour.
|
|
750
|
+
*
|
|
751
|
+
* A calibration cycle slower than this is not wrong, but it is a finding: it
|
|
752
|
+
* means the plant is being run through the wrong instrument, and the report
|
|
753
|
+
* must say so while the choice is still cheap to change.
|
|
754
|
+
*/
|
|
755
|
+
export const CALIBRATION_BUDGET_MS = 5_000;
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* @param {Array<{label: string, ms: number}>} cycles
|
|
759
|
+
* @param {number} [budgetMs]
|
|
760
|
+
* @returns {{withinBudget: boolean, slowest: {label: string, ms: number}|null,
|
|
761
|
+
* totalMs: number, note: string|null}}
|
|
762
|
+
*/
|
|
763
|
+
export function assessCalibrationCost(cycles, budgetMs = CALIBRATION_BUDGET_MS) {
|
|
764
|
+
const rows = Array.isArray(cycles) ? cycles.filter((c) => Number.isFinite(c?.ms)) : [];
|
|
765
|
+
const totalMs = rows.reduce((n, c) => n + c.ms, 0);
|
|
766
|
+
if (!rows.length) return { withinBudget: true, slowest: null, totalMs: 0, note: null };
|
|
767
|
+
const slowest = rows.reduce((a, b) => (b.ms > a.ms ? b : a));
|
|
768
|
+
if (slowest.ms <= budgetMs) return { withinBudget: true, slowest, totalMs, note: null };
|
|
769
|
+
return {
|
|
770
|
+
withinBudget: false,
|
|
771
|
+
slowest,
|
|
772
|
+
totalMs,
|
|
773
|
+
note:
|
|
774
|
+
`slowest calibration cycle "${slowest.label}" took ${slowest.ms}ms against a ${budgetMs}ms budget. ` +
|
|
775
|
+
`GATE-RULES Rule 1 calls a calibration "four steps, seconds each" — a cycle past that is being run ` +
|
|
776
|
+
`through the wrong instrument, and the cost is paid on every plant forever.`,
|
|
777
|
+
};
|
|
778
|
+
}
|