Skip to content

Commit a42b4e9

Browse files
Fix the README HTML coverage collapse; de-flake the generative gate (#37)
1 parent 4761151 commit a42b4e9

5 files changed

Lines changed: 79 additions & 82 deletions

File tree

test/coverage-table.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,30 @@ if (!/<!-- coverage:start -->[\s\S]*?<!-- coverage:end -->/.test(txt)) {
9494
console.error('No <!-- coverage:start/end --> markers in README.md — add them first.');
9595
process.exit(1);
9696
}
97+
98+
// Refuse to publish a metric COLLAPSE: a harness broken by an internal contract change
99+
// (not a real regression) produces an absurd drop, and the auto-regen workflow would
100+
// commit it silently — the HTML parser axis once sat at 1.2% for two regens because a
101+
// duplicated normalizer kept reading a deleted CST field. Compare each new parser-agree
102+
// value against the committed table; a fall of more than 20 points is treated as a
103+
// broken harness, not a result.
104+
{
105+
const committed = txt.match(/<!-- coverage:start -->[\s\S]*?<!-- coverage:end -->/)![0];
106+
const collapsed: string[] = [];
107+
for (const lang of LANGS) {
108+
const c = covBy.get(lang);
109+
if (!c) continue;
110+
const row = committed.match(new RegExp(`\\|\\s*${lang}\\s*\\|\\s*([\\d.]+)%`));
111+
if (row && Number(row[1]) - c.agreePct > 20) {
112+
collapsed.push(`${lang}: ${row[1]}% → ${c.agreePct.toFixed(1)}%`);
113+
}
114+
}
115+
if (collapsed.length) {
116+
console.error('Refusing to write a collapsed parser-agree value (broken harness, not a result): ' + collapsed.join(', '));
117+
process.exit(1);
118+
}
119+
}
120+
97121
txt = txt.replace(/<!-- coverage:start -->[\s\S]*?<!-- coverage:end -->/, () => block);
98122
writeFileSync(README, txt);
99123
console.error('✓ README coverage region updated.');

test/grammar-gen.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ export interface GenOptions {
275275
fuzzRounds?: number; // budget (cap) on systematic-coverage rounds — DETERMINISTIC choice vectors, not random
276276
seed?: number; // NO-OP, retained for back-compat: the generator is a pure function of the grammar
277277
nestDepth?: number; // self-recursive nesting depth
278-
timeBudgetMs?: number; // wall-clock cap for the depth strategies (large token-stream grammars)
278+
timeBudgetMs?: number; // NO-OP, retained for back-compat: generation is deterministically work-capped (a wall-clock budget made the gate load-dependent)
279279
}
280280

281281
class Walker {
@@ -1204,13 +1204,14 @@ export function generateInputs(grammar: CstGrammar, opts: GenOptions = {}): GenI
12041204
const matOpts: MatOptions = { mode, indentStep: 2 };
12051205
const entry = grammar.rules[grammar.rules.length - 1];
12061206

1207-
// wall-clock budget: the depth strategies (nest / dirnest) over a LARGE token-stream grammar (the
1208-
// TS family — 50+ self-recursive rules, huge Pratt-expression alts) are heavy and add little, since
1209-
// those grammars have no indent/markup depth bugs for the scope≡role check to find. Cap total time
1210-
// so one driver stays tractable across all 7 languages; each per-rule loop checks it.
1211-
const t0 = Date.now();
1212-
const timeBudgetMs = opts.timeBudgetMs ?? 9000;
1213-
const timeUp = () => Date.now() - t0 > timeBudgetMs;
1207+
// NO wall-clock budget: every strategy below is bounded by DETERMINISTIC work caps
1208+
// (enumTop's budgetCalls, coverRounds, maxInputs, per-token sample counts), and the
1209+
// depth strategies only run for indent/markup grammars at all (depthMatters) — the
1210+
// whole 7-grammar sweep measures ~5s on a calm machine, worst single grammar ~4s.
1211+
// A Date.now() budget USED to sit here and made the GATE load-dependent: under a
1212+
// saturated machine the yaml depth strategies got cut mid-walk, the #23/#24 witness
1213+
// shapes silently vanished from the corpus, and mustCover failed with no code change
1214+
// anywhere. A correctness gate must be a pure function of the grammar.
12141215

12151216
const seen = new Set<string>();
12161217
const out: GenInput[] = [];
@@ -1266,7 +1267,6 @@ export function generateInputs(grammar: CstGrammar, opts: GenOptions = {}): GenI
12661267
// 2) bounded-exhaustive ROOTED at each self-recursive rule: exercises every rule's own small shapes
12671268
// (round-tripped against that rule as the entry), incl. the FIRST level of self-embedding.
12681269
for (const rn of recursive) {
1269-
if (timeUp()) break;
12701270
const r = w.ruleByName.get(rn)!;
12711271
for (let d = 1; d <= Math.min(nestDepth, 3); d++) for (const ems of w.enumTop(r.body, d)) push(ems, `nest:${rn}@${d}`, rn);
12721272
}
@@ -1275,7 +1275,6 @@ export function generateInputs(grammar: CstGrammar, opts: GenOptions = {}): GenI
12751275
// inner sibling) at depth 1..N — monogram#24 is a BlockSequence inside a BlockSequence with an
12761276
// inner sibling (`- - a\n - b\n- c`), which the un-biased capped enumeration starves.
12771277
for (const rn of recursive) {
1278-
if (timeUp()) break;
12791278
const r = w.ruleByName.get(rn)!;
12801279
for (let d = 1; d <= nestDepth; d++) push(w.nestChain(r.body, rn, d), `dirnest:${rn}@${d}`, rn);
12811280
}
@@ -1293,7 +1292,6 @@ export function generateInputs(grammar: CstGrammar, opts: GenOptions = {}): GenI
12931292
const COVER_BASE = 4, COVER_DIGITS = 4, ROTS = [0, 1, 2];
12941293
for (const ch of coverSchedule(COVER_BASE, COVER_DIGITS, coverRounds, ROTS)) push(w.cover(entry.body, depth + 2, ch), 'fuzz', entry.name);
12951294
for (const rn of recursive) {
1296-
if (timeUp()) break;
12971295
const r = w.ruleByName.get(rn)!;
12981296
for (const ch of coverSchedule(COVER_BASE, COVER_DIGITS, Math.ceil(coverRounds / 8), ROTS)) push(w.cover(r.body, depth + 2, ch), `fuzz:${rn}`, rn);
12991297
}
@@ -1306,7 +1304,6 @@ export function generateInputs(grammar: CstGrammar, opts: GenOptions = {}): GenI
13061304
// on the 50-rule TS grammar and needs no depth budget. The samples are guard-filtered (sampleVariants
13071305
// skips the leading-literal embeds for decimal-/anchor-led tokens, so `0x1F` is never mangled to `-0x1F`).
13081306
for (const tok of w.coverableTokens(entry.name)) {
1309-
if (timeUp()) break;
13101307
// CLEAN samples only (no interesting-literal embeds): tokenCover's job is to make the token APPEAR in
13111308
// a legal context, not to stress boundary collisions — that is the enum/fuzz strategies' role, where
13121309
// the embed belongs. Prepending a boundary sigil to a sigil-led token (`<` + `#name`, `>` + `@name`)

test/html-conformance.ts

Lines changed: 1 addition & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -17,41 +17,7 @@ import { parseFragment } from 'parse5';
1717
const grammar = (await import('../html.ts')).default;
1818
const { parse } = createParser(grammar);
1919

20-
interface El { tag: string; children: El[] }
21-
22-
// Element tree (tag + nested elements only — text/comments/attrs ignored) from the
23-
// Monogram CST: an Element node's tag is its open Name/VoidName leaf; its children
24-
// are the Element nodes nested in its content.
25-
function monoTree(node: any, src: string): El[] {
26-
const out: El[] = [];
27-
for (const c of node.children ?? []) collect(c, out, src);
28-
return out;
29-
}
30-
function collect(node: any, out: El[], src: string): void {
31-
if (node.tokenType !== undefined) return;
32-
if (node.rule === 'Element') {
33-
const name = (node.children ?? []).find(
34-
(c: any) => c.tokenType === 'Name' || c.tokenType === 'VoidName',
35-
);
36-
out.push({ tag: (name ? src.slice(name.offset, name.end) : '').toLowerCase(), children: monoTree(node, src) });
37-
return; // its element children are handled by the recursive monoTree above
38-
}
39-
for (const c of node.children ?? []) collect(c, out, src); // descend through wrappers (Node, …)
40-
}
41-
42-
// Same element tree from a parse5 fragment. Spec-mandated containers parse5 SYNTHESISES
43-
// (implied <tbody>, <colgroup>, …) carry a null sourceCodeLocation (needs
44-
// {sourceCodeLocationInfo:true}); drop them and hoist their real children so this compares
45-
// SOURCE structure against the source-faithful CST, not against parse5's constructed DOM.
46-
function p5Tree(node: any): El[] {
47-
const out: El[] = [];
48-
for (const c of node.childNodes ?? []) {
49-
if (!c.tagName) continue;
50-
if (c.sourceCodeLocation == null) { out.push(...p5Tree(c)); continue; }
51-
out.push({ tag: c.tagName.toLowerCase(), children: p5Tree(c) });
52-
}
53-
return out;
54-
}
20+
import { monoTree, p5Tree } from './html-tree.ts';
5521

5622
// Curated WELL-FORMED HTML fragments (B-lite scope: proper nesting, explicit or
5723
// self-closed/void tags). No DOCTYPE, no optional-close-tag leniency — those are

test/html-tree.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Shared, side-effect-free HTML element-tree normalization — the ONE definition of
2+
// "the element tree" both the conformance GATE (test/html-conformance.ts) and the
3+
// README coverage METRIC (test/src-coverage-html.ts) compare on. It used to live
4+
// copied in both files; the copy drifted (the metric kept reading the deleted CST
5+
// `text` field after the span-only contract landed and silently reported 1.2%
6+
// agreement), so the normalization now lives here once.
7+
8+
export interface El { tag: string; children: El[] }
9+
10+
// Element tree (tag + nested elements only — text/comments/attrs ignored) from the
11+
// Monogram CST: an Element node's tag is its open Name/VoidName leaf; its children
12+
// are the Element nodes nested in its content. Leaves are span-only — the tag text
13+
// is sliced from the source.
14+
export function monoTree(node: any, src: string): El[] {
15+
const out: El[] = [];
16+
for (const c of node.children ?? []) collect(c, out, src);
17+
return out;
18+
}
19+
function collect(node: any, out: El[], src: string): void {
20+
if (node.tokenType !== undefined) return;
21+
if (node.rule === 'Element') {
22+
const name = (node.children ?? []).find(
23+
(c: any) => c.tokenType === 'Name' || c.tokenType === 'VoidName',
24+
);
25+
out.push({ tag: (name ? src.slice(name.offset, name.end) : '').toLowerCase(), children: monoTree(node, src) });
26+
return; // its element children are handled by the recursive monoTree above
27+
}
28+
for (const c of node.children ?? []) collect(c, out, src); // descend through wrappers (Node, …)
29+
}
30+
31+
// Same element tree from a parse5 fragment. Spec-mandated containers parse5 SYNTHESISES
32+
// (implied <tbody>, <colgroup>, …) carry a null sourceCodeLocation (needs
33+
// {sourceCodeLocationInfo:true}); drop them and hoist their real children so this compares
34+
// SOURCE structure against the source-faithful CST, not against parse5's constructed DOM.
35+
export function p5Tree(node: any): El[] {
36+
const out: El[] = [];
37+
for (const c of node.childNodes ?? []) {
38+
if (!c.tagName) continue;
39+
if (c.sourceCodeLocation == null) { out.push(...p5Tree(c)); continue; }
40+
out.push({ tag: c.tagName.toLowerCase(), children: p5Tree(c) });
41+
}
42+
return out;
43+
}

test/src-coverage-html.ts

Lines changed: 2 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -19,40 +19,7 @@ import { run, type AgreeResult, type CorpusItem } from './src-coverage.ts';
1919
const grammar = (await import('../html.ts')).default;
2020
const { parse } = createParser(grammar);
2121

22-
// ── Tree normalization + comparison — VERBATIM from test/html-conformance.ts. ──
23-
interface El { tag: string; children: El[] }
24-
function monoTree(node: any): El[] {
25-
const out: El[] = [];
26-
for (const c of node.children ?? []) collect(c, out);
27-
return out;
28-
}
29-
function collect(node: any, out: El[]): void {
30-
if (node.tokenType !== undefined) return;
31-
if (node.rule === 'Element') {
32-
const name = (node.children ?? []).find(
33-
(c: any) => c.tokenType === 'Name' || c.tokenType === 'VoidName',
34-
);
35-
out.push({ tag: (name?.text ?? '').toLowerCase(), children: monoTree(node) });
36-
return;
37-
}
38-
for (const c of node.children ?? []) collect(c, out);
39-
}
40-
function p5Tree(node: any): El[] {
41-
const out: El[] = [];
42-
for (const c of node.childNodes ?? []) {
43-
if (!c.tagName) continue;
44-
// parse5's tree construction synthesises spec-mandated containers (implied <tbody>,
45-
// <colgroup>, …) that are NOT in the source; those carry a null sourceCodeLocation
46-
// (requires parseFragment(..., {sourceCodeLocationInfo:true})). Drop them and hoist
47-
// their real children, so we compare SOURCE structure (the CST's basis) against SOURCE
48-
// structure — not Monogram's source-faithful CST against parse5's constructed DOM.
49-
// Relocations (foster-parenting) keep their location → still differ; a Monogram parse
50-
// failure still throws → still a disagreement. So only pure implied-container inserts flip.
51-
if (c.sourceCodeLocation == null) { out.push(...p5Tree(c)); continue; }
52-
out.push({ tag: c.tagName.toLowerCase(), children: p5Tree(c) });
53-
}
54-
return out;
55-
}
22+
import { monoTree, p5Tree } from './html-tree.ts';
5623

5724
// ── Corpus. Pool every existing HTML fixture in the repo + tricky tree-construction. ──
5825
const conformanceCorpus: string[] = [
@@ -137,7 +104,7 @@ await run({
137104
agree: (html, official): AgreeResult => {
138105
const off = (official as { tree: string }).tree;
139106
let monoStr = '<throw>', agree = false;
140-
try { monoStr = JSON.stringify(monoTree(parse(html))); agree = monoStr === off; }
107+
try { monoStr = JSON.stringify(monoTree(parse(html), html)); agree = monoStr === off; }
141108
catch (e) { monoThrew++; monoStr = '<throw: ' + String((e as Error).message).split('\n')[0] + '>'; }
142109
if (!agree) treeFails.push({ html, mono: monoStr, off });
143110
return { agree };

0 commit comments

Comments
 (0)