Skip to content

Commit 3b0e1dd

Browse files
committed
Mutation-test the completeness detector (#51)
A passing checker means nothing if the checker is blind. Add test/tm-mutation.ts: it injects a catalogue of known gaps into the emitted grammar (drop a key / all of a token's includes, neuter a scope to the bare root, add a dead key, a dangling include, mis-scope a token to a wrong role, reorder two disambiguation patterns) and records which detector layer kills each — measuring the detector's power instead of asserting it. Measured: every PRESENCE / REACHABILITY gap is killed corpus-free (16/16, 12/16 by reachability / token-census / the new flat-token neuter check); WRONG-ROLE gaps are caught only by a differential witness (presence ≠ correctness); ORDERING gaps are a measured blind spot — TextMate is order-sensitive and pattern rank lives in the emitted artifact, not the grammar algebra, so no corpus-free structural check reaches it. This is the honest boundary, now empirical: the structural proof covers presence + reachability; ordering / correctness are the soundness axis, reached only by evaluation. The over-claim of an a-priori "no gap can hide" over the whole gap space is dropped — COMPLETENESS.md states the bounded, measured claim. The harness motivated one detector strengthening: tokenCensus now flags a flat token NEUTERED to the bare root scope (an entry that exists but paints inert document text), moving that gap class from differential-only to corpus-free. Wired as a meta-gate in `npm run check`.
1 parent 836ed32 commit 3b0e1dd

5 files changed

Lines changed: 262 additions & 15 deletions

File tree

COMPLETENESS.md

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,35 @@ The empirical witness that all of the above actually paint is **leaf coverage**:
111111
deterministic grammar-derived corpus (`test/grammar-gen.ts`), every parsed leaf whose
112112
by-construction role (`buildRoleMap`) is a content/keyword role (keyword / string / number /
113113
comment) is confirmed to receive a non-root scope. The denominator is fixed (the obligation
114-
leaves); the metric is non-vacuous (deleting a discharging repository key drops it below 100%).
115-
Result: **2433/2433 across all six grammars.**
114+
leaves). Result: **2433/2433 across all six grammars.**
115+
116+
## Measuring the detector — mutation testing
117+
118+
A passing checker is worthless if the checker is *blind* — the corpus-trap this project has been
119+
bitten by. So the guarantee is not asserted, it is **measured**: `test/tm-mutation.ts` injects a
120+
catalogue of known gaps into the emitted grammar (drop a key, drop all of a token's includes,
121+
neuter a scope to the bare root, add a dead key, a dangling include, mis-scope a token to a wrong
122+
role, reorder two disambiguation patterns) and records which detector layer — if any — kills each.
123+
The honest, measured result:
124+
125+
- **Presence gaps** (a token / scope / key dropped or neutered): **16/16 killed · 12/16 by a
126+
CORPUS-FREE structural detector** (reachability dead/dangling · the token census · the flat-token
127+
neuter check). The remaining four — a *region* token neutered — are caught by a targeted
128+
differential witness, not corpus-free. **No presence gap survives; this is the gate.**
129+
- **Wrong-role gaps** (a token still painted, but the wrong role): caught by the differential
130+
(a bucket change at the witness), *not* by the structural detector — a token that *is* painted
131+
satisfies presence. This is the completeness/soundness seam: presence ≠ correctness.
132+
- **Ordering gaps** (two patterns reordered so a looser rule shadows a tighter one): a **measured
133+
blind spot**. TextMate is order-sensitive, and which pattern wins is a property of the emitted
134+
artifact's *sequence*, not the grammar's algebra — so no corpus-free structural check reaches it,
135+
and a scope-preserving reorder slips even the bucket-level differential.
136+
137+
So the claim this document makes is bounded and measured: **every presence / reachability gap is
138+
caught corpus-free** (mutation-proven, the gate); **wrong-role and ordering gaps are the soundness /
139+
interaction axis**, reached only by evaluation (the differential, or `test/gap-ledger.ts`), never by
140+
a grammar-algebraic proof. An a-priori "no gap can hide" over the *whole* gap space is not available
141+
— ordering and correctness obligations live in the emitted artifact and slide toward regex-vs-CFG
142+
undecidability — and this document does not claim it.
116143

117144
## Reachability — root ∪ export surfaces
118145

@@ -179,8 +206,10 @@ Auto-generated by `node test/tm-completeness.ts --write`; `--check` fails CI if
179206
## The gates that hold this exact
180207

181208
- `test/tm-completeness.ts` — Layer A closure (RuleExpr / TokenPattern / `collectLiterals`), the
182-
`sep`-recursion regression guard, reachability, the token census, and leaf coverage with a fixed
183-
denominator. `npm run completeness` prints it; `npm run completeness:check` gates the ledger.
209+
`sep`-recursion regression guard, reachability, the token census (orphans + neuter), and leaf
210+
coverage with a fixed denominator. `npm run completeness`; `npm run completeness:check` gates the ledger.
211+
- `test/tm-mutation.ts` — the **meta-gate**: injects known gaps and asserts every presence gap is
212+
killed with no false alarms, measuring (not asserting) the detector's power. `npm run completeness:mutation`.
184213
- `test/agnostic.ts` — detector shape-completeness: the detectors fire on structure, not on TS
185214
names, so "every shape that bears the obligation is detected" holds for any grammar.
186215
- `test/scope-gap.ts`, `test/gap-ledger.ts` — the **soundness** axis (is each painted scope

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"completeness": "node test/tm-completeness.ts",
4040
"completeness:check": "node test/tm-completeness.ts --check",
4141
"completeness:write": "node test/tm-completeness.ts --write",
42+
"completeness:mutation": "node test/tm-mutation.ts",
4243
"ledger:selftest": "node test/gap-ledger-selftest.ts",
4344
"ledger:issues": "node test/gap-issues.ts",
4445
"ledger:issues:dry": "node test/gap-issues.ts --dry-run",

test/check.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const GATES: Gate[] = [
3737
{ group: 'conformance', name: 'html', args: ['test/html-conformance.ts'] },
3838
{ group: 'highlighter', name: 'tm-guards', args: ['test/tm-highlight-guards.ts'] },
3939
{ group: 'highlighter', name: 'tm-completeness', args: ['test/tm-completeness.ts', '--check'] },
40+
{ group: 'highlighter', name: 'tm-mutation', args: ['test/tm-mutation.ts'] },
4041
{ group: 'highlighter', name: 'tm-diagnostics', args: ['test/redcmd-tm-diagnostics.ts'] },
4142
{ group: 'highlighter', name: 'angle-depth', args: ['test/angle-depth-probe.ts'] },
4243
{ group: 'highlighter', name: 'html-monarch', args: ['test/html-monarch.ts'] },

test/tm-completeness.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ function checkTokenPatternClosure(): void {
180180
// REACHABILITY — every emitted repo key reachable from root ∪ export surfaces
181181
// ════════════════════════════════════════════════════════════════════════════
182182

183-
interface TmGrammarJson { patterns?: unknown[]; repository?: Record<string, unknown>; scopeName?: string }
183+
export interface TmGrammarJson { patterns?: unknown[]; repository?: Record<string, unknown>; scopeName?: string }
184184

185185
// The DECLARED export surfaces of a grammar — repository keys an external embedder reaches
186186
// not from the root but by an explicit `<scope>#<key>` include: the #expression sub-grammar
@@ -193,9 +193,9 @@ function exportSurfaceKeys(g: CstGrammar): string[] {
193193
return out;
194194
}
195195

196-
interface ReachResult { repoKeys: number; reached: number; dead: string[]; danglingWithSource: string[] }
196+
export interface ReachResult { repoKeys: number; reached: number; dead: string[]; danglingWithSource: string[] }
197197

198-
function checkReachability(g: CstGrammar, tm: TmGrammarJson): ReachResult {
198+
export function checkReachability(g: CstGrammar, tm: TmGrammarJson): ReachResult {
199199
const scope = tm.scopeName ?? g.scopeName ?? `source.${g.name}`;
200200
const repo = tm.repository ?? {};
201201
const reached = new Set<string>();
@@ -242,25 +242,33 @@ function checkReachability(g: CstGrammar, tm: TmGrammarJson): ReachResult {
242242
// grammar emits no per-token keys — generateMarkupTm owns text/tag/attr), or a region that
243243
// owns the token's delimiter (the JSX `/>` / `</` punctuation, scoped inside the JSX patterns).
244244
// An ORPHAN — a non-skip token with no discharge path — is an emitter-completeness gap.
245-
interface TokenCensus { total: number; skip: number; byPath: Record<string, number>; orphans: string[] }
246-
function tokenCensus(g: CstGrammar, tmJson: TmGrammarJson): TokenCensus {
245+
export interface TokenCensus { total: number; skip: number; byPath: Record<string, number>; orphans: string[]; neutered: string[] }
246+
export function tokenCensus(g: CstGrammar, tmJson: TmGrammarJson): TokenCensus {
247247
const repo = tmJson.repository ?? {};
248+
const root = tmJson.scopeName ?? `source.${g.name}`;
248249
const full = JSON.stringify(tmJson);
249250
const byPath: Record<string, number> = {};
250251
const orphans: string[] = [];
252+
const neutered: string[] = []; // a flat token whose entry exists but paints only the bare root (no visual scope)
251253
let skip = 0;
252254
const bump = (p: string) => byPath[p] = (byPath[p] ?? 0) + 1;
255+
// a flat `{name, match}` entry discharges its scope obligation only if `name` is a real
256+
// visual scope — not the bare document root and not empty. An entry whose name was reduced
257+
// to the root scope is a "neuter" gap (the token tokenises but reads as inert document text),
258+
// structurally visible without any corpus.
259+
const flatNeutered = (e: any): boolean => !e.begin && !e.patterns && (!e.name || String(e.name).split(' ').every((s: string) => s === root || !s));
253260
for (const t of g.tokens) {
254261
if (t.flags.includes('skip')) { skip++; continue; }
255-
if (repo[t.name.toLowerCase()]) { bump('flat'); continue; }
262+
const flat = repo[t.name.toLowerCase()];
263+
if (flat) { if (flatNeutered(flat)) neutered.push(`${t.name}${(flat as any).name ?? '∅'}`); else bump('flat'); continue; }
256264
if (t.flags.includes('regex')) { bump('regex-family'); continue; }
257265
if (tokenPatternIsNever(t)) { bump('engine-emitted'); continue; }
258266
if (g.markup) { bump('markup-region'); continue; } // generateMarkupTm owns it
259267
const delim = tokenPatternLiteralText(t); // a region owns this token's delimiter?
260268
if (delim && full.includes(JSON.stringify(delim).slice(1, -1))) { bump('region-owned'); continue; }
261269
orphans.push(`${t.name}[${t.flags.join(',') || '-'}]`);
262270
}
263-
return { total: g.tokens.length, skip, byPath, orphans };
271+
return { total: g.tokens.length, skip, byPath, orphans, neutered };
264272
}
265273

266274
// ════════════════════════════════════════════════════════════════════════════
@@ -272,7 +280,7 @@ const require = createRequire(import.meta.url);
272280
const wasmBin = readFileSync(require.resolve('vscode-oniguruma/release/onig.wasm'));
273281
await loadWASM(wasmBin.buffer.slice(wasmBin.byteOffset, wasmBin.byteOffset + wasmBin.byteLength));
274282

275-
async function loadTmFromObject(scopeName: string, grammars: Record<string, object>): Promise<vsctm.IGrammar | null> {
283+
export async function loadTmFromObject(scopeName: string, grammars: Record<string, object>): Promise<vsctm.IGrammar | null> {
276284
const reg = new Registry({
277285
onigLib: Promise.resolve({ createOnigScanner: (p: string[]) => new OnigScanner(p), createOnigString: (s: string) => new OnigString(s) }),
278286
loadGrammar: async (sn: string) => grammars[sn] ? parseRawGrammar(JSON.stringify(grammars[sn]), sn + '.json') : null,
@@ -287,7 +295,7 @@ async function loadTmFromFiles(scopeName: string, files: Record<string, string>)
287295
});
288296
return reg.loadGrammar(scopeName);
289297
}
290-
function tmTokenize(grammar: vsctm.IGrammar, text: string): TmTok[] {
298+
export function tmTokenize(grammar: vsctm.IGrammar, text: string): TmTok[] {
291299
const toks: TmTok[] = []; let rs = INITIAL, off = 0;
292300
for (const line of text.split('\n')) { const r = grammar.tokenizeLine(line, rs); for (const t of r.tokens) toks.push({ start: off + t.startIndex, end: off + t.endIndex, scopes: t.scopes }); rs = r.ruleStack; off += line.length + 1; }
293301
return toks;
@@ -307,9 +315,9 @@ function tmTokenize(grammar: vsctm.IGrammar, text: string): TmTok[] {
307315
// ════════════════════════════════════════════════════════════════════════════
308316
const CONTENT_OBLIGATION = new Set<Bucket>(['keyword', 'string', 'number', 'comment']);
309317

310-
interface CoverageResult { den: number; painted: number; uncovered: { text: string; want: string; ctx: string }[] }
318+
export interface CoverageResult { den: number; painted: number; uncovered: { text: string; want: string; ctx: string }[] }
311319

312-
function leafCoverage(grammar: CstGrammar, tm: vsctm.IGrammar, opts = GEN_OPTS): CoverageResult {
320+
export function leafCoverage(grammar: CstGrammar, tm: vsctm.IGrammar, opts = GEN_OPTS): CoverageResult {
313321
const { parse } = createParser(grammar);
314322
const roleOf = buildRoleMap(grammar);
315323
const inputs = generateInputs(grammar, opts);
@@ -510,6 +518,7 @@ async function main(): Promise<void> {
510518
check(`reachability(${cfg.name}): no dangling self-#refs with present source`, r.danglingWithSource.length === 0, r.danglingWithSource.join(', '));
511519
const tc = tokenCensus(g, tmJson);
512520
check(`token-completeness(${cfg.name}): every non-skip token has a discharge path`, tc.orphans.length === 0, `orphans: ${tc.orphans.join(' ')}`);
521+
check(`token-completeness(${cfg.name}): no flat token is neutered to the bare root scope`, tc.neutered.length === 0, `neutered: ${tc.neutered.join(' ')}`);
513522
const tm = await loadTmFromFiles(cfg.scopeName, { [cfg.scopeName]: cfg.tm, ...(cfg.tmExtra ?? {}) });
514523
let cov: CoverageResult = { den: 0, painted: 0, uncovered: [] };
515524
if (tm) cov = leafCoverage(g, tm);

0 commit comments

Comments
 (0)