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.
Files changed (145) hide show
  1. package/bin/create-cmp.mjs +33 -3
  2. package/package.json +10 -4
  3. package/packages/harness/package.json +18 -8
  4. package/packages/harness/src/approve.mjs +46 -18
  5. package/packages/harness/src/console/console-data.mjs +117 -0
  6. package/packages/harness/src/console/console-evidence.mjs +122 -0
  7. package/packages/harness/src/console/console-overview.mjs +642 -0
  8. package/packages/harness/src/console/console-shell.mjs +1139 -0
  9. package/packages/harness/src/console/console-tabs.mjs +2828 -0
  10. package/packages/harness/src/console/contrast.mjs +74 -0
  11. package/packages/harness/src/console/preview-service.mjs +1384 -0
  12. package/packages/harness/src/framework-check.mjs +747 -0
  13. package/packages/harness/src/lib/a11y.mjs +1 -1
  14. package/packages/harness/src/lib/affected-tests.mjs +99 -44
  15. package/packages/harness/src/lib/agent-hold.mjs +234 -0
  16. package/packages/harness/src/lib/approvals.mjs +177 -350
  17. package/packages/harness/src/lib/audit-cadence.mjs +1 -1
  18. package/packages/harness/src/lib/comments.mjs +18 -4
  19. package/packages/harness/src/lib/determinism.mjs +275 -14
  20. package/packages/harness/src/lib/evidence-badge.mjs +38 -6
  21. package/packages/harness/src/lib/evidence-ladder.mjs +202 -0
  22. package/packages/harness/src/lib/evidence-level.mjs +144 -59
  23. package/packages/harness/src/lib/feature-brief.mjs +53 -16
  24. package/packages/harness/src/lib/flight-recorder.mjs +72 -8
  25. package/packages/harness/src/lib/framework-check.mjs +778 -0
  26. package/packages/harness/src/lib/harness-lock.mjs +87 -19
  27. package/packages/harness/src/lib/harness-manifest.mjs +208 -0
  28. package/packages/harness/src/lib/harness-region.mjs +106 -2
  29. package/packages/harness/src/lib/harness-source.mjs +142 -0
  30. package/packages/harness/src/lib/inputs-hash.mjs +180 -22
  31. package/packages/harness/src/lib/lane-markers.mjs +78 -0
  32. package/packages/harness/src/lib/lane-narrator.mjs +4 -2
  33. package/packages/harness/src/lib/lane-runner.mjs +72 -6
  34. package/packages/harness/src/lib/plan.mjs +44 -11
  35. package/packages/harness/src/lib/plant-calibration.mjs +135 -0
  36. package/packages/harness/src/lib/profile-loader.mjs +323 -0
  37. package/packages/harness/src/lib/profiles/cmp/affected.mjs +70 -0
  38. package/packages/harness/src/lib/profiles/cmp/android-checks.mjs +56 -0
  39. package/packages/harness/src/lib/profiles/cmp/artifacts.mjs +381 -0
  40. package/packages/harness/src/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
  41. package/packages/harness/src/lib/profiles/cmp/console-copy.mjs +29 -0
  42. package/packages/harness/src/lib/profiles/cmp/declarations.mjs +181 -0
  43. package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
  44. package/packages/harness/src/lib/profiles/cmp/index.mjs +47 -0
  45. package/packages/harness/src/lib/profiles/cmp/ladder.mjs +67 -0
  46. package/packages/harness/src/lib/profiles/cmp/maestro.mjs +113 -0
  47. package/packages/harness/src/lib/profiles/cmp/plants.mjs +95 -0
  48. package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/reachability.mjs +1 -1
  49. package/packages/harness/src/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +230 -28
  50. package/packages/harness/src/lib/receipt-validate.mjs +84 -16
  51. package/packages/harness/src/lib/spec-coverage.mjs +179 -92
  52. package/packages/harness/src/lib/spec-model.mjs +262 -0
  53. package/packages/harness/src/lib/step-cache.mjs +11 -2
  54. package/packages/harness/src/lib/step-outcomes.mjs +84 -151
  55. package/packages/harness/src/lib/walk.mjs +1 -1
  56. package/packages/harness/src/plan.mjs +30 -1
  57. package/packages/harness/src/preview-gallery.mjs +1 -1
  58. package/packages/harness/src/receipt-check.mjs +95 -26
  59. package/packages/harness/src/record-audit.mjs +1 -1
  60. package/packages/harness/src/scaffold-feature.mjs +10 -2
  61. package/packages/harness/src/verify.mjs +222 -142
  62. package/packages/harness/src/watch.mjs +146 -33
  63. package/packages/receipts/package.json +1 -1
  64. package/packages/receipts/src/index.mjs +2 -2
  65. package/packages/receipts/src/inputs-hash.mjs +180 -22
  66. package/packages/receipts/src/receipt-validate.mjs +84 -16
  67. package/src/commands/attach.mjs +128 -4
  68. package/src/commands/harden.mjs +7 -1
  69. package/src/commands/upgrade.mjs +7 -0
  70. package/src/lib/minimal.mjs +23 -1
  71. package/src/scaffold.mjs +8 -0
  72. package/template/.claude/settings.json +2 -2
  73. package/template/.claude/skills/add-feature/SKILL.md +7 -3
  74. package/template/.claude/skills/add-repository/SKILL.md +2 -2
  75. package/template/.claude/skills/add-screen/SKILL.md +8 -4
  76. package/template/.githooks/pre-push +9 -0
  77. package/template/CLAUDE.md +55 -186
  78. package/template/docs/TESTING.md +1 -1
  79. package/template/gitignore +4 -0
  80. package/template/qa/approve.mjs +46 -18
  81. package/template/qa/comments.json +1 -1
  82. package/template/qa/evidence/schema.json +258 -34
  83. package/template/qa/framework-check.mjs +747 -0
  84. package/template/qa/harness-manifest.json +10 -0
  85. package/template/qa/lib/a11y.mjs +1 -1
  86. package/template/qa/lib/affected-tests.mjs +99 -44
  87. package/template/qa/lib/agent-hold.mjs +234 -0
  88. package/template/qa/lib/approvals.mjs +177 -350
  89. package/template/qa/lib/audit-cadence.mjs +1 -1
  90. package/template/qa/lib/comments.mjs +18 -4
  91. package/template/qa/lib/determinism.mjs +275 -14
  92. package/template/qa/lib/evidence-badge.mjs +38 -6
  93. package/template/qa/lib/evidence-ladder.mjs +202 -0
  94. package/template/qa/lib/evidence-level.mjs +144 -59
  95. package/template/qa/lib/feature-brief.mjs +53 -16
  96. package/template/qa/lib/flight-recorder.mjs +72 -8
  97. package/template/qa/lib/framework-check.mjs +778 -0
  98. package/template/qa/lib/harness-lock.mjs +87 -19
  99. package/template/qa/lib/harness-manifest.mjs +208 -0
  100. package/template/qa/lib/harness-region.mjs +106 -2
  101. package/template/qa/lib/harness-source.mjs +142 -0
  102. package/template/qa/lib/inputs-hash.mjs +180 -22
  103. package/template/qa/lib/lane-markers.mjs +78 -0
  104. package/template/qa/lib/lane-narrator.mjs +4 -2
  105. package/template/qa/lib/lane-runner.mjs +72 -6
  106. package/template/qa/lib/plan.mjs +44 -11
  107. package/template/qa/lib/plant-calibration.mjs +135 -0
  108. package/template/qa/lib/profile-loader.mjs +323 -0
  109. package/template/qa/lib/profiles/cmp/affected.mjs +70 -0
  110. package/template/qa/lib/profiles/cmp/android-checks.mjs +56 -0
  111. package/template/qa/lib/profiles/cmp/artifacts.mjs +381 -0
  112. package/template/qa/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
  113. package/template/qa/lib/profiles/cmp/console-copy.mjs +29 -0
  114. package/template/qa/lib/profiles/cmp/declarations.mjs +181 -0
  115. package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
  116. package/template/qa/lib/profiles/cmp/index.mjs +47 -0
  117. package/template/qa/lib/profiles/cmp/ladder.mjs +67 -0
  118. package/template/qa/lib/profiles/cmp/maestro.mjs +113 -0
  119. package/template/qa/lib/profiles/cmp/plants.mjs +95 -0
  120. package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/reachability.mjs +1 -1
  121. package/template/qa/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +230 -28
  122. package/template/qa/lib/receipt-validate.mjs +84 -16
  123. package/template/qa/lib/spec-coverage.mjs +179 -92
  124. package/template/qa/lib/spec-model.mjs +262 -0
  125. package/template/qa/lib/step-cache.mjs +11 -2
  126. package/template/qa/lib/step-outcomes.mjs +84 -151
  127. package/template/qa/lib/walk.mjs +1 -1
  128. package/template/qa/plan.mjs +30 -1
  129. package/template/qa/preview-gallery.mjs +1 -1
  130. package/template/qa/receipt-check.mjs +95 -26
  131. package/template/qa/record-audit.mjs +1 -1
  132. package/template/qa/scaffold-feature.mjs +10 -2
  133. package/template/qa/verified-surface.json +17 -0
  134. package/template/qa/verify.mjs +222 -142
  135. package/template/qa/watch.mjs +146 -33
  136. /package/packages/harness/src/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
  137. /package/packages/harness/src/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
  138. /package/packages/harness/src/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
  139. /package/packages/harness/src/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
  140. /package/packages/harness/src/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
  141. /package/template/qa/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
  142. /package/template/qa/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
  143. /package/template/qa/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
  144. /package/template/qa/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
  145. /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
+ }