Skip to content

Commit b934b39

Browse files
committed
Generative: close the comment coverage hole via injected witnesses
Comments are skip:true tokens — the parser drops them, so they are never CST leaves, so the scope≡role judge (which walks the parser's CST) never checked the highlighter's comment scopes (0% covered). Closing it needs a witness the GENERATOR records, not a parser leaf. 4a — deterministic comment injection at one safe position per mode (config-derived, no `//`/`#`/`<!--` hardcoded): token-stream → a no-newline block comment at an inter-token space; indent → end-of-line `# c` outside flow; markup → `<!-- c -->` after a tagClose. A re-parse-and-drop net keeps round-trip clean; the injected comment is recorded as a witness in GenInput.tokens (its first consumer), inheriting the host's tier. 4b — the judge grades each witness span: the flat highlighter must paint `comment` somewhere in it (same scopeBucket partition + leniency); a comment painted non-comment is unambiguous, so it GATES. Coverage hole closed 0→N graded per language (YAML 442, TS 46, …), all 0 uncolored today; proven non-trivial — mutating a comment scope makes every witness uncolored and the gate fail. Deterministic preserved; 7/7 + depth-site 2/2 (#23/#24); gap-ledger --check clean; agnostic 9/9.
1 parent 5d06488 commit b934b39

3 files changed

Lines changed: 211 additions & 4 deletions

File tree

test/generative-detect.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import { normScope } from './scope-roles.ts';
1717
import type { CstNode, CstChild } from '../src/gen-parser.ts';
1818
import type { CstGrammar, TokenPattern } from '../src/types.ts';
19-
import type { GenOptions } from './grammar-gen.ts';
19+
import { commentSpec, type GenOptions, type GenInput } from './grammar-gen.ts';
2020

2121
// The generation knobs BOTH consumers use, so the gap ledger sees the SAME derived corpus (hence the
2222
// SAME divergence set) as generative.ts's check. `seed` is a no-op (the generator is a pure function
@@ -173,9 +173,59 @@ export function collectViolations(args: {
173173
return out;
174174
}
175175

176+
// ── COMMENT-WITNESS check (the last coverage hole: comment scopes) ───────────────────────────────
177+
// A comment is a `skip:true` token the parser DROPS — it never becomes a CST leaf, so the leaf walk above
178+
// (and `checkedTokens`) can NEVER reach a comment's highlighter scope (0% coverage). The generator closes
179+
// that by INJECTING a comment at a safe position and recording its span as a WITNESS in `GenInput.tokens`
180+
// (grammar-gen.ts §8). THIS is the consumer: for each recorded comment witness, the flat highlighter must
181+
// paint that span with the COMMENT bucket — graded with the SAME `scopeBucket` partition the rest of the
182+
// check uses. The judge compares against the GENERATOR'S witness, not a parser leaf (there is none) — the
183+
// crux the prompt names. Leniency mirrors `collectViolations`: a comment is CONSISTENT if the highlighter
184+
// paints ANY part of its span `comment` (so the `<!--`/`-->` punctuation sub-scope is fine, only the body
185+
// needs `comment`); a span with NO `comment` anywhere (painted entirely string / text / etc.) is the gap.
186+
187+
// The comment-token NAMES of a grammar (so a witness in `tokens` is identifiable as a comment) — the SAME
188+
// generic discovery the generator uses, memoised per grammar object so repeated calls are cheap.
189+
const commentNamesCache = new WeakMap<object, Set<string>>();
190+
export function commentTokenNames(grammar: CstGrammar): Set<string> {
191+
let s = commentNamesCache.get(grammar);
192+
if (!s) { s = commentSpec(grammar)?.names ?? new Set<string>(); commentNamesCache.set(grammar, s); }
193+
return s;
194+
}
195+
196+
// The comment WITNESSES of one generated input: the `tokens` entries whose name is a comment token. ONLY
197+
// a comment-INJECTED input (its strategy carries the `comment:` marker) has authoritative, span-verified
198+
// witnesses — there `tokens` is exactly the comment(s) the generator spliced at a known offset. A BASE
199+
// input may ALSO carry `materialize`-recorded comment tokens (the grammar can emit a native `<!-- -->`),
200+
// but those spans are unreliable for markup fragments (a degenerate `> <!--x-->` mis-spans, an empty
201+
// `<!---->` is all-punctuation) and were never meant as a ground-truth witness — so they are NOT graded.
202+
export function commentWitnesses(grammar: CstGrammar, input: GenInput): GenInput['tokens'] {
203+
if (!input.strategy.includes('comment:')) return [];
204+
const names = commentTokenNames(grammar);
205+
return names.size ? input.tokens.filter((t) => names.has(t.name) && t.end > t.start) : [];
206+
}
207+
208+
// Grade each comment witness: a divergence iff the highlighter painted NO part of the witness span with the
209+
// `comment` bucket. Returns the divergences as `Violation`s (kind `#comment uncolored`), filed like the
210+
// others so they flow into the same report / gate / gap-ledger plumbing.
211+
export function collectCommentViolations(args: { grammar: CstGrammar; input: string; strategy: string; witnesses: GenInput['tokens']; toks: TmTok[] }): Violation[] {
212+
const out: Violation[] = [];
213+
for (const w of args.witnesses) {
214+
const got = spanBuckets(args.toks, args.input, w.start, w.end);
215+
if (got.has('comment')) continue; // painted as a comment somewhere → consistent
216+
const gotBucket = [...got][0] ?? 'none';
217+
out.push({ input: args.input, strategy: args.strategy, pos: w.start, text: w.text, tokenType: w.name, expected: 'comment', got: gotBucket, gotScope: innerOf(scopeAt(args.toks, w.start)), kind: '#comment uncolored' });
218+
}
219+
return out;
220+
}
221+
176222
// What GATES vs what is a report-only DISCOVERY (generative.ts's exact predicate):
177223
// • an ANCHORED-MARKER misfire (#23) ALWAYS gates.
178224
// • a STRUCTURAL-LITERAL→content divergence (#24) gates on the STRUCTURED strategies, but is
179225
// report-only on FUZZ inputs (a standing flat-TM frontier limit). The gap ledger lists the
180226
// DISCOVERED ones (the !isGated set) — the floor-blind divergences a corpus metric is blind to.
181-
export const isGated = (v: { kind: string; strategy: string }): boolean => v.kind.startsWith('#23') || !v.strategy.startsWith('fuzz');
227+
// • a COMMENT-uncolored divergence ALWAYS gates: a comment (parser-dropped, highlighter-scoped
228+
// `comment.*` by construction) painted as a NON-comment is unambiguous — there is no legitimate
229+
// "frontier limit" where an injected comment is not a comment (its position is chosen safe + it
230+
// round-trips). On today's correct grammars this finds 0; it CATCHES a future scope regression.
231+
export const isGated = (v: { kind: string; strategy: string }): boolean => v.kind.startsWith('#23') || v.kind.startsWith('#comment') || !v.strategy.startsWith('fuzz');

test/generative.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { generateInputs, type GenInput } from './grammar-gen.ts';
3838
import {
3939
type TmTok, type Violation,
4040
buildRoleMap, leafRoles, anchoredScopes, collectViolations, isGated, GEN_OPTS,
41+
commentWitnesses, collectCommentViolations,
4142
} from './generative-detect.ts';
4243

4344
// ── language registry: every per-language fact (grammar module, scope, flat grammar file,
@@ -138,6 +139,7 @@ async function runLang(cfg: LangCfg): Promise<{ name: string; ok: boolean; viola
138139
// and a context fold (a number folded into a multi-line string) are NOT false-positives.
139140
const violations: Violation[] = [];
140141
let checkedTokens = 0;
142+
let checkedComments = 0; // comment WITNESSES graded — was structurally 0 (comments are skip→no CST leaf)
141143
for (const inp of entryLegal) {
142144
let cst: CstNode, toks: TmTok[];
143145
try { cst = parse(inp.text); toks = tmTokenize(tm, inp.text); } catch { continue; }
@@ -147,6 +149,13 @@ async function runLang(cfg: LangCfg): Promise<{ name: string; ok: boolean; viola
147149
// detector — identical logic to before, now reused by the gap ledger. The 200-cap is the running
148150
// total across all inputs (startCount), as the inline version was.
149151
violations.push(...collectViolations({ input: inp.text, strategy: inp.strategy, cst, toks, leaves, anchored, cap: 200, startCount: violations.length }));
152+
// COMMENT-WITNESS arm — a comment is a skip token (no CST leaf), so it is NEVER in `leaves`/`checkedTokens`
153+
// and the highlighter's comment scope was previously UNCHECKED (0% coverage). Grade the spans the
154+
// generator recorded as witnesses (grammar-gen §8): the highlighter must paint each `comment`. This is
155+
// the first consumer of `GenInput.tokens`. ALWAYS-gating (see isGated) — but ~0 on the correct grammars.
156+
const witnesses = commentWitnesses(grammar, inp);
157+
checkedComments += witnesses.length;
158+
violations.push(...collectCommentViolations({ grammar, input: inp.text, strategy: inp.strategy, witnesses, toks }));
150159
}
151160

152161
// ── report ──
@@ -174,6 +183,9 @@ async function runLang(cfg: LangCfg): Promise<{ name: string; ok: boolean; viola
174183
const gated = violations.filter(isGated);
175184
const discovered = violations.filter((v) => !isGated(v));
176185
console.log(` scope≡role: ${checkedTokens} declared-scope tokens checked · ${gated.length} gated inconsistenc${gated.length === 1 ? 'y' : 'ies'} · ${discovered.length} discovered (fuzz frontier-limit, report-only)`);
186+
// comment-witness coverage: how many injected comment spans were graded (was structurally 0 — a comment
187+
// is a skip token, dropped by the parser, so it never reached the leaf-walking scope≡role check).
188+
console.log(` comment witnesses: ${checkedComments} comment span${checkedComments === 1 ? '' : 's'} graded (highlighter must paint COMMENT) · ${violations.filter((v) => v.kind.startsWith('#comment')).length} uncolored`);
177189
const show = (vs: Violation[], tag: string) => {
178190
const grouped = new Map<string, { v: Violation; n: number }>();
179191
for (const v of vs) { const key = `${v.kind} ${v.tokenType}`; const e = grouped.get(key); if (e) e.n++; else grouped.set(key, { v, n: 1 }); }

0 commit comments

Comments
 (0)