Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions test/coverage-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,30 @@ if (!/<!-- coverage:start -->[\s\S]*?<!-- coverage:end -->/.test(txt)) {
console.error('No <!-- coverage:start/end --> markers in README.md — add them first.');
process.exit(1);
}

// Refuse to publish a metric COLLAPSE: a harness broken by an internal contract change
// (not a real regression) produces an absurd drop, and the auto-regen workflow would
// commit it silently — the HTML parser axis once sat at 1.2% for two regens because a
// duplicated normalizer kept reading a deleted CST field. Compare each new parser-agree
// value against the committed table; a fall of more than 20 points is treated as a
// broken harness, not a result.
{
const committed = txt.match(/<!-- coverage:start -->[\s\S]*?<!-- coverage:end -->/)![0];
const collapsed: string[] = [];
for (const lang of LANGS) {
const c = covBy.get(lang);
if (!c) continue;
const row = committed.match(new RegExp(`\\|\\s*${lang}\\s*\\|\\s*([\\d.]+)%`));
if (row && Number(row[1]) - c.agreePct > 20) {
collapsed.push(`${lang}: ${row[1]}% → ${c.agreePct.toFixed(1)}%`);
}
}
if (collapsed.length) {
console.error('Refusing to write a collapsed parser-agree value (broken harness, not a result): ' + collapsed.join(', '));
process.exit(1);
}
}

txt = txt.replace(/<!-- coverage:start -->[\s\S]*?<!-- coverage:end -->/, () => block);
writeFileSync(README, txt);
console.error('✓ README coverage region updated.');
21 changes: 9 additions & 12 deletions test/grammar-gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ export interface GenOptions {
fuzzRounds?: number; // budget (cap) on systematic-coverage rounds — DETERMINISTIC choice vectors, not random
seed?: number; // NO-OP, retained for back-compat: the generator is a pure function of the grammar
nestDepth?: number; // self-recursive nesting depth
timeBudgetMs?: number; // wall-clock cap for the depth strategies (large token-stream grammars)
timeBudgetMs?: number; // NO-OP, retained for back-compat: generation is deterministically work-capped (a wall-clock budget made the gate load-dependent)
}

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

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

const seen = new Set<string>();
const out: GenInput[] = [];
Expand Down Expand Up @@ -1266,7 +1267,6 @@ export function generateInputs(grammar: CstGrammar, opts: GenOptions = {}): GenI
// 2) bounded-exhaustive ROOTED at each self-recursive rule: exercises every rule's own small shapes
// (round-tripped against that rule as the entry), incl. the FIRST level of self-embedding.
for (const rn of recursive) {
if (timeUp()) break;
const r = w.ruleByName.get(rn)!;
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);
}
Expand All @@ -1275,7 +1275,6 @@ export function generateInputs(grammar: CstGrammar, opts: GenOptions = {}): GenI
// inner sibling) at depth 1..N — monogram#24 is a BlockSequence inside a BlockSequence with an
// inner sibling (`- - a\n - b\n- c`), which the un-biased capped enumeration starves.
for (const rn of recursive) {
if (timeUp()) break;
const r = w.ruleByName.get(rn)!;
for (let d = 1; d <= nestDepth; d++) push(w.nestChain(r.body, rn, d), `dirnest:${rn}@${d}`, rn);
}
Expand All @@ -1293,7 +1292,6 @@ export function generateInputs(grammar: CstGrammar, opts: GenOptions = {}): GenI
const COVER_BASE = 4, COVER_DIGITS = 4, ROTS = [0, 1, 2];
for (const ch of coverSchedule(COVER_BASE, COVER_DIGITS, coverRounds, ROTS)) push(w.cover(entry.body, depth + 2, ch), 'fuzz', entry.name);
for (const rn of recursive) {
if (timeUp()) break;
const r = w.ruleByName.get(rn)!;
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);
}
Expand All @@ -1306,7 +1304,6 @@ export function generateInputs(grammar: CstGrammar, opts: GenOptions = {}): GenI
// on the 50-rule TS grammar and needs no depth budget. The samples are guard-filtered (sampleVariants
// skips the leading-literal embeds for decimal-/anchor-led tokens, so `0x1F` is never mangled to `-0x1F`).
for (const tok of w.coverableTokens(entry.name)) {
if (timeUp()) break;
// CLEAN samples only (no interesting-literal embeds): tokenCover's job is to make the token APPEAR in
// a legal context, not to stress boundary collisions — that is the enum/fuzz strategies' role, where
// the embed belongs. Prepending a boundary sigil to a sigil-led token (`<` + `#name`, `>` + `@name`)
Expand Down
36 changes: 1 addition & 35 deletions test/html-conformance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,41 +17,7 @@ import { parseFragment } from 'parse5';
const grammar = (await import('../html.ts')).default;
const { parse } = createParser(grammar);

interface El { tag: string; children: El[] }

// Element tree (tag + nested elements only — text/comments/attrs ignored) from the
// Monogram CST: an Element node's tag is its open Name/VoidName leaf; its children
// are the Element nodes nested in its content.
function monoTree(node: any, src: string): El[] {
const out: El[] = [];
for (const c of node.children ?? []) collect(c, out, src);
return out;
}
function collect(node: any, out: El[], src: string): void {
if (node.tokenType !== undefined) return;
if (node.rule === 'Element') {
const name = (node.children ?? []).find(
(c: any) => c.tokenType === 'Name' || c.tokenType === 'VoidName',
);
out.push({ tag: (name ? src.slice(name.offset, name.end) : '').toLowerCase(), children: monoTree(node, src) });
return; // its element children are handled by the recursive monoTree above
}
for (const c of node.children ?? []) collect(c, out, src); // descend through wrappers (Node, …)
}

// Same element tree from a parse5 fragment. Spec-mandated containers parse5 SYNTHESISES
// (implied <tbody>, <colgroup>, …) carry a null sourceCodeLocation (needs
// {sourceCodeLocationInfo:true}); drop them and hoist their real children so this compares
// SOURCE structure against the source-faithful CST, not against parse5's constructed DOM.
function p5Tree(node: any): El[] {
const out: El[] = [];
for (const c of node.childNodes ?? []) {
if (!c.tagName) continue;
if (c.sourceCodeLocation == null) { out.push(...p5Tree(c)); continue; }
out.push({ tag: c.tagName.toLowerCase(), children: p5Tree(c) });
}
return out;
}
import { monoTree, p5Tree } from './html-tree.ts';

// Curated WELL-FORMED HTML fragments (B-lite scope: proper nesting, explicit or
// self-closed/void tags). No DOCTYPE, no optional-close-tag leniency — those are
Expand Down
43 changes: 43 additions & 0 deletions test/html-tree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Shared, side-effect-free HTML element-tree normalization — the ONE definition of
// "the element tree" both the conformance GATE (test/html-conformance.ts) and the
// README coverage METRIC (test/src-coverage-html.ts) compare on. It used to live
// copied in both files; the copy drifted (the metric kept reading the deleted CST
// `text` field after the span-only contract landed and silently reported 1.2%
// agreement), so the normalization now lives here once.

export interface El { tag: string; children: El[] }

// Element tree (tag + nested elements only — text/comments/attrs ignored) from the
// Monogram CST: an Element node's tag is its open Name/VoidName leaf; its children
// are the Element nodes nested in its content. Leaves are span-only — the tag text
// is sliced from the source.
export function monoTree(node: any, src: string): El[] {
const out: El[] = [];
for (const c of node.children ?? []) collect(c, out, src);
return out;
}
function collect(node: any, out: El[], src: string): void {
if (node.tokenType !== undefined) return;
if (node.rule === 'Element') {
const name = (node.children ?? []).find(
(c: any) => c.tokenType === 'Name' || c.tokenType === 'VoidName',
);
out.push({ tag: (name ? src.slice(name.offset, name.end) : '').toLowerCase(), children: monoTree(node, src) });
return; // its element children are handled by the recursive monoTree above
}
for (const c of node.children ?? []) collect(c, out, src); // descend through wrappers (Node, …)
}

// Same element tree from a parse5 fragment. Spec-mandated containers parse5 SYNTHESISES
// (implied <tbody>, <colgroup>, …) carry a null sourceCodeLocation (needs
// {sourceCodeLocationInfo:true}); drop them and hoist their real children so this compares
// SOURCE structure against the source-faithful CST, not against parse5's constructed DOM.
export function p5Tree(node: any): El[] {
const out: El[] = [];
for (const c of node.childNodes ?? []) {
if (!c.tagName) continue;
if (c.sourceCodeLocation == null) { out.push(...p5Tree(c)); continue; }
out.push({ tag: c.tagName.toLowerCase(), children: p5Tree(c) });
}
return out;
}
37 changes: 2 additions & 35 deletions test/src-coverage-html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,40 +19,7 @@ import { run, type AgreeResult, type CorpusItem } from './src-coverage.ts';
const grammar = (await import('../html.ts')).default;
const { parse } = createParser(grammar);

// ── Tree normalization + comparison — VERBATIM from test/html-conformance.ts. ──
interface El { tag: string; children: El[] }
function monoTree(node: any): El[] {
const out: El[] = [];
for (const c of node.children ?? []) collect(c, out);
return out;
}
function collect(node: any, out: El[]): void {
if (node.tokenType !== undefined) return;
if (node.rule === 'Element') {
const name = (node.children ?? []).find(
(c: any) => c.tokenType === 'Name' || c.tokenType === 'VoidName',
);
out.push({ tag: (name?.text ?? '').toLowerCase(), children: monoTree(node) });
return;
}
for (const c of node.children ?? []) collect(c, out);
}
function p5Tree(node: any): El[] {
const out: El[] = [];
for (const c of node.childNodes ?? []) {
if (!c.tagName) continue;
// parse5's tree construction synthesises spec-mandated containers (implied <tbody>,
// <colgroup>, …) that are NOT in the source; those carry a null sourceCodeLocation
// (requires parseFragment(..., {sourceCodeLocationInfo:true})). Drop them and hoist
// their real children, so we compare SOURCE structure (the CST's basis) against SOURCE
// structure — not Monogram's source-faithful CST against parse5's constructed DOM.
// Relocations (foster-parenting) keep their location → still differ; a Monogram parse
// failure still throws → still a disagreement. So only pure implied-container inserts flip.
if (c.sourceCodeLocation == null) { out.push(...p5Tree(c)); continue; }
out.push({ tag: c.tagName.toLowerCase(), children: p5Tree(c) });
}
return out;
}
import { monoTree, p5Tree } from './html-tree.ts';

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