@mutagent/evaluator 0.1.0-alpha.5 → 0.2.0-alpha.1

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 (55) hide show
  1. package/.claude/skills/mutagent-evaluator/SKILL.md +28 -30
  2. package/.claude/skills/mutagent-evaluator/assets/agents/dataset-builder.md +6 -5
  3. package/.claude/skills/mutagent-evaluator/assets/agents/evaluator.md +93 -30
  4. package/.claude/skills/mutagent-evaluator/assets/code-quality-criteria.yaml +75 -0
  5. package/.claude/skills/mutagent-evaluator/assets/templates/discover-report.template.html +397 -0
  6. package/.claude/skills/mutagent-evaluator/assets/templates/eval-report.template.html +594 -0
  7. package/.claude/skills/mutagent-evaluator/assets/templates/review-report.template.html +560 -0
  8. package/.claude/skills/mutagent-evaluator/golden/judge-trajectory.prose.md +69 -0
  9. package/.claude/skills/mutagent-evaluator/lenses/methodology-critic-lens.md +1 -1
  10. package/.claude/skills/mutagent-evaluator/references/data-registry.md +43 -0
  11. package/.claude/skills/mutagent-evaluator/references/edd-loop.md +6 -6
  12. package/.claude/skills/mutagent-evaluator/references/eval-layers.md +52 -0
  13. package/.claude/skills/mutagent-evaluator/references/eval-stage.md +37 -4
  14. package/.claude/skills/mutagent-evaluator/references/memory-format.md +1 -1
  15. package/.claude/skills/mutagent-evaluator/schemas/methodology-review.schema.yaml +1 -1
  16. package/.claude/skills/mutagent-evaluator/scripts/agent-dispatch.ts +0 -0
  17. package/.claude/skills/mutagent-evaluator/scripts/aggregate-discover.ts +254 -9
  18. package/.claude/skills/mutagent-evaluator/scripts/audience.ts +84 -0
  19. package/.claude/skills/mutagent-evaluator/scripts/build-review-ui.ts +320 -6
  20. package/.claude/skills/mutagent-evaluator/scripts/cli/prep.ts +32 -2
  21. package/.claude/skills/mutagent-evaluator/scripts/cli/profile-subject.ts +1 -1
  22. package/.claude/skills/mutagent-evaluator/scripts/code-eval-library.ts +201 -0
  23. package/.claude/skills/mutagent-evaluator/scripts/code-quality-verdict.ts +60 -68
  24. package/.claude/skills/mutagent-evaluator/scripts/config/schema.ts +3 -3
  25. package/.claude/skills/mutagent-evaluator/scripts/contracts/agentspec-evals.ts +101 -39
  26. package/.claude/skills/mutagent-evaluator/scripts/contracts/eval-matrix.ts +326 -3
  27. package/.claude/skills/mutagent-evaluator/scripts/contracts/eval-types.ts +30 -4
  28. package/.claude/skills/mutagent-evaluator/scripts/determine-outcome.ts +8 -0
  29. package/.claude/skills/mutagent-evaluator/scripts/edd/change-request.ts +1 -1
  30. package/.claude/skills/mutagent-evaluator/scripts/edd/edd-types.ts +2 -2
  31. package/.claude/skills/mutagent-evaluator/scripts/judge-prompt-template.ts +144 -15
  32. package/.claude/skills/mutagent-evaluator/scripts/materialize-dataset.ts +130 -68
  33. package/.claude/skills/mutagent-evaluator/scripts/matrix-judge.ts +219 -1
  34. package/.claude/skills/mutagent-evaluator/scripts/memory/append.ts +1 -1
  35. package/.claude/skills/mutagent-evaluator/scripts/memory/ratify.ts +165 -0
  36. package/.claude/skills/mutagent-evaluator/scripts/merge-criteria.ts +849 -0
  37. package/.claude/skills/mutagent-evaluator/scripts/prep-tasks.ts +15 -4
  38. package/.claude/skills/mutagent-evaluator/scripts/read-manifest.ts +120 -0
  39. package/.claude/skills/mutagent-evaluator/scripts/read-unitf-traces.ts +34 -2
  40. package/.claude/skills/mutagent-evaluator/scripts/render-build-cards.ts +37 -0
  41. package/.claude/skills/mutagent-evaluator/scripts/render-discover-report-v3.ts +1155 -0
  42. package/.claude/skills/mutagent-evaluator/scripts/render-eval-report-v3.ts +610 -0
  43. package/.claude/skills/mutagent-evaluator/scripts/render-eval-report.ts +11 -92
  44. package/.claude/skills/mutagent-evaluator/scripts/render-review-report-v3.ts +691 -0
  45. package/.claude/skills/mutagent-evaluator/scripts/report-fragments.ts +173 -0
  46. package/.claude/skills/mutagent-evaluator/scripts/route-failures.ts +12 -9
  47. package/.claude/skills/mutagent-evaluator/scripts/run-evaluate.ts +46 -1
  48. package/.claude/skills/mutagent-evaluator/scripts/run-pipeline.ts +39 -4
  49. package/.claude/skills/mutagent-evaluator/scripts/run-review.ts +266 -0
  50. package/.claude/skills/mutagent-evaluator/scripts/subject-profile.ts +82 -0
  51. package/.claude/skills/mutagent-evaluator/scripts/sync-eval-criteria.ts +2 -2
  52. package/.claude/skills/mutagent-evaluator/scripts/unitf-to-evaltrace.ts +64 -21
  53. package/.claude/skills/mutagent-evaluator/scripts/verify-render.ts +728 -0
  54. package/bin/mutagent-cli.mjs +9 -5
  55. package/package.json +2 -2
@@ -0,0 +1,728 @@
1
+ /**
2
+ * verify-render — a PURE, BROWSER-FREE structural verifier for a rendered v3 report.
3
+ *
4
+ * WHY THIS EXISTS. Until now, nothing in `bun test` could catch a broken report. The
5
+ * render checks lived in the SIM harness (`scripts/release/sim-all.ts`), and the CI
6
+ * guard test drives that harness with `playwright:false` — so every deep check (tab
7
+ * sections, the drill, the E6 font floor, the layout gates) executes only when a human
8
+ * runs the sim by hand. A report could ship structurally broken and the test suite
9
+ * would stay green. This module is the part of that verification which needs no
10
+ * browser, so it can run as an ordinary unit test.
11
+ *
12
+ * ─────────────────────────────────────────────────────────────────────────────
13
+ * WHAT IT COVERS (assert-able from the emitted bytes alone)
14
+ * · no unfilled `@@SLOT@@` markers survived the render
15
+ * · every tab exists, and every tab PANEL carries content
16
+ * · every required `data-component` island is present, and none is blank
17
+ * · the server-rendered collections are non-empty — metric tiles, trace-inventory
18
+ * rows, criterion-detail cards, finding cards, self-eval cards
19
+ * · the embedded `window.ASTER` payload parses and its arrays are non-empty
20
+ * (criteria · traces · per-criterion scores · the findings handoff markdown)
21
+ * · audience discipline — no "INTERNAL" text on a client-audience render, and no
22
+ * dev feedback bar unless devFeedback was explicitly requested
23
+ * · the E6 FONT FLOOR, statically: every reachable `font-size` — in the embedded
24
+ * stylesheet AND in inline styles emitted by the page's own JS — is >= the floor
25
+ * · NAMED ABSENCE — a section with nothing to report must SAY so in words; a blank
26
+ * island is a defect, not an empty state
27
+ *
28
+ * WHAT IT DOES **NOT** COVER (this is not a substitute for the Playwright pass)
29
+ * · COMPUTED styles. It reads declarations, not what the browser resolves. A rule
30
+ * overridden by specificity, a CSS variable, or a media query is invisible here.
31
+ * · LAYOUT and geometry. Nothing is measured: no box sizes, no text extent, no
32
+ * overrun, wrapping, overlap or clipping. The rotated-header containment checks
33
+ * live in the sim's headless pass and can only live there.
34
+ * · ANY behaviour. No tab is clicked, no row is drilled, no walk is opened. Client
35
+ * JS never executes, so the checks below verify the DATA the page will render
36
+ * FROM (`window.ASTER`) and the HOSTS it renders INTO — never that the render
37
+ * actually happened. `#ledger tbody` is legitimately empty in the emitted bytes.
38
+ * · CONSOLE errors, network, or anything runtime.
39
+ *
40
+ * So: this catches a report that was BUILT wrong. Playwright still owns everything
41
+ * about a report that BEHAVES wrong. Neither replaces the other.
42
+ *
43
+ * PURE — no clock, no randomness, no network, no filesystem. Input is HTML text.
44
+ */
45
+
46
+ /** One problem found in a rendered report. `note` never fails a verification. */
47
+ export interface RenderIssue {
48
+ severity: "error" | "note";
49
+ /** the check that produced it (stable id, safe to assert on) */
50
+ check: string;
51
+ detail: string;
52
+ }
53
+
54
+ export interface VerifyRenderResult {
55
+ ok: boolean;
56
+ issues: RenderIssue[];
57
+ /** counts the checks read off the document — useful in failure messages. */
58
+ stats: Record<string, number>;
59
+ /**
60
+ * Checks this spec DECLARED ITSELF OUT OF, by name. A reader must be able to see
61
+ * what did not run without inferring it from a missing stats key — otherwise a
62
+ * surface that skips the panel checks reads as one that passed them.
63
+ */
64
+ checksSkipped: string[];
65
+ }
66
+
67
+ /** A required island: an element that must exist AND must not be blank. */
68
+ interface IslandReq {
69
+ /** CSS selector (HTMLRewriter subset) */
70
+ selector: string;
71
+ label: string;
72
+ /** minimum matching elements (default 1) */
73
+ minCount?: number;
74
+ /**
75
+ * `true` when this element is a HOST that client JS fills at runtime — it must
76
+ * EXIST but is legitimately empty in the emitted bytes. Its content is covered by
77
+ * the corresponding `dataArrays` entry instead.
78
+ */
79
+ jsFilled?: boolean;
80
+ /**
81
+ * `true` when this island belongs to an INTERNAL-only surface. Required on an
82
+ * internal render; NOT required on an external one, where the audience strip has
83
+ * correctly removed it (its absence there is asserted via `internalMarkers`).
84
+ */
85
+ internalOnly?: boolean;
86
+ }
87
+
88
+ /** A non-empty array required inside the embedded data payload. */
89
+ interface DataArrayReq {
90
+ /** dotted path into the payload, e.g. "criteria" */
91
+ path: string;
92
+ label: string;
93
+ minLength?: number;
94
+ }
95
+
96
+ export interface ReportSpec {
97
+ /** human name, used in messages */
98
+ surface: string;
99
+ /** the global the payload is assigned to, e.g. "window.ASTER" */
100
+ dataGlobal: string;
101
+ /** the tab-bar elements themselves — labels are read from HERE, not from the
102
+ * whole document (every label is repeated in its panel header). */
103
+ tabSelector: string;
104
+ /**
105
+ * Every tab label that must appear, in order. EMPTY is allowed only alongside
106
+ * `tabless: true` — see that field.
107
+ */
108
+ tabLabels: string[];
109
+ /**
110
+ * DECLARE that this surface genuinely has no tab bar. Required when `tabLabels` is
111
+ * empty, because to the engine an empty array is indistinguishable from a typo, a
112
+ * bad copy-paste, or a half-finished spec — and it would silently skip the tab
113
+ * check while still reporting `ok`. This is the NAMED-ABSENCE rule applied to the
114
+ * SPEC itself: absence has to be stated, not inferred.
115
+ */
116
+ tabless?: true;
117
+ /** tab labels belonging to INTERNAL-only surfaces — not required on an external render. */
118
+ internalOnlyTabs: string[];
119
+ /** `data-page` panels belonging to INTERNAL-only surfaces — ditto. */
120
+ internalOnlyPanels: string[];
121
+ /** substrings that mark INTERNAL-only material; none may survive an `external`
122
+ * render. Matched case-insensitively. */
123
+ internalMarkers: string[];
124
+ /** every `data-page` panel that must exist and carry content. EMPTY only with
125
+ * `panelless: true`. */
126
+ panels: string[];
127
+ /**
128
+ * DECLARE that this surface has no `data-page` panels. Required when `panels` is
129
+ * empty. Skipping the panel checks ALSO skips the per-panel NAMED-ABSENCE check, so
130
+ * an undeclared empty array buys a "sections all fine" impression for a surface
131
+ * whose sections were never looked at.
132
+ */
133
+ panelless?: true;
134
+ islands: IslandReq[];
135
+ dataArrays: DataArrayReq[];
136
+ /** payload string fields that must be non-empty */
137
+ dataStrings: { path: string; label: string }[];
138
+ /**
139
+ * NAMED-ABSENCE surfaces that are CONDITIONALLY populated. Each entry demands that
140
+ * AT LEAST ONE of its `anyOf` strings appears: either the populated form, or the
141
+ * explicit "nothing to report" wording. Neither ⇒ the section rendered BLANK, which
142
+ * this contract treats as a defect rather than an empty state.
143
+ *
144
+ * This is how a surface like the PENDING near-duplicate block is checked: it has no
145
+ * `data-component` marker and legitimately renders one of two shapes, so requiring
146
+ * the populated form alone would fail every clean run.
147
+ */
148
+ requireAnyOf?: { label: string; anyOf: string[] }[];
149
+ }
150
+
151
+ export interface VerifyOpts {
152
+ /**
153
+ * Drives the V11 audience check in BOTH directions: `external` asserts the internal
154
+ * markers are absent, `internal` asserts they are still present. Omit to skip the
155
+ * audience check entirely.
156
+ */
157
+ audience?: "internal" | "external";
158
+ /** the run id — asserted ABSENT from an external render (run-internal detail). */
159
+ runId?: string;
160
+ /** the pinned judge-model string — asserted ABSENT from an external render. */
161
+ judgeModel?: string;
162
+ /** the dev feedback bar is expected ONLY when this is true. */
163
+ devFeedback?: boolean;
164
+ /** E6 readability floor in px. */
165
+ fontFloorPx?: number;
166
+ }
167
+
168
+ /* ── the EVAL report (v3 frozen contract) ──────────────────────────────────── */
169
+ export const EVAL_REPORT_SPEC: ReportSpec = {
170
+ surface: "eval-report-v3",
171
+ dataGlobal: "window.ASTER",
172
+ tabSelector: "#report > .tabs s",
173
+ tabLabels: ["① Overview", "② Trajectory ‖ Judge", "③ Eval Scorecard", "④ Findings", "⑤ Self-Eval"],
174
+ panels: ["overview", "trajectory-judge", "scorecard", "findings", "self-eval"],
175
+ // ⑤ is the INTERNAL surface: required on an internal render, correctly gone on an
176
+ // external one. Without this the structural checks would demand the very tab the
177
+ // audience strip is supposed to remove.
178
+ internalOnlyTabs: ["⑤ Self-Eval"],
179
+ internalOnlyPanels: ["self-eval"],
180
+ /**
181
+ * V11 audience contract. Every one of these must be ABSENT from an `external`
182
+ * render and PRESENT in an `internal` one — the verifier checks both directions,
183
+ * because a one-directional check passes vacuously if the renderer strips
184
+ * everything (or renders nothing at all).
185
+ *
186
+ * Note the HTML COMMENT marker. `<!-- ⑤ SELF-EVAL -->` ships to the client exactly
187
+ * like any other byte, so removing the visible tab while leaving the marker is
188
+ * still a leak; matching is case-insensitive and comment-blind for that reason.
189
+ */
190
+ internalMarkers: [
191
+ "⑤ Self-Eval", // the tab-bar entry + panel header
192
+ "<!-- ⑤ SELF-EVAL -->", // the section comment marker — ships like any byte
193
+ "internal — stripped on publish", // the panel's own internal label
194
+ "mgt-selfeval-card", // the MR-1..5 self-grading cards
195
+ ".mutagent/evaluator/reports", // the gitignored artifact path (footer + payload)
196
+ ],
197
+ islands: [
198
+ // server-rendered — content is in the bytes
199
+ { selector: '[data-component="mgt-report-hero"]', label: "① report hero" },
200
+ { selector: ".scm", label: "① / ③ metric tiles", minCount: 5 },
201
+ { selector: '[data-component="mgt-trace-inventory"] tbody tr', label: "① ingested-trace inventory rows" },
202
+ { selector: ".subc", label: "③ criterion-detail cards" },
203
+ { selector: '[data-component="mgt-finding-card"]', label: "④ finding cards" },
204
+ { selector: '[data-component="mgt-selfeval-card"]', label: "⑤ self-eval cards", minCount: 5, internalOnly: true },
205
+ // JS-filled hosts — must EXIST; their content is asserted via dataArrays
206
+ { selector: '[data-component="mgt-subject-profile"]', label: "① subject-profile host", jsFilled: true },
207
+ { selector: '[data-component="mgt-suite-summary"]', label: "① eval-suite host", jsFilled: true },
208
+ { selector: '[data-component="mgt-trace-ledger"]', label: "② trace-ledger host", jsFilled: true },
209
+ { selector: '[data-component="mgt-layer-matrix"]', label: "③ layer-matrix host", jsFilled: true },
210
+ { selector: '[data-component="mgt-criteria-matrix"]', label: "③ criteria-matrix host", jsFilled: true },
211
+ ],
212
+ dataArrays: [
213
+ { path: "criteria", label: "criteria (drives the suite table + both matrices)" },
214
+ { path: "traces", label: "traces (drives the ② ledger + matrix columns)" },
215
+ ],
216
+ dataStrings: [
217
+ { path: "handoffMd", label: "④ findings handoff markdown" },
218
+ { path: "profile.name", label: "① subject name" },
219
+ ],
220
+ };
221
+
222
+ /* ── the DISCOVERY report (v3 frozen contract) ─────────────────────────────────
223
+ * Added with no engine change, which was the design bet in 10df56fe8: a new surface
224
+ * is a `ReportSpec` plus the thin wrapper below. ⑥ Methodology is this surface's
225
+ * INTERNAL boundary — the counterpart of the eval report's ⑤ Self-Eval. */
226
+ export const DISCOVERY_REPORT_SPEC: ReportSpec = {
227
+ surface: "discovery-report-v3",
228
+ dataGlobal: "window.ASTER",
229
+ tabSelector: "#report > .tabs s",
230
+ tabLabels: [
231
+ "① Overview",
232
+ "② Corpus & Sampling", // NOTE: `&amp;` in the source; HTMLRewriter decodes entities
233
+ "③ Discovery Walk",
234
+ "④ Criteria Catalog",
235
+ "⑤ Dataset",
236
+ "⑥ Methodology [INT]",
237
+ ],
238
+ panels: ["overview", "corpus-sampling", "discovery-walk", "criteria-catalog", "dataset", "methodology"],
239
+ internalOnlyTabs: ["⑥ Methodology [INT]"],
240
+ internalOnlyPanels: ["methodology"],
241
+ internalMarkers: [
242
+ "⑥ Methodology", // the tab-bar entry + panel header
243
+ "[INT]", // the tab's explicit internal label
244
+ "<!-- ⑥ METHODOLOGY -->", // the section comment marker — ships like any byte
245
+ "internal — stripped on publish", // the panel's own internal label
246
+ ".mutagent/evaluator/reports", // the gitignored artifact path (footer + payload)
247
+ ],
248
+ islands: [
249
+ { selector: '[data-component="mgt-report-hero"]', label: "① report hero" },
250
+ // JS-filled hosts — must EXIST; content asserted via dataArrays
251
+ { selector: '[data-component="mgt-funnel"]', label: "① coverage funnel host", jsFilled: true },
252
+ { selector: '[data-component="mgt-sample-heatgrid"]', label: "① sample heatgrid host", jsFilled: true },
253
+ { selector: '[data-component="mgt-criterion-catalog"]', label: "④ criterion-catalog host", jsFilled: true },
254
+ { selector: '[data-component="mgt-layer-coverage"]', label: "④ layer-coverage host", jsFilled: true },
255
+ { selector: '[data-component="mgt-trace-peek"]', label: "③ trace-peek host", jsFilled: true },
256
+ ],
257
+ dataArrays: [
258
+ { path: "criteria", label: "mined criteria (drives the ④ catalog)" },
259
+ { path: "traces", label: "traces (drives the ① heatgrid + ③ peek)" },
260
+ { path: "funnelStages", label: "① funnel stages" },
261
+ ],
262
+ dataStrings: [{ path: "adoptionMd", label: "④ adoption handoff markdown" }],
263
+ requireAnyOf: [
264
+ {
265
+ // The near-duplicate PENDING surface (impl-dedup's cross-run decision ledger).
266
+ // It renders EITHER the open-questions table or an explicit "None." — both are
267
+ // valid; silence is not. Wording taken from `pendingSection()`, not invented.
268
+ label: "③ PENDING near-duplicate questions (populated or explicitly none)",
269
+ anyOf: [
270
+ "⚖ PENDING — open near-duplicate questions",
271
+ "No pair of criteria was similar enough to raise a question",
272
+ ],
273
+ },
274
+ ],
275
+ };
276
+
277
+ /* ── the REVIEW surface — deliberately NOT specced here ──────────────────────
278
+ * CLOSED (was TODO(review-surface)). The review surface hosts its own
279
+ * `REVIEW_REPORT_SPEC` inside its test file and drives `verifyRenderedReport`
280
+ * unchanged — the engine needed no modification for a third surface, which is the
281
+ * whole point of it being spec-driven. A spec does not have to live in this module
282
+ * to be a first-class citizen; co-locating it with the tests that use it keeps the
283
+ * surface's owner and its contract in one place.
284
+ *
285
+ * NOTE for any future surface: that surface has NO tab bar, so it sets
286
+ * `tabless: true` / `panelless: true` and its result carries the corresponding
287
+ * `checksSkipped` entries. It compensates with island + payload assertions. If you
288
+ * are adding a tabless surface, do the same — and read the two declaration fields'
289
+ * docs before assuming an empty array is harmless.
290
+ *
291
+ * `audience` for any new renderer comes from `scripts/audience.ts`
292
+ * (`isExternal` + `stripInternalSurface`) — do not rediscover the rule. */
293
+
294
+ /* ── tiny helpers (pure) ───────────────────────────────────────────────────── */
295
+ function getPath(obj: unknown, path: string): unknown {
296
+ return path.split(".").reduce<unknown>((acc, k) => (acc === null || acc === undefined ? undefined : (acc as Record<string, unknown>)[k]), obj);
297
+ }
298
+
299
+ /**
300
+ * Extract the balanced JSON object starting at `from`. String-aware, so a `{` or `}`
301
+ * inside subject text (a quoted agent reply, a criterion statement) does not end it.
302
+ *
303
+ * A naive "slice to the next `;\n`" DOES NOT WORK across surfaces: the eval template
304
+ * puts the payload on its own line, the discovery template ends it with `;</script>` on
305
+ * the same line, so the naive scan ran past the payload and captured garbage. Balanced
306
+ * braces is the surface-independent answer.
307
+ */
308
+ function extractJsonObject(html: string, from: number): string | null {
309
+ const open = html.indexOf("{", from);
310
+ if (open < 0) return null;
311
+ let depth = 0;
312
+ let inStr = false;
313
+ let esc = false;
314
+ for (let i = open; i < html.length; i++) {
315
+ const ch = html[i]!;
316
+ if (inStr) {
317
+ if (esc) esc = false;
318
+ else if (ch === "\\") esc = true;
319
+ else if (ch === '"') inStr = false;
320
+ continue;
321
+ }
322
+ if (ch === '"') inStr = true;
323
+ else if (ch === "{") depth++;
324
+ else if (ch === "}") {
325
+ depth--;
326
+ if (depth === 0) return html.slice(open, i + 1);
327
+ }
328
+ }
329
+ return null;
330
+ }
331
+
332
+ /**
333
+ * Decode the handful of HTML entities that appear in TAB LABELS. HTMLRewriter hands
334
+ * back raw source text, so a label written `② Corpus &amp; Sampling` in the template
335
+ * arrives with the entity intact — comparing it against the human-readable label in a
336
+ * spec would spuriously report the tab missing.
337
+ */
338
+ function decodeEntities(s: string): string {
339
+ return s
340
+ .replace(/&amp;/g, "&")
341
+ .replace(/&lt;/g, "<")
342
+ .replace(/&gt;/g, ">")
343
+ .replace(/&quot;/g, '"')
344
+ .replace(/&#39;/g, "'")
345
+ .replace(/&nbsp;/g, " ");
346
+ }
347
+
348
+ /** Extract the `<style>` blocks (the embedded stylesheet). */
349
+ function styleBlocks(html: string): string[] {
350
+ const out: string[] = [];
351
+ const re = /<style[^>]*>([\s\S]*?)<\/style>/g;
352
+ let m: RegExpExecArray | null;
353
+ while ((m = re.exec(html)) !== null) out.push(m[1]!);
354
+ return out;
355
+ }
356
+
357
+ /**
358
+ * Split a stylesheet into `{selector, body}` rules. Deliberately simple: the frozen
359
+ * template is machine-generated flat CSS with no nesting and no at-rule blocks that
360
+ * carry font sizes. A rule this misses is a NOTE, never a silent pass — see the
361
+ * unreachable-rule handling in `checkFontFloor`.
362
+ */
363
+ function cssRules(css: string): { selector: string; body: string }[] {
364
+ const out: { selector: string; body: string }[] = [];
365
+ const re = /([^{}]+)\{([^{}]*)\}/g;
366
+ let m: RegExpExecArray | null;
367
+ while ((m = re.exec(css)) !== null) out.push({ selector: m[1]!.trim(), body: m[2]! });
368
+ return out;
369
+ }
370
+
371
+ /**
372
+ * Is a CSS rule REACHABLE in this document? A selector's class/id tokens are looked
373
+ * up as literal `class="…"` / `id="…"` occurrences anywhere in the file — including
374
+ * inside the page's own JS, which is how the report emits most of its markup.
375
+ *
376
+ * The frozen contract legitimately retains rules for chrome that production no longer
377
+ * renders (the mock badge, the VQ pick widgets, a tag removed in a later round). Those
378
+ * must not fail a font-floor check — but they must not be silently ignored either, so
379
+ * an unreachable sub-floor rule is reported as a NOTE.
380
+ */
381
+ function selectorReachable(selector: string, html: string): boolean {
382
+ const tokens = selector.match(/[.#][A-Za-z0-9_-]+/g);
383
+ if (tokens === null || tokens.length === 0) return true; // bare tag selector — assume reachable
384
+ return tokens.every((t) => {
385
+ const name = t.slice(1);
386
+ return t.startsWith(".")
387
+ ? new RegExp(`class="[^"]*\\b${name}\\b`).test(html) || new RegExp(`classList\\.(add|toggle)\\('${name}'`).test(html)
388
+ : new RegExp(`id="${name}"`).test(html);
389
+ });
390
+ }
391
+
392
+ /* ── the checks ────────────────────────────────────────────────────────────── */
393
+
394
+ /** E6, statically. Reads DECLARATIONS — never computed styles (see the header). */
395
+ function checkFontFloor(html: string, floor: number, issues: RenderIssue[], stats: Record<string, number>): void {
396
+ let declarations = 0;
397
+ let waived = 0;
398
+
399
+ // (a) the embedded stylesheet, rule by rule, so an offender can be NAMED.
400
+ for (const css of styleBlocks(html)) {
401
+ for (const { selector, body } of cssRules(css)) {
402
+ const m = /font-size:\s*([\d.]+)px/.exec(body);
403
+ if (m === null) continue;
404
+ declarations++;
405
+ const px = Number.parseFloat(m[1]!);
406
+ if (px >= floor) continue;
407
+ if (selectorReachable(selector, html)) {
408
+ issues.push({
409
+ severity: "error",
410
+ check: "font-floor",
411
+ detail: `stylesheet rule \`${selector}\` sets font-size:${px}px, below the ${floor}px floor`,
412
+ });
413
+ } else {
414
+ waived++;
415
+ issues.push({
416
+ severity: "note",
417
+ check: "font-floor-unreachable",
418
+ detail: `\`${selector}\` is ${px}px but renders nowhere in this document — retained mock chrome, not a live offender`,
419
+ });
420
+ }
421
+ }
422
+ }
423
+
424
+ // (b) INLINE font sizes — including the ones the page's own JS emits into markup.
425
+ // These are invisible to any stylesheet census, and are exactly where a
426
+ // sub-floor size hides.
427
+ const inline = /style="[^"]*font-size:\s*([\d.]+)px/g;
428
+ let im: RegExpExecArray | null;
429
+ while ((im = inline.exec(html)) !== null) {
430
+ declarations++;
431
+ const px = Number.parseFloat(im[1]!);
432
+ if (px < floor) {
433
+ const at = Math.max(0, im.index - 60);
434
+ issues.push({
435
+ severity: "error",
436
+ check: "font-floor-inline",
437
+ detail: `inline style sets font-size:${px}px, below the ${floor}px floor — near: …${html.slice(at, im.index + 40).replace(/\s+/g, " ")}…`,
438
+ });
439
+ }
440
+ }
441
+
442
+ stats["fontDeclarations"] = declarations;
443
+ stats["fontRulesWaivedUnreachable"] = waived;
444
+ }
445
+
446
+ /** The embedded data payload the page renders FROM. */
447
+ function checkDataPayload(html: string, spec: ReportSpec, issues: RenderIssue[], stats: Record<string, number>): void {
448
+ const anchor = `${spec.dataGlobal}=`;
449
+ const start = html.indexOf(anchor);
450
+ if (start < 0) {
451
+ issues.push({ severity: "error", check: "data-payload", detail: `no \`${spec.dataGlobal}=\` payload found` });
452
+ return;
453
+ }
454
+ const raw = extractJsonObject(html, start + anchor.length);
455
+ if (raw === null) {
456
+ issues.push({ severity: "error", check: "data-payload", detail: `\`${spec.dataGlobal}\` payload is not a balanced JSON object` });
457
+ return;
458
+ }
459
+ let payload: unknown;
460
+ try {
461
+ payload = JSON.parse(raw.replace(/\\u003c/g, "<"));
462
+ } catch (e) {
463
+ issues.push({ severity: "error", check: "data-payload", detail: `\`${spec.dataGlobal}\` is not valid JSON: ${String(e).slice(0, 120)}` });
464
+ return;
465
+ }
466
+ for (const a of spec.dataArrays) {
467
+ const v = getPath(payload, a.path);
468
+ const n = Array.isArray(v) ? v.length : -1;
469
+ stats[`data.${a.path}`] = n;
470
+ if (n < (a.minLength ?? 1)) {
471
+ issues.push({
472
+ severity: "error",
473
+ check: "data-array-empty",
474
+ detail: `${a.label} — \`${a.path}\` ${n < 0 ? "is not an array" : `has ${n} entries`}; the page would render an empty section`,
475
+ });
476
+ }
477
+ }
478
+ for (const s of spec.dataStrings) {
479
+ const v = getPath(payload, s.path);
480
+ if (typeof v !== "string" || v.trim().length === 0) {
481
+ issues.push({ severity: "error", check: "data-string-empty", detail: `${s.label} — \`${s.path}\` is empty` });
482
+ }
483
+ }
484
+ }
485
+
486
+ /* ── the verifier ──────────────────────────────────────────────────────────── */
487
+ /**
488
+ * Verify a rendered report's STRUCTURE + DATA. See the module header for the
489
+ * (deliberately explicit) list of what this does and does not cover.
490
+ */
491
+ export async function verifyRenderedReport(html: string, spec: ReportSpec, opts: VerifyOpts = {}): Promise<VerifyRenderResult> {
492
+ const issues: RenderIssue[] = [];
493
+ const stats: Record<string, number> = {};
494
+ const checksSkipped: string[] = [];
495
+ const floor = opts.fontFloorPx ?? 11;
496
+
497
+ // ── SPEC HONESTY GATE ────────────────────────────────────────────────────────
498
+ // An empty requirement array is indistinguishable from a mistake, and skipping a
499
+ // check silently is worse than not having it: it manufactures confidence. So an
500
+ // empty array must be DECLARED, and whatever is skipped is NAMED in the result.
501
+ if (spec.tabLabels.length === 0) {
502
+ if (spec.tabless !== true) {
503
+ issues.push({
504
+ severity: "error",
505
+ check: "spec-undeclared-tabless",
506
+ detail: `${spec.surface}: spec declares no tabs — set \`tabless: true\` if that is intentional, otherwise \`tabLabels\` is empty by mistake and the tab check would be silently skipped`,
507
+ });
508
+ } else {
509
+ checksSkipped.push("tab-presence (surface declared tabless)");
510
+ }
511
+ }
512
+ if (spec.panels.length === 0) {
513
+ if (spec.panelless !== true) {
514
+ issues.push({
515
+ severity: "error",
516
+ check: "spec-undeclared-panelless",
517
+ detail: `${spec.surface}: spec declares no panels — set \`panelless: true\` if that is intentional, otherwise \`panels\` is empty by mistake and BOTH the panel-content and per-panel named-absence checks would be silently skipped`,
518
+ });
519
+ } else {
520
+ checksSkipped.push("panel-content (surface declared panelless)");
521
+ checksSkipped.push("named-absence per panel (surface declared panelless)");
522
+ }
523
+ }
524
+ // On an EXTERNAL render the internal-only surfaces are meant to be gone, so they
525
+ // are not required here — their ABSENCE is asserted by the `internalMarkers` pass.
526
+ const external = opts.audience === "external";
527
+ const reqTabs = external ? spec.tabLabels.filter((t) => !spec.internalOnlyTabs.includes(t)) : spec.tabLabels;
528
+ const reqPanels = external ? spec.panels.filter((p) => !spec.internalOnlyPanels.includes(p)) : spec.panels;
529
+ const reqIslands = external ? spec.islands.filter((i) => i.internalOnly !== true) : spec.islands;
530
+
531
+ // 1. unfilled slots — the renderer guards this too, but a report can reach a test
532
+ // from disk without having gone through that guard.
533
+ const slot = /@@[A-Z_]+@@/.exec(html);
534
+ if (slot !== null) issues.push({ severity: "error", check: "unfilled-slot", detail: `slot marker survived the render: ${slot[0]}` });
535
+
536
+ // 2. tabs present — as TAB ELEMENTS, not as text anywhere in the document. Every
537
+ // tab label also appears in its panel header, so a substring search over the
538
+ // whole file would happily "find" a tab that had been deleted from the tab bar.
539
+ const tabLabelsFound: string[] = [];
540
+ await new HTMLRewriter()
541
+ .on(spec.tabSelector, {
542
+ text(t) {
543
+ const s = decodeEntities(t.text).trim();
544
+ if (s.length > 0) tabLabelsFound.push(s);
545
+ },
546
+ })
547
+ .transform(new Response(html))
548
+ .text();
549
+ for (const label of reqTabs) {
550
+ if (!tabLabelsFound.includes(label)) {
551
+ issues.push({
552
+ severity: "error",
553
+ check: "tab-missing",
554
+ detail: `tab "${label}" is not in the tab bar (\`${spec.tabSelector}\`); found [${tabLabelsFound.join(" | ")}]`,
555
+ });
556
+ }
557
+ }
558
+ stats["tabs"] = tabLabelsFound.length;
559
+
560
+ // 3. panels present AND carrying content (a tab that opens onto nothing is a defect).
561
+ const panelText = new Map<string, number>();
562
+ const panelSeen = new Set<string>();
563
+ let rewriter = new HTMLRewriter();
564
+ for (const page of reqPanels) {
565
+ rewriter = rewriter.on(`.sec[data-page="${page}"]`, {
566
+ element() {
567
+ panelSeen.add(page);
568
+ },
569
+ text(t) {
570
+ panelText.set(page, (panelText.get(page) ?? 0) + t.text.trim().length);
571
+ },
572
+ });
573
+ }
574
+ // 4. islands.
575
+ const islandCount = new Map<string, number>();
576
+ const islandText = new Map<string, number>();
577
+ const islandChildren = new Map<string, number>();
578
+ for (const isl of reqIslands) {
579
+ rewriter = rewriter.on(isl.selector, {
580
+ element() {
581
+ islandCount.set(isl.selector, (islandCount.get(isl.selector) ?? 0) + 1);
582
+ },
583
+ text(t) {
584
+ islandText.set(isl.selector, (islandText.get(isl.selector) ?? 0) + t.text.trim().length);
585
+ },
586
+ });
587
+ rewriter = rewriter.on(`${isl.selector} *`, {
588
+ element() {
589
+ islandChildren.set(isl.selector, (islandChildren.get(isl.selector) ?? 0) + 1);
590
+ },
591
+ });
592
+ }
593
+ // 5. the dev feedback bar.
594
+ let fbpanel = 0;
595
+ rewriter = rewriter.on("#fbpanel", { element() { fbpanel++; } });
596
+
597
+ await rewriter.transform(new Response(html)).text();
598
+
599
+ for (const page of reqPanels) {
600
+ if (!panelSeen.has(page)) {
601
+ issues.push({ severity: "error", check: "panel-missing", detail: `panel \`[data-page="${page}"]\` is absent` });
602
+ continue;
603
+ }
604
+ const len = panelText.get(page) ?? 0;
605
+ stats[`panel.${page}.textLen`] = len;
606
+ // NAMED ABSENCE: a panel with nothing to report must still SAY something. Blank
607
+ // is never a valid empty state in this contract.
608
+ if (len < 40) {
609
+ issues.push({
610
+ severity: "error",
611
+ check: "named-absence",
612
+ detail: `panel \`${page}\` renders only ${len} chars of text — a section with nothing to report must NAME the absence in words, never be blank`,
613
+ });
614
+ }
615
+ }
616
+
617
+ for (const isl of reqIslands) {
618
+ const n = islandCount.get(isl.selector) ?? 0;
619
+ stats[`island.${isl.selector}`] = n;
620
+ if (n < (isl.minCount ?? 1)) {
621
+ issues.push({
622
+ severity: "error",
623
+ check: "island-missing",
624
+ detail: `${isl.label} — expected >= ${isl.minCount ?? 1} \`${isl.selector}\`, found ${n}`,
625
+ });
626
+ continue;
627
+ }
628
+ if (isl.jsFilled === true) continue; // content lives in the data payload
629
+ const filled = (islandText.get(isl.selector) ?? 0) > 0 || (islandChildren.get(isl.selector) ?? 0) > 0;
630
+ if (!filled) {
631
+ issues.push({
632
+ severity: "error",
633
+ check: "island-blank",
634
+ detail: `${isl.label} — \`${isl.selector}\` is present but BLANK (no text, no children)`,
635
+ });
636
+ }
637
+ }
638
+
639
+ // 6. audience discipline.
640
+ stats["fbpanel"] = fbpanel;
641
+ if (opts.devFeedback !== true && fbpanel > 0) {
642
+ issues.push({
643
+ severity: "error",
644
+ check: "feedback-bar-leak",
645
+ detail: "the dev feedback bar (#fbpanel) is present on a report not rendered with devFeedback",
646
+ });
647
+ }
648
+ if (opts.devFeedback === true && fbpanel === 0) {
649
+ issues.push({ severity: "error", check: "feedback-bar-missing", detail: "devFeedback was requested but #fbpanel is absent" });
650
+ }
651
+ // V11 — BOTH DIRECTIONS. An `external` render must contain NONE of the internal
652
+ // markers; an `internal` render must still contain them. Checking only the external
653
+ // direction would pass vacuously the moment the renderer strips too much (or
654
+ // renders nothing), which is exactly how a "leak fixed" claim goes wrong.
655
+ const lower = html.toLowerCase();
656
+ let internalMarkersPresent = 0;
657
+ for (const marker of spec.internalMarkers) {
658
+ const present = lower.includes(marker.toLowerCase());
659
+ if (present) internalMarkersPresent++;
660
+ if (opts.audience === "external" && present) {
661
+ issues.push({
662
+ severity: "error",
663
+ check: "internal-leak",
664
+ detail: `a client-audience render still contains INTERNAL-only material: "${marker}"`,
665
+ });
666
+ }
667
+ if (opts.audience === "internal" && !present) {
668
+ issues.push({
669
+ severity: "error",
670
+ check: "internal-surface-missing",
671
+ detail: `an INTERNAL render is missing "${marker}" — the audience strip is over-reaching, or the internal surface regressed`,
672
+ });
673
+ }
674
+ }
675
+ stats["internalMarkersPresent"] = internalMarkersPresent;
676
+
677
+ // run-internal operational detail that must never reach a client render. The run id
678
+ // is caller-supplied, so it is passed in rather than pattern-matched.
679
+ if (opts.audience === "external") {
680
+ if (opts.runId !== undefined && opts.runId.length > 0 && html.includes(opts.runId)) {
681
+ issues.push({ severity: "error", check: "run-id-leak", detail: `the internal run id "${opts.runId}" appears in a client-audience render` });
682
+ }
683
+ if (opts.judgeModel !== undefined && opts.judgeModel.length > 0 && html.includes(opts.judgeModel)) {
684
+ issues.push({
685
+ severity: "error",
686
+ check: "judge-pin-leak",
687
+ detail: `the pinned judge-model string "${opts.judgeModel}" appears in a client-audience render`,
688
+ });
689
+ }
690
+ }
691
+
692
+ // 7. conditionally-populated NAMED-ABSENCE surfaces: populated OR explicitly empty,
693
+ // never blank.
694
+ for (const req of spec.requireAnyOf ?? []) {
695
+ const hit = req.anyOf.find((n) => html.includes(n));
696
+ if (hit === undefined) {
697
+ issues.push({
698
+ severity: "error",
699
+ check: "named-absence-surface",
700
+ detail: `${req.label} — the section says NEITHER its populated form nor its explicit empty form; a blank section is a defect, not an empty state (expected one of: ${req.anyOf.map((n) => `"${n}"`).join(" | ")})`,
701
+ });
702
+ }
703
+ }
704
+
705
+ // 8. data payload + 9. font floor.
706
+ checkDataPayload(html, spec, issues, stats);
707
+ checkFontFloor(html, floor, issues, stats);
708
+
709
+ return { ok: issues.every((i) => i.severity !== "error"), issues, stats, checksSkipped };
710
+ }
711
+
712
+ /** Convenience wrapper for the v3 eval report. */
713
+ export async function verifyEvalReportHtml(html: string, opts: VerifyOpts = {}): Promise<VerifyRenderResult> {
714
+ return verifyRenderedReport(html, EVAL_REPORT_SPEC, opts);
715
+ }
716
+
717
+ /** Convenience wrapper for the v3 discovery report. */
718
+ export async function verifyDiscoveryReportHtml(html: string, opts: VerifyOpts = {}): Promise<VerifyRenderResult> {
719
+ return verifyRenderedReport(html, DISCOVERY_REPORT_SPEC, opts);
720
+ }
721
+
722
+ /** Render a verification result as a single actionable message (empty when ok). */
723
+ export function formatRenderIssues(result: VerifyRenderResult): string {
724
+ return result.issues
725
+ .filter((i) => i.severity === "error")
726
+ .map((i) => `[${i.check}] ${i.detail}`)
727
+ .join("\n");
728
+ }