Skip to content

Commit 4e5e656

Browse files
Rebuild the YAML scope-gap metric on an independent CST oracle (#18)
RedCMD's monogram#12 comment (issuecomment-4640869820) listed ten YAML highlighting bugs. The scope-gap metric reported zero of them and claimed 100% / 0 Monogram-wrong: it was measuring agreement with a co-biased oracle, not correctness. Five blind spots let the bugs through: A. the oracle hand-rolled comment/directive/flow/tag with regexes that reproduced the same mistakes, so it graded the buggy scope as correct; B. isGradable skipped every invalid input (much of the report); C. the corpus was the yaml-test-suite only (the repros weren't in it); D. each oracle token was graded only at its START char, so a bug mid-span (a %YAML folded into a plain scalar, a # line in a |5 body) was invisible; E. the oracle was silent on whole constructs, so a metric that only grades where its oracle speaks could never see those bugs. This reworks the metric only; the grammars are unchanged and the other six languages are byte-identical. - yaml-oracle.ts is rebuilt on the yaml package's low-level CST (new Parser().parse()) for presentation tokens (comment, directive, anchor, alias, tag, flow punctuation, block-scalar, doc markers) plus the AST for scalar value-typing, key detection and escapes. The CST is a different author than Monogram's grammar and is error-tolerant, so it can't share the blind spots and it grades malformed input too. (A,B,E) - scope-gap.ts grades the FULL span of each oracle token, not just its start (opt-in per adapter; on for YAML, off elsewhere so the fine-grained TS/HTML oracles are unaffected). (D) - scope-gap.ts adds a differential pass: every position where Monogram and the official grammar paint different visual classes AND the oracle is silent is flagged, separating genuine class-confusion from the error-overlay design difference. This oracle-independent net keeps an unmodeled construct from becoming a silent blind spot. (E) - yaml-issue12-regressions.ts pins all ten repros as asserted should-be scopes (xfail for the ones still open) and runs in CI. (C) The metric now exposes seven real Monogram YAML bugs (previously zero): items #6, #7, #8, #9, #10 from the report, plus two it did not list (an anchor mis-detected inside a plain scalar, and a multi-line key typed as a constant). The grammar fixes for those are a follow-up; this is only the measurement. Refs #12
1 parent 02da0ab commit 4e5e656

7 files changed

Lines changed: 357 additions & 114 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ jobs:
5555
node test/vue-directives.ts
5656
node test/vue-embed-boundary.ts
5757
node test/vue-interp-expr.ts
58+
node test/yaml-issue12-regressions.ts
5859
5960
# The derived tree-sitter highlighter is the strongest thesis proof (a real GLR
6061
# parser from the same grammar, beating the official hand-written one). Build its

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"conformance:html": "node test/html-conformance.ts",
1313
"test:tm-diagnostics": "node test/redcmd-tm-diagnostics.ts",
1414
"test:tm-guards": "node test/tm-highlight-guards.ts",
15+
"test:yaml-issues": "node test/yaml-issue12-regressions.ts",
1516
"spike:html-lexer": "node test/html-lexer-spike.ts",
1617
"bench:html-official": "node test/html-bench.ts",
1718
"bench:html-embed": "node test/html-embed-js.ts",

test/scope-gap-yaml.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { dirname, join } from 'node:path';
1212
import { parse as yamlParse, parseAllDocuments } from 'yaml';
1313
import { run } from './scope-gap.ts';
1414
import { yamlOracle } from './yaml-oracle.ts';
15+
import { cases as issue12 } from './yaml-issue12-regressions.ts';
1516

1617
const OFFICIAL = process.env.MONOGRAM_OFFICIAL_YAML ?? '/tmp/redcmd-yaml/syntaxes/yaml.tmLanguage.json';
1718
// The RedCMD/VS Code YAML grammar is a dispatcher stub that include()s version-specific
@@ -37,6 +38,10 @@ for (const f of readdirSync(SUITE).filter((n) => n.endsWith('.yaml'))) {
3738
}
3839
} catch { /* skip */ }
3940
}
41+
// Plus the RedCMD monogram#12 repros (many are tiny edge/error inputs absent from the suite) so the
42+
// metric actually SEES the constructs the comment flagged. Asserted should-be scopes live in their
43+
// own gate (yaml-issue12-regressions.ts); here they just widen what the gap/differential pass covers.
44+
for (const c of issue12) corpus.push({ name: `monogram#12 ${c.id}`, text: c.src });
4045

4146
await run({
4247
name: 'YAML',
@@ -46,6 +51,18 @@ await run({
4651
monogramPath: 'yaml.tmLanguage.json',
4752
loadCorpus: () => corpus,
4853
roleOracle: yamlOracle,
49-
// grade only inputs the parser fully accepts (the oracle is meaningful there).
54+
// The GRADED headline stays valid-only: on malformed YAML the AST's key/value resolution is itself
55+
// unreliable, so grading it would inject false "Monogram-wrong" tokens and poison the very signal
56+
// we're making trustworthy. The invalid-input blind spot is instead closed by TWO mechanisms that
57+
// stay honest there: (1) the asserted regression gate (yaml-issue12-regressions.ts) pins the
58+
// should-be scope of the specific malformed repros (#4/#5/#8); (2) the differential pass below runs
59+
// on ALL inputs and FLAGS invalid-input divergences for human review without auto-judging them.
5060
isGradable: (text) => { try { return parseAllDocuments(text).every((d: any) => d.errors.length === 0); } catch { return false; } },
61+
// YAML's oracle emits COARSE, role-homogeneous spans (a whole plain scalar, a block-scalar body, a
62+
// directive line); grade every char so a bug mid-span (a `%YAML` folded into a scalar, a block line
63+
// bailing to a comment) is caught instead of hidden behind a correct start. See scope-gap.ts.
64+
fullSpan: true,
65+
// Also report oracle-INDEPENDENT divergences (Monogram vs official, where the oracle is silent) so a
66+
// construct the CST oracle doesn't model can't become a silent blind spot. See scope-gap.ts.
67+
differential: true,
5168
});

test/scope-gap.ts

Lines changed: 116 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ import { readFileSync, existsSync } from 'node:fs';
1616
import { createRequire } from 'node:module';
1717
import vsctm from 'vscode-textmate';
1818
import onig from 'vscode-oniguruma';
19-
import { gradeScopeStack, isCorrect, ROLE_SPEC } from './scope-roles.ts';
20-
import type { RoleName } from './scope-roles.ts';
19+
import { gradeScopeStack, isCorrect, ROLE_SPEC, normScope } from './scope-roles.ts';
20+
import type { RoleName, Verdict } from './scope-roles.ts';
2121

2222
// A parser-assigned token role over a span. The per-language oracle returns these (e.g.
2323
// oracle.ts for TS/JS). Only start/end/text/role are required by the harness.
@@ -35,6 +35,21 @@ export interface ScopeGapAdapter {
3535
loadCorpus: () => { name: string; text: string }[];
3636
roleOracle: (text: string) => GoldToken[]; // parser → per-token role (the neutral oracle)
3737
isGradable?: (text: string) => boolean; // skip inputs the oracle can't judge (default: all)
38+
// Grade every non-whitespace codepoint of an oracle token, not just its start offset. Needed when
39+
// the oracle emits COARSE spans (a multi-line YAML plain scalar, a block-scalar body) whose role
40+
// must hold across the whole span: a token correct at its start but wrong mid-span (a `%YAML`
41+
// folded into a plain scalar, a block-scalar line bailing out to a comment) is otherwise invisible.
42+
// Default false (start-only) — opt in only where the oracle's spans are role-HOMOGENEOUS, i.e. the
43+
// role applies to every char (NOT e.g. a TS template literal whose `${…}` holes are expressions).
44+
fullSpan?: boolean;
45+
// Run the DIFFERENTIAL pass: report every position where Monogram and the official grammar paint
46+
// DIFFERENT visual token classes (comment/string/number/keyword/name) AND the oracle has NO opinion
47+
// there (no non-lexical gold token covers it). These are UNADJUDICATED divergences — the metric
48+
// cannot say who is right, so they are flagged for human review. This is the structural fix for the
49+
// "oracle is silent → blind spot" failure mode: a metric that only grades where its oracle speaks
50+
// can never see a bug in a construct the oracle does not model; the differential pass catches the
51+
// disagreement regardless, so a clean run means "no Monogram-wrong AND no unexamined divergence".
52+
differential?: boolean;
3853
}
3954

4055
const { INITIAL, Registry, parseRawGrammar } = vsctm;
@@ -89,6 +104,70 @@ function scopeAt(toks: TmToken[], pos: number): string[] {
89104
while (lo <= hi) { const mid = (lo + hi) >> 1; if (toks[mid].start <= pos) { ans = mid; lo = mid + 1; } else hi = mid - 1; }
90105
return ans >= 0 && toks[ans].end > pos ? toks[ans].scopes : [];
91106
}
107+
const innerOf = (s: string[]): string => (s.length ? s[s.length - 1] : '(none)');
108+
const isWs = (c: number): boolean => c === 32 || c === 9 || c === 10 || c === 13;
109+
// Grade an oracle token. start-only (default) reproduces the historical single-point sampling; full
110+
// grades every non-whitespace codepoint in [start,end) and returns the WORST verdict (first WRONG
111+
// position wins, with its scope for reporting) — so a coarse span that is right at its start but
112+
// wrong mid-span reads WRONG. Whitespace is skipped (continuation indent is colourless either way).
113+
interface SpanGrade { v: Verdict; scope: string; wrongAt?: number }
114+
function gradeSpan(role: RoleName, toks: TmToken[], start: number, end: number, text: string, full: boolean): SpanGrade {
115+
if (!full) { const s = scopeAt(toks, start); return { v: gradeScopeStack(role, s), scope: innerOf(s) }; }
116+
let worst: Verdict = 'exact';
117+
for (let p = start; p < end; p++) {
118+
if (isWs(text.charCodeAt(p))) continue;
119+
const s = scopeAt(toks, p);
120+
const v = gradeScopeStack(role, s);
121+
if (v === 'wrong') return { v: 'wrong', scope: innerOf(s), wrongAt: p };
122+
if (v === 'family' && worst === 'exact') worst = 'family';
123+
}
124+
return { v: worst, scope: innerOf(scopeAt(toks, start)) };
125+
}
126+
127+
// ─── differential pass: oracle-INDEPENDENT divergence detector ───────────────────────────────────
128+
// Map a TM scope chain to a coarse VISUAL bucket — the level at which a highlight difference is
129+
// actually visible (a comment vs a string vs a number vs a keyword). `name` (identifiers/entities) and
130+
// `punct`/`none` are convention noise we don't flag on their own; a divergence is only interesting if
131+
// at least one side is a visually-distinct class. Innermost wins; scan inner→outer for the first hit.
132+
type Bucket = 'invalid' | 'comment' | 'string' | 'number' | 'keyword' | 'name' | 'punct' | 'none';
133+
function scopeBucket(chain: string[]): Bucket {
134+
for (let i = chain.length - 1; i >= 0; i--) {
135+
const s = normScope(chain[i]);
136+
if (/^invalid/.test(s)) return 'invalid'; // an error overlay (official marks errors; a highlighter may legitimately highlight-normally instead)
137+
if (/^comment/.test(s)) return 'comment';
138+
if (/^constant\.numeric/.test(s)) return 'number';
139+
if (/^(string|constant\.character|constant\.other\.symbol)/.test(s)) return 'string';
140+
if (/^(keyword|storage|constant\.language|support\.constant|variable\.language)/.test(s)) return 'keyword';
141+
if (/^(entity|variable|support|constant)/.test(s)) return 'name';
142+
if (/^punctuation/.test(s)) return 'punct';
143+
}
144+
return 'none';
145+
}
146+
// Visually-distinct classes whose confusion is a real (not convention-noise) difference. `invalid`
147+
// is reported but flagged separately below — official-marks-error vs Monogram-highlights-normally is a
148+
// design stance (cf. monogram#12 #3 "should still be highlighted normally"), not necessarily a bug.
149+
const DISTINCT = new Set<Bucket>(['invalid', 'comment', 'string', 'number', 'keyword']);
150+
const involvesInvalid = (a: Bucket, b: Bucket): boolean => a === 'invalid' || b === 'invalid';
151+
// a divergence matters when the two buckets differ AND at least one is a visually-distinct class
152+
const interestingDivergence = (a: Bucket, b: Bucket): boolean => a !== b && (DISTINCT.has(a) || DISTINCT.has(b));
153+
154+
export interface Divergence { pos: number; text: string; mono: string; off: string; bM: Bucket; bO: Bucket }
155+
// Positions where the two grammars disagree visually AND no non-lexical oracle token adjudicates.
156+
function divergences(off: TmToken[], mono: TmToken[], gold: GoldToken[], text: string): Divergence[] {
157+
const cov = gold.filter((g) => { const t = ROLE_SPEC[g.role]?.tier; return t && t !== 'lexical'; }).map((g) => [g.start, g.end] as const);
158+
const isCovered = (p: number) => cov.some(([a, b]) => p >= a && p < b);
159+
const positions = [...new Set([...mono.map((t) => t.start), ...off.map((t) => t.start)])].sort((a, b) => a - b);
160+
const out: Divergence[] = [];
161+
for (const pos of positions) {
162+
if (pos >= text.length || isWs(text.charCodeAt(pos))) continue;
163+
if (isCovered(pos)) continue; // oracle already has an opinion here → graded above
164+
const cm = scopeAt(mono, pos), co = scopeAt(off, pos);
165+
const bM = scopeBucket(cm), bO = scopeBucket(co);
166+
if (!interestingDivergence(bM, bO)) continue;
167+
out.push({ pos, text: text.slice(pos, pos + 12), mono: innerOf(cm), off: innerOf(co), bM, bO });
168+
}
169+
return out;
170+
}
92171

93172
export async function run(adapter: ScopeGapAdapter): Promise<void> {
94173
if (!existsSync(adapter.officialPath)) { console.error(`Official grammar not found:\n ${adapter.officialPath}`); process.exit(1); }
@@ -100,6 +179,7 @@ export async function run(adapter: ScopeGapAdapter): Promise<void> {
100179

101180
const corpus = adapter.loadCorpus();
102181
const gradable = adapter.isGradable ?? (() => true);
182+
const fullSpan = adapter.fullSpan ?? false;
103183

104184
let nFiles = 0;
105185
const tally = { oCorrect: 0, oExact: 0, mCorrect: 0, mExact: 0, total: 0 };
@@ -118,17 +198,17 @@ export async function run(adapter: ScopeGapAdapter): Promise<void> {
118198
for (const t of gold) {
119199
const tier = ROLE_SPEC[t.role]?.tier;
120200
if (!tier || tier === 'lexical') continue; // lexical floor: excluded from the headline
121-
const so = scopeAt(tmO, t.start), sm = scopeAt(tmM, t.start); // full scope CHAINS
122-
const vo = gradeScopeStack(t.role, so), vm = gradeScopeStack(t.role, sm);
201+
const go = gradeSpan(t.role, tmO, t.start, t.end, text, fullSpan); // full scope CHAINS
202+
const gm = gradeSpan(t.role, tmM, t.start, t.end, text, fullSpan);
203+
const vo = go.v, vm = gm.v;
123204
const oc = isCorrect(vo), mc = isCorrect(vm);
124205
tally.total++; gradedHere++;
125206
if (oc) tally.oCorrect++; if (vo === 'exact') tally.oExact++;
126207
if (mc) tally.mCorrect++; if (vm === 'exact') tally.mExact++;
127208
const pr = perRole.get(t.role) ?? { n: 0, oC: 0, mC: 0 }; pr.n++; if (oc) pr.oC++; if (mc) pr.mC++; perRole.set(t.role, pr);
128209
if (!oc) okO = false; if (!mc) okM = false;
129-
const inner = (s: string[]) => s.length ? s[s.length - 1] : '(none)';
130-
if (mc && !oc && onlyMono.length < 40) onlyMono.push({ text: t.text, role: t.role, o: inner(so), m: inner(sm) });
131-
if (oc && !mc && onlyOff.length < 40) onlyOff.push({ text: t.text, role: t.role, o: inner(so), m: inner(sm) });
210+
if (mc && !oc && onlyMono.length < 40) onlyMono.push({ text: t.text, role: t.role, o: go.scope, m: gm.scope });
211+
if (oc && !mc && onlyOff.length < 40) onlyOff.push({ text: t.text, role: t.role, o: go.scope, m: gm.scope });
132212
}
133213
if (gradedHere) { snip.n++; if (okO) snip.o++; if (okM) snip.m++; }
134214
}
@@ -158,11 +238,40 @@ export async function run(adapter: ScopeGapAdapter): Promise<void> {
158238
console.log(`\n only-official-correct tokens (Monogram wrong) — ${onlyOff.length} shown:`);
159239
for (const x of onlyOff.slice(0, 12)) console.log(` «${x.text.slice(0, 18)}» ${x.role}: official «${x.o}» → monogram «${x.m}»`);
160240
}
241+
242+
// ── DIFFERENTIAL pass: oracle-INDEPENDENT divergences (the blind-spot net) ───────────────────────
243+
let divTotal = 0;
244+
if (adapter.differential) {
245+
const all: Divergence[] = [];
246+
let divFiles = 0;
247+
for (const { text } of corpus) { // ALL inputs, incl. ones the oracle/grader skip
248+
let gold: GoldToken[], tmO: TmToken[], tmM: TmToken[];
249+
try { gold = adapter.roleOracle(text); tmO = tmTokenize(official, text); tmM = tmTokenize(monogram, text); } catch { continue; }
250+
const ds = divergences(tmO, tmM, gold, text);
251+
if (ds.length) divFiles++;
252+
all.push(...ds);
253+
}
254+
divTotal = all.length;
255+
const genuine = all.filter((d) => !involvesInvalid(d.bM, d.bO)); // real class confusion (not error-overlay)
256+
const overlay = divTotal - genuine.length; // official-marks-error vs Monogram-normal
257+
const byPair = new Map<string, { n: number; sample: Divergence }>();
258+
for (const d of all) { const k = `${d.bM}${d.bO}`; const e = byPair.get(k); if (e) e.n++; else byPair.set(k, { n: 1, sample: d }); }
259+
console.log(`\n ── DIFFERENTIAL (oracle-independent) — UNADJUDICATED divergences over ${divFiles} files ──`);
260+
console.log(` positions where Monogram and official paint different VISUAL classes and the oracle is`);
261+
console.log(` silent → the metric cannot adjudicate; each is a candidate bug for human review.`);
262+
console.log(` ${genuine.length} genuine class-confusion + ${overlay} error-overlay (official invalid.illegal vs Monogram highlight-normally)`);
263+
for (const [k, e] of [...byPair.entries()].sort((a, b) => b[1].n - a[1].n).slice(0, 12)) {
264+
const s = e.sample;
265+
console.log(` ${k.padEnd(18)} ×${String(e.n).padStart(4)} e.g. «${s.text.replace(/\n/g, '\\n')}» mono«${s.mono}» off«${s.off}»`);
266+
}
267+
}
268+
161269
// Machine-readable summary for the README coverage-table generator (test/coverage-table.ts).
162270
console.log('##SCOPEGAP## ' + JSON.stringify({
163271
name: adapter.name, official: adapter.officialPath.replace(/^.*\//, ''), tokens: tally.total,
164272
officialPct: tally.total ? (100 * tally.oCorrect) / tally.total : null,
165273
monogramPct: tally.total ? (100 * tally.mCorrect) / tally.total : null,
274+
monogramWrong: onlyOff.length, unadjudicated: divTotal,
166275
}));
167276
console.log('\nDone.');
168277
}

test/scope-roles.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,19 @@ export const ROLE_SPEC: Record<RoleName, RoleSpec> = {
270270
// (e.g. a coarse generic `punctuation` for flow, the split punctuation.definition.tag for a tag).
271271
[R.blockIndicator]: { tier: 'strict', desc: 'YAML block-scalar indicator (|/>, chomping, indent)', exact: ['keyword.control.flow.block-scalar'], family: ['keyword', 'storage.modifier', 'constant.numeric', 'punctuation.definition'] },
272272
[R.tagType]: { tier: 'strict', desc: 'YAML node tag (!!str / !foo / !<verbatim>)', exact: ['storage.type.tag'], family: ['storage.type', 'storage', 'entity.name.type', 'support.type', 'keyword.other', 'punctuation.definition.tag'] },
273-
[R.directive]: { tier: 'strict', desc: 'YAML directive name (%YAML / %TAG / %…)', exact: ['keyword.other.directive'], family: ['keyword.other', 'keyword', 'punctuation.definition.directive'] },
273+
// A directive is graded over its WHOLE line (full-span): the name is keyword.other.directive, but
274+
// the interior legitimately carries finer scopes a maintained grammar splits out — the version
275+
// (constant.numeric), the tag handle (punctuation.definition.tag), the tag prefix (support.type),
276+
// directive parameters (string.unquoted.directive-*). family accepts all of those so a correct
277+
// fine-grained directive is not penalised, while STILL rejecting the two readings that are the
278+
// monogram#12 bugs: a `#…` mis-read as a comment (#8) and a `,` mis-read as a flow separator (#1)
279+
// — neither `comment` nor `punctuation.separator` is in the family.
280+
// NB family carries `string.unquoted` (a maintained grammar scopes a directive's name/params as
281+
// string.unquoted.directive-name / -parameter): the comparative bench grades by visual CLASS, and a
282+
// string-coloured param is a defensible rendering, so it must not read WRONG. The PRECISE should-be
283+
// (a param belongs to the directive, not a stray plain scalar — monogram#12 #4) is pinned by the
284+
// strict regression gate yaml-issue12-regressions.ts, not relaxed here.
285+
[R.directive]: { tier: 'strict', desc: 'YAML directive line (%YAML / %TAG / %…) — name + handle/prefix/version', exact: ['keyword.other.directive'], family: ['keyword', 'punctuation.definition', 'support', 'storage', 'constant.numeric', 'string.unquoted', 'entity.name', 'variable.other'] },
274286
[R.flowPunct]: { tier: 'strict', desc: 'YAML flow punctuation ([ ] { } ,)', exact: ['punctuation.definition', 'punctuation.separator'], family: ['punctuation'] },
275287

276288
// ── lexical floor: reported, excluded from the headline ───────────────────────
@@ -461,6 +473,12 @@ if (import.meta.url === `file://${process.argv[1]}`) {
461473
[R.tagType, 'storage.type.tag.yaml', 'exact'],
462474
[R.tagType, 'punctuation.definition.tag.begin.yaml', 'family'],
463475
[R.directive, 'keyword.other.directive.yaml', 'exact'],
476+
// directive interior: the version/handle/prefix sub-scopes a maintained grammar splits out are
477+
// FAMILY-correct; the two monogram#12 mis-readings (comment / flow-separator) stay WRONG.
478+
[R.directive, 'constant.numeric.yaml-version.yaml', 'family'],
479+
[R.directive, 'punctuation.definition.tag.begin.yaml', 'family'],
480+
[R.directive, 'comment.line.number-sign.yaml', 'wrong'],
481+
[R.directive, 'punctuation.separator.sequence.yaml', 'wrong'],
464482
[R.flowPunct, 'punctuation.definition.sequence.begin.yaml', 'exact'],
465483
[R.flowPunct, 'punctuation.yaml', 'family'],
466484
];

0 commit comments

Comments
 (0)