Skip to content

Commit f6f9033

Browse files
committed
Add scope-coverage: measure drop-in-replacement gaps vs official
highlight-bench grades CORRECTNESS (token family vs a neutral tsc oracle); it can't see what's missing to actually REPLACE the official grammar. test/scope-coverage.ts measures that — drop-in COMPATIBILITY against the official scope vocabulary — in four quantified, repeatable views: 1. VOCABULARY — 49 official scopes Monogram never emits: 27 JSDoc-body + 22 regex internals (the two missing sub-grammars). 2. FIDELITY — per token, scope path vs official (suffix-normalised): 51% exact, 39% family-only (right family, coarser/different path → theme may recolor), 0% missing, 10% divergent. The 51% is the real drop-in number behind the 87.6% family-correctness headline. 3. SUB-GRAMMAR density — JSDoc body: official 6 scopes, Monogram 0 (flat token); regex internals: official 5, Monogram 3. 4. DIALECT — Monogram has no JSX/TSX productions (the official ships a separate TypeScriptReact grammar). Run via `npm run coverage` (needs MONOGRAM_OFFICIAL_TM). Documented in the README Tests section. This is the gap inventory to drive the replacement work.
1 parent 84b3f02 commit f6f9033

3 files changed

Lines changed: 187 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ node test/conformance-matrix.ts # THE parser metric: bidirectional vs tsc — 1
187187
node test/highlight-bench.ts # absolute correctness, both grammars vs a neutral tsc oracle
188188
node test/highlight-bench.ts --corpus adversarial # documented bug ledger only (no /tmp/ts-repo needed)
189189
node test/highlight-bench.ts --write-readme # regenerate the head-to-head block above
190+
node test/scope-coverage.ts # drop-in COMPATIBILITY gaps vs official (scope fidelity, missing regex/JSDoc sub-grammars, TSX) — what correctness doesn't measure
190191
```
191192

192193
> `test/run-conformance.ts` reports a *raw accept-rate* (94.2% over all 3776 files, multi-file cases included) — an acceptance-only sanity check, not the bidirectional proof. `conformance-matrix.ts` is the number this README quotes.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"conformance:js": "node test/js-conformance.ts",
1010
"bench": "node test/highlight-bench.ts",
1111
"bench:js": "node test/js-highlight-bench.ts",
12-
"bench:readme": "node test/highlight-bench.ts --corpus adversarial --write-readme"
12+
"bench:readme": "node test/highlight-bench.ts --corpus adversarial --write-readme",
13+
"coverage": "node test/scope-coverage.ts"
1314
},
1415
"devDependencies": {
1516
"@types/node": "^25.9.1",

test/scope-coverage.ts

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// ─────────────────────────────────────────────────────────────────────────────
2+
// scope-coverage.ts — what Monogram is MISSING to be a drop-in REPLACEMENT for the
3+
// official grammar. This is NOT correctness (highlight-bench.ts already grades that
4+
// against a neutral tsc oracle); it is drop-in COMPATIBILITY against the official
5+
// scope vocabulary — the coverage + fidelity that "more correct on the bug ledger"
6+
// does not capture. Three views, each a quantified, repeatable gap:
7+
//
8+
// 1. VOCABULARY — scopes the official grammar emits that Monogram NEVER does,
9+
// grouped by category (the missing sub-grammars: regex
10+
// internals, JSDoc body, …).
11+
// 2. FIDELITY — per meaningful token (oracle positions) on a corpus:
12+
// exact / family-only / missing / divergent vs official.
13+
// `missing` = we emit no scope where official colors (a pure
14+
// coverage gap); `family-only` = right family, different scope
15+
// (a theme MAY still recolor); `divergent` includes our
16+
// deliberate bug-fixes, so it is not all deficiency.
17+
// 3. SUB-GRAMMAR — inside a regex / JSDoc comment / tagged template, how many
18+
// distinct scopes each grammar emits (the 1-vs-N internal gap).
19+
//
20+
// Run: MONOGRAM_OFFICIAL_TM=/path/to/TypeScript.tmLanguage.json node test/scope-coverage.ts
21+
// ─────────────────────────────────────────────────────────────────────────────
22+
import vsctm from 'vscode-textmate';
23+
import onig from 'vscode-oniguruma';
24+
import { readFileSync, existsSync } from 'node:fs';
25+
import { createRequire } from 'node:module';
26+
import { oracle } from './oracle.ts';
27+
import { scopeFamily } from './highlight-engines.ts';
28+
import { ROLE_SPEC, roleFamily, normScope } from './scope-roles.ts';
29+
30+
const { INITIAL, Registry, parseRawGrammar } = vsctm;
31+
const require = createRequire(import.meta.url);
32+
const wasmBin = readFileSync(require.resolve('vscode-oniguruma/release/onig.wasm'));
33+
await onig.loadWASM(wasmBin.buffer.slice(wasmBin.byteOffset, wasmBin.byteOffset + wasmBin.byteLength));
34+
35+
const MONO_PATH = 'examples/typescript.tmLanguage.json';
36+
const OFFICIAL_PATH = process.env.MONOGRAM_OFFICIAL_TM
37+
?? '/Applications/Visual Studio Code.app/Contents/Resources/app/extensions/typescript-basics/syntaxes/TypeScript.tmLanguage.json';
38+
if (!existsSync(OFFICIAL_PATH)) {
39+
console.error(`Official grammar not found. Set MONOGRAM_OFFICIAL_TM=/path/to/TypeScript.tmLanguage.json`);
40+
process.exit(1);
41+
}
42+
43+
function load(scopeName: string, path: string): Promise<vsctm.IGrammar | null> {
44+
const content = readFileSync(path, 'utf-8');
45+
return new Registry({
46+
onigLib: Promise.resolve({
47+
createOnigScanner: (p: string[]) => new onig.OnigScanner(p),
48+
createOnigString: (s: string) => new onig.OnigString(s),
49+
}),
50+
loadGrammar: async (sn: string) => (sn === scopeName ? parseRawGrammar(content, 'g.json') : null),
51+
}).loadGrammar(scopeName);
52+
}
53+
const mono = (await load('source.typescript', MONO_PATH))!;
54+
const official = (await load('source.ts', OFFICIAL_PATH))!;
55+
const ROOT_MONO = 'source.typescript', ROOT_OFF = 'source.ts';
56+
57+
interface Tok { start: number; end: number; scope: string }
58+
function tokenize(g: vsctm.IGrammar, text: string): Tok[] {
59+
const out: Tok[] = [];
60+
let rs = INITIAL, off = 0;
61+
for (const line of text.split('\n')) {
62+
const r = g.tokenizeLine(line, rs);
63+
for (const t of r.tokens) out.push({ start: off + t.startIndex, end: off + t.endIndex, scope: t.scopes[t.scopes.length - 1] });
64+
rs = r.ruleStack; off += line.length + 1;
65+
}
66+
return out;
67+
}
68+
const scopeAt = (toks: Tok[], pos: number): string => {
69+
for (const t of toks) if (t.start <= pos && pos < t.end) return t.scope;
70+
return '';
71+
};
72+
73+
// ── 1. VOCABULARY — official scopes Monogram never emits ──
74+
function vocab(g: any): Set<string> {
75+
const s = new Set<string>();
76+
const walk = (o: any): void => {
77+
if (!o || typeof o !== 'object') return;
78+
if (typeof o.name === 'string') o.name.split(/\s+/).forEach((x: string) => s.add(x));
79+
if (typeof o.contentName === 'string') s.add(o.contentName);
80+
for (const k in o) walk(o[k]);
81+
};
82+
walk(JSON.parse(readFileSync(g, 'utf-8')));
83+
return s;
84+
}
85+
function category(scope: string): string {
86+
if (scope.includes('.regexp')) return 'regexp (regex internals)';
87+
if (scope.includes('.jsdoc')) return 'jsdoc (doc-comment body)';
88+
if (/\bjsx\b|\.tsx\b/.test(scope)) return 'jsx/tsx (React dialect)';
89+
if (scope.includes('.template') || scope.includes('embedded')) return 'embedded (template-literal langs)';
90+
const head = scope.split('.')[0];
91+
return `${head.padEnd(11)}(finer ${head})`;
92+
}
93+
const monoVocab = vocab(MONO_PATH), offVocab = vocab(OFFICIAL_PATH);
94+
const officialOnly = [...offVocab].filter((x) => !monoVocab.has(x) && x.includes('.') && !/^source\.|\.tsx?$/.test(x));
95+
const byCat = new Map<string, string[]>();
96+
for (const s of officialOnly) { const c = category(s); (byCat.get(c) ?? byCat.set(c, []).get(c)!).push(s); }
97+
98+
console.log('═══ DROP-IN COMPATIBILITY vs official (coverage + fidelity, NOT correctness) ═══\n');
99+
console.log(`scopeName Monogram=source.typescript official=source.ts ${'(mismatch → not yet a drop-in)'}`);
100+
console.log(`scope vocabulary Monogram=${monoVocab.size} official=${offVocab.size}\n`);
101+
console.log(`── 1. VOCABULARY: ${officialOnly.length} official scopes Monogram never emits (pure coverage gaps) ──`);
102+
for (const [cat, list] of [...byCat.entries()].sort((a, b) => b[1].length - a[1].length)) {
103+
console.log(` ${String(list.length).padStart(3)} ${cat}`);
104+
}
105+
106+
// ── 2. FIDELITY — per oracle-token scope relationship vs official, on a corpus ──
107+
const CORPUS: string[] = [
108+
`import { readFile } from 'fs'; export const x: number = 1;`,
109+
`function greet<T extends string>(name: T, opts?: { loud: boolean }): string { return name; }`,
110+
`class Animal { #legs = 4; static kingdom = 'A'; get legs() { return this.#legs; } move(d: number): void {} }`,
111+
`interface Shape { area(): number; readonly name: string; }`,
112+
`type Result<T> = { ok: true; value: T } | { ok: false; error: Error };`,
113+
`const arr = [1, 2, 3].map((n) => n * 2).filter((n) => n > 2);`,
114+
`const { a, b: renamed, ...rest } = config; const [first, ...more] = list;`,
115+
`const re = /^\\d{3}-(\\w+)$/gi; const ok = re.test(input);`,
116+
`const t = \`Hello \${user.name}, you have \${count} messages\`;`,
117+
`enum Color { Red, Green = 2, Blue } namespace NS { export const v = 1; }`,
118+
`async function load(url: string) { const r = await fetch(url); return r.json() as Promise<Data>; }`,
119+
`/** @param {string} name the user @returns {void} */\nfunction doc(name) {}`,
120+
`@Component({ selector: 'app' }) class C { @Input() value = 0; }`,
121+
`export default function App() { return null; } export * from './mod';`,
122+
];
123+
type Rel = 'exact' | 'family' | 'missing' | 'divergent';
124+
const tally: Record<Rel, number> = { exact: 0, family: 0, missing: 0, divergent: 0 };
125+
const missingEx = new Map<string, { n: number; ex: string }>();
126+
let graded = 0;
127+
for (const text of CORPUS) {
128+
const mt = tokenize(mono, text), ot = tokenize(official, text);
129+
for (const g of oracle(text)) {
130+
if (ROLE_SPEC[g.role].tier === 'lexical' || roleFamily(g.role) === 'punct') continue;
131+
const off = scopeAt(ot, g.start), mn = scopeAt(mt, g.start);
132+
if (!off || off === ROOT_OFF) continue; // official emits nothing → no compat signal
133+
graded++;
134+
// normalise the language suffix (.ts / .typescript / .tsx) so we measure the
135+
// STRUCTURAL scope path, not the systematic source.ts-vs-source.typescript skew.
136+
if (normScope(mn) === normScope(off)) tally.exact++;
137+
else if (!mn || mn === ROOT_MONO) {
138+
tally.missing++;
139+
const k = `${g.role} (official: ${normScope(off)})`;
140+
const e = missingEx.get(k) ?? { n: 0, ex: g.text }; e.n++; missingEx.set(k, e);
141+
} else if (scopeFamily(mn) === scopeFamily(off)) tally.family++;
142+
else tally.divergent++;
143+
}
144+
}
145+
const pct = (n: number) => ((n / graded) * 100).toFixed(1);
146+
console.log(`\n── 2. FIDELITY: ${graded} meaningful tokens (where official emits a scope; scope`);
147+
console.log(` paths compared MODULO the .ts/.typescript language suffix) ──`);
148+
console.log(` exact ${pct(tally.exact)}% same scope PATH → theme colors match (once suffix aligned)`);
149+
console.log(` family-only ${pct(tally.family)}% right family, different/coarser path → theme MAY recolor`);
150+
console.log(` missing ${pct(tally.missing)}% we emit NO scope where official colors → coverage gap`);
151+
console.log(` divergent ${pct(tally.divergent)}% different family (includes our deliberate bug-fixes)`);
152+
if (missingEx.size) {
153+
console.log(` top "missing" (we color nothing where official does):`);
154+
for (const [k, v] of [...missingEx.entries()].sort((a, b) => b[1].n - a[1].n).slice(0, 6))
155+
console.log(` ${String(v.n).padStart(2)}× ${k} e.g. «${v.ex}»`);
156+
}
157+
158+
// ── 3. SUB-GRAMMAR DENSITY — distinct scopes INSIDE the construct, measured over the
159+
// exact char range where the OFFICIAL grammar emits the category's scopes ──
160+
console.log(`\n── 3. SUB-GRAMMAR density (distinct scopes in the region official sub-highlights) ──`);
161+
const probes: { label: string; text: string; cat: RegExp }[] = [
162+
{ label: 'regex internals', text: `const re = /^\\d{3}-(\\w+)$/gi;`, cat: /\.regexp/ },
163+
{ label: 'JSDoc body', text: `/** @param {string} n @returns {void} */\nlet x = 1;`, cat: /\.jsdoc/ },
164+
{ label: 'tagged template (css`…`)', text: 'const s = css`.a { color: red }`;', cat: /\.css|source\.css|meta\.embedded/ },
165+
];
166+
for (const p of probes) {
167+
const ot = tokenize(official, p.text), mt = tokenize(mono, p.text);
168+
const offIn = ot.filter((t) => p.cat.test(t.scope));
169+
if (!offIn.length) { console.log(` ${p.label.padEnd(26)} official emits no such sub-scopes here (skip)`); continue; }
170+
const lo = Math.min(...offIn.map((t) => t.start)), hi = Math.max(...offIn.map((t) => t.end));
171+
const offN = new Set(offIn.map((t) => t.scope)).size;
172+
const monoN = new Set(mt.filter((t) => t.start >= lo && t.start < hi).map((t) => t.scope)).size;
173+
const flag = monoN <= 1 ? ' ← MISSING sub-grammar (we emit one flat token)' : '';
174+
console.log(` ${p.label.padEnd(26)} official=${offN} scopes Monogram=${monoN}${flag}`);
175+
}
176+
177+
// ── 4. DIALECT — does Monogram even tokenize TSX/JSX? ──
178+
console.log(`\n── 4. DIALECT: TSX/JSX ──`);
179+
const jsx = `const el = <div className="x">{items.map(i => <Item key={i} />)}</div>;`;
180+
const mScopes = new Set(tokenize(mono, jsx).map((t) => t.scope).filter((s) => s !== ROOT_MONO));
181+
console.log(` <div>…</div> JSX → Monogram emits ${mScopes.size} non-root scopes (TS grammar has no JSX productions;`);
182+
console.log(` the official ships a SEPARATE TypeScriptReact grammar — a whole dialect Monogram lacks).`);
183+
184+
console.log(`\n═══ These are the gaps to close for a drop-in replacement; none are measured by the correctness bench. ═══`);

0 commit comments

Comments
 (0)