Skip to content

Commit 987847e

Browse files
Decidable completeness + a soundness-ceiling audit for the TextMate generator (#51) (#55)
1 parent 5db1e1b commit 987847e

8 files changed

Lines changed: 1604 additions & 109 deletions

COMPLETENESS.md

Lines changed: 285 additions & 0 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@
3636
"coverage:table": "node test/coverage-table.ts --write",
3737
"ledger": "node test/gap-ledger.ts --write",
3838
"ledger:check": "node test/gap-ledger.ts --check",
39+
"completeness": "node test/tm-completeness.ts",
40+
"completeness:check": "node test/tm-completeness.ts --check",
41+
"completeness:write": "node test/tm-completeness.ts --write",
42+
"completeness:mutation": "node test/tm-mutation.ts",
3943
"ledger:selftest": "node test/gap-ledger-selftest.ts",
4044
"ledger:issues": "node test/gap-issues.ts",
4145
"ledger:issues:dry": "node test/gap-issues.ts --dry-run",

src/gen-tm.ts

Lines changed: 209 additions & 85 deletions
Large diffs are not rendered by default.

test/check.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ const GATES: Gate[] = [
3636
{ group: 'conformance', name: 'jsx', args: ['test/jsx-conformance.ts'] },
3737
{ group: 'conformance', name: 'html', args: ['test/html-conformance.ts'] },
3838
{ group: 'highlighter', name: 'tm-guards', args: ['test/tm-highlight-guards.ts'] },
39+
{ group: 'highlighter', name: 'tm-completeness', args: ['test/tm-completeness.ts', '--check'] },
40+
{ group: 'highlighter', name: 'tm-mutation', args: ['test/tm-mutation.ts'] },
3941
{ group: 'highlighter', name: 'tm-diagnostics', args: ['test/redcmd-tm-diagnostics.ts'] },
4042
{ group: 'highlighter', name: 'angle-depth', args: ['test/angle-depth-probe.ts'] },
4143
{ group: 'highlighter', name: 'html-monarch', args: ['test/html-monarch.ts'] },

test/tm-completeness.ts

Lines changed: 865 additions & 0 deletions
Large diffs are not rendered by default.

test/tm-mutation.ts

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
// ─────────────────────────────────────────────────────────────────────────────
2+
// tm-mutation.ts — MUTATION TESTING for the completeness gap-detector.
3+
//
4+
// The completeness checker (test/tm-completeness.ts) proves structural properties
5+
// (closure, reachability, token discharge, leaf coverage). But "the checker passes"
6+
// only means something if the checker can actually FAIL when there IS a gap. A clean
7+
// pass on a blind checker is worthless — the exact corpus-blindness this project has
8+
// been bitten by. So this harness MEASURES the detector's power directly: it INJECTS a
9+
// catalogue of known gaps into the emitted grammar (fault injection), runs every
10+
// detector layer, and records which layer (if any) catches each.
11+
//
12+
// This is the honest answer to "can every gap be found?" — not an a-priori completeness
13+
// claim (the review showed ordering / disambiguation-correctness obligations are not
14+
// grammar-algebraic and slide into undecidable territory), but a MEASURED kill rate:
15+
//
16+
// • PRESENCE gaps (a token / scope / key dropped or neutered) MUST be killed by a
17+
// corpus-free STRUCTURAL detector (reachability / token-census / leaf-coverage).
18+
// A surviving presence mutant is a detector bug → this gate fails.
19+
// • CORRECTNESS / ORDERING gaps (a disambiguation guard weakened, two patterns
20+
// reordered) are EXPECTED to slip past the structural detectors — they are caught,
21+
// if at all, only by a differential WITNESS (a paint change on a targeted input).
22+
// Survivors here are the detector's MEASURED blind spots, reported not failed: they
23+
// are the honest boundary COMPLETENESS.md draws, made empirical.
24+
//
25+
// Run: node test/tm-mutation.ts
26+
// ─────────────────────────────────────────────────────────────────────────────
27+
import { generateTmLanguage } from '../src/gen-tm.ts';
28+
import { createParser } from '../src/gen-parser.ts';
29+
import type { CstGrammar } from '../src/types.ts';
30+
import { generateInputs } from './grammar-gen.ts';
31+
import { buildRoleMap, leafRoles, spanBuckets, scopeAt, GEN_OPTS, type TmTok, type Bucket } from './generative-detect.ts';
32+
import {
33+
checkReachability, tokenCensus, literalDischarge, leafCoverage, loadTmFromObject, tmTokenize,
34+
type TmGrammarJson,
35+
} from './tm-completeness.ts';
36+
37+
// ── a mutation: a precise, kind-labelled fault injected into the emitted grammar ──
38+
type MutClass = 'presence' | 'correctness' | 'ordering';
39+
interface Mutation {
40+
label: string;
41+
cls: MutClass;
42+
// mutate the (already-deep-cloned) emitted grammar in place; return false to skip
43+
// (the site does not exist in this grammar — keeps the catalogue grammar-agnostic).
44+
apply: (tm: any) => boolean;
45+
witness?: string; // a targeted input the differential detector tokenises
46+
leaf?: string; // the substring whose paint the differential watches
47+
equivalent?: boolean; // a true gap is created (false) vs a no-op the detector SHOULDN'T flag (true)
48+
}
49+
50+
const rootIncludeIndex = (tm: any, key: string) =>
51+
(tm.patterns as any[]).findIndex(p => p?.include === `#${key}`);
52+
// recursively delete every `{include:#key}` anywhere in the grammar (so the key truly dies)
53+
function dropAllIncludes(node: any, key: string): void {
54+
if (!node || typeof node !== 'object') return;
55+
if (Array.isArray(node)) { for (let i = node.length - 1; i >= 0; i--) { if (node[i]?.include === `#${key}`) node.splice(i, 1); else dropAllIncludes(node[i], key); } return; }
56+
for (const v of Object.values(node)) dropAllIncludes(v, key);
57+
}
58+
59+
// the catalogue is built PER-WITNESS: we tokenise the baseline, find the repository key that
60+
// ACTUALLY paints each witness leaf, and target THAT key — so a mutation creates a real gap
61+
// instead of an equivalent mutant (e.g. dropping #number's ROOT include is a no-op because
62+
// #number is still reachable from #expression; only dropping ALL includes truly kills it).
63+
function buildCatalogue(tm: any, paintKey: (w: string, leaf: string) => string | null): Mutation[] {
64+
const root = String(tm.scopeName ?? 'source');
65+
const lang = root.replace(/^(source|text)\./, '');
66+
const muts: Mutation[] = [];
67+
const sites: { witness: string; leaf: string; role: string }[] = [
68+
{ witness: 'q = 42', leaf: '42', role: 'number' },
69+
{ witness: 'q = "x"', leaf: '"x"', role: 'string' },
70+
{ witness: 'a // c', leaf: '// c', role: 'comment' },
71+
];
72+
for (const s of sites) {
73+
const key = paintKey(s.witness, s.leaf);
74+
if (!key) continue;
75+
// PRESENCE — a corpus-free structural detector must kill each of these:
76+
muts.push({ label: `drop ${s.role} key (all includes + entry)`, cls: 'presence', witness: s.witness, leaf: s.leaf,
77+
apply: (t) => { dropAllIncludes(t, key); delete t.repository[key]; return true; } });
78+
muts.push({ label: `neuter ${s.role} scope → bare root`, cls: 'presence', witness: s.witness, leaf: s.leaf,
79+
apply: (t) => { t.repository[key] = { ...t.repository[key], name: root }; if (t.repository[key].patterns || t.repository[key].begin) { delete t.repository[key].beginCaptures; delete t.repository[key].endCaptures; t.repository[key].patterns = []; } return true; } });
80+
// CORRECTNESS — a VALID grammar that paints the WRONG role (leaf still painted, just wrong):
81+
muts.push({ label: `mis-scope ${s.role} → keyword (wrong role, still painted)`, cls: 'correctness', witness: s.witness, leaf: s.leaf,
82+
apply: (t) => { t.repository[key] = { ...t.repository[key], name: `keyword.control.${lang}` }; return true; } });
83+
}
84+
// PRESENCE — a real dead key (nothing includes it) and a real dangling include:
85+
muts.push({ label: 'add an unreachable (dead) repo key', cls: 'presence',
86+
apply: (t) => { t.repository['__orphan__'] = { match: 'zzzqqq', name: `comment.${lang}` }; return true; } });
87+
muts.push({ label: 'dangling include to a missing key', cls: 'presence',
88+
apply: (t) => { t.patterns.unshift({ include: '#__ghost__' }); return true; } });
89+
// ORDERING — flip a disambiguation priority so a looser rule shadows a tighter one:
90+
if (tm.repository['generic-call'] && rootIncludeIndex(tm, 'comparison') >= 0) {
91+
muts.push({ label: 'move generic-call after comparison (priority flip)', cls: 'ordering', witness: 'a<T>(x)', leaf: 'T',
92+
apply: (t) => { const gi = rootIncludeIndex(t, 'generic-call'); if (gi < 0) return false; const [g] = t.patterns.splice(gi, 1); t.patterns.push(g); return true; } });
93+
}
94+
return muts;
95+
}
96+
97+
// ── detectors ──────────────────────────────────────────────────────────────────────
98+
// corpus-FREE structural detectors (the ones whose guarantee is a-priori, not sampled)
99+
function structuralCatches(g: CstGrammar, mutated: TmGrammarJson): string[] {
100+
const hits: string[] = [];
101+
const r = checkReachability(g, mutated);
102+
if (r.dead.length) hits.push(`reachability:dead(${r.dead.join(',')})`);
103+
if (r.danglingWithSource.length) hits.push(`reachability:dangling(${r.danglingWithSource.join(',')})`);
104+
const c = tokenCensus(g, mutated);
105+
if (c.orphans.length) hits.push(`token-census:orphan(${c.orphans.join(',')})`);
106+
if (c.neutered.length) hits.push(`token-census:neutered(${c.neutered.join(',')})`);
107+
const ld = literalDischarge(g, mutated);
108+
if (ld.gaps.length) hits.push(`literal-discharge(${ld.gaps.slice(0, 3).join(',')})`);
109+
return hits;
110+
}
111+
// load that survives an invalid mutated grammar (a broken regex) — a grammar that fails
112+
// to compile is itself a detectable defect, reported as compile-error rather than crashing.
113+
async function tryLoad(scope: string, grammar: object): Promise<{ tm: any } | { err: string }> {
114+
try { const tm = await loadTmFromObject(scope, { [scope]: grammar }); return tm ? { tm } : { err: 'load-null' }; }
115+
catch (e: any) { return { err: `compile-error(${String(e?.message ?? e).slice(0, 30)})` }; }
116+
}
117+
// grammar-derived-corpus detector (leaf coverage over generated inputs)
118+
async function corpusCatches(g: CstGrammar, scope: string, mutated: object): Promise<string | null> {
119+
const r = await tryLoad(scope, mutated);
120+
if ('err' in r) return `leaf-coverage:${r.err}`;
121+
const cov = leafCoverage(g, r.tm, { ...GEN_OPTS, maxInputs: 250 });
122+
return cov.painted < cov.den ? `leaf-coverage(${cov.painted}/${cov.den})` : null;
123+
}
124+
// targeted DIFFERENTIAL detector: did the witness leaf's paint change vs baseline?
125+
async function differentialCatches(scope: string, base: object, mutated: object, witness: string, leaf: string): Promise<string | null> {
126+
const [bt, mt] = await Promise.all([tryLoad(scope, base), tryLoad(scope, mutated)]);
127+
if ('err' in bt) return null;
128+
if ('err' in mt) return `differential:${mt.err}`;
129+
const at = witness.indexOf(leaf); if (at < 0) return null;
130+
const bb = bucketsAt(bt.tm, witness, at, leaf.length), mb = bucketsAt(mt.tm, witness, at, leaf.length);
131+
const bs = [...bb].sort().join('|'), ms = [...mb].sort().join('|');
132+
return bs !== ms ? `differential({${bs||'∅'}}→{${ms||'∅'}})` : null;
133+
}
134+
function bucketsAt(tm: any, text: string, start: number, len: number): Set<Bucket> {
135+
return spanBuckets(tmTokenize(tm, text), text, start, start + len);
136+
}
137+
138+
// ── driver ──────────────────────────────────────────────────────────────────────────
139+
interface Row { grammar: string; label: string; cls: MutClass; equivalent: boolean; killedBy: string[]; survived: boolean; skipped: boolean }
140+
141+
async function runGrammar(name: string, module: string, scope: string): Promise<Row[]> {
142+
const g = (await import(module)).default as CstGrammar;
143+
const base = generateTmLanguage(g) as any;
144+
if (base.scopeName) scope = base.scopeName;
145+
const baseTm = await loadTmFromObject(scope, { [scope]: base });
146+
if (!baseTm) return [];
147+
// the painting-key finder: the repo key whose `name` paints a witness leaf (sampled at the
148+
// leaf's MIDDLE char, so a string's CONTENT scope is found, not its delimiter punctuation).
149+
const paintKey = (witness: string, leaf: string): string | null => {
150+
const at = witness.indexOf(leaf); if (at < 0) return null;
151+
const inner = scopeAt(tmTokenize(baseTm, witness), at + Math.floor(leaf.length / 2)).at(-1) ?? '';
152+
if (!inner || inner === scope) return null;
153+
for (const [k, v] of Object.entries(base.repository) as [string, any][]) if (v?.name === inner) return k;
154+
for (const [k, v] of Object.entries(base.repository) as [string, any][]) if (typeof v?.name === 'string' && inner.startsWith(v.name + '.')) return k;
155+
return null;
156+
};
157+
const rows: Row[] = [];
158+
for (const m of buildCatalogue(base, paintKey)) {
159+
const mutated = structuredClone(base);
160+
if (!m.apply(mutated)) { rows.push({ grammar: name, label: m.label, cls: m.cls, equivalent: !!m.equivalent, killedBy: [], survived: false, skipped: true }); continue; }
161+
const killedBy = structuralCatches(g, mutated);
162+
const corpus = await corpusCatches(g, scope, mutated); if (corpus) killedBy.push(corpus);
163+
if (m.witness && m.leaf) { const d = await differentialCatches(scope, base, mutated, m.witness, m.leaf); if (d) killedBy.push(d); }
164+
rows.push({ grammar: name, label: m.label, cls: m.cls, equivalent: !!m.equivalent, killedBy, survived: killedBy.length === 0, skipped: false });
165+
}
166+
return rows;
167+
}
168+
169+
async function main(): Promise<void> {
170+
const GRAMMARS = [
171+
{ name: 'typescript', module: '../typescript.ts', scope: 'source.ts' },
172+
{ name: 'yaml', module: '../yaml.ts', scope: 'source.yaml' },
173+
];
174+
const rows: Row[] = [];
175+
for (const cfg of GRAMMARS) rows.push(...await runGrammar(cfg.name, cfg.module, cfg.scope));
176+
177+
console.log('── mutation testing: which detector layer kills each injected gap ──\n');
178+
for (const r of rows) {
179+
const mark = r.skipped ? '·' : r.equivalent ? (r.survived ? '✓' : '⚠') : r.survived ? '✗' : '✓';
180+
const by = r.skipped ? '(site n/a — skipped)'
181+
: r.equivalent ? (r.survived ? 'correctly NOT flagged (no-op mutant)' : `FALSE ALARM: ${r.killedBy.join(' ')}`)
182+
: r.survived ? 'SURVIVED — no detector caught it' : r.killedBy.join(' ');
183+
console.log(` ${mark} [${r.cls.padEnd(11)}]${r.equivalent ? '[equiv]' : ' '} ${r.grammar.padEnd(11)} ${r.label.padEnd(52)} ${by}`);
184+
}
185+
186+
const live = rows.filter(r => !r.skipped);
187+
const real = live.filter(r => !r.equivalent);
188+
const presence = real.filter(r => r.cls === 'presence');
189+
const presenceSurvivors = presence.filter(r => r.survived);
190+
const structuralKill = (r: Row) => r.killedBy.some(k => k.startsWith('reachability') || k.startsWith('token-census'));
191+
const corrOrder = real.filter(r => r.cls !== 'presence');
192+
const corrOrderSurvivors = corrOrder.filter(r => r.survived);
193+
const falseAlarms = live.filter(r => r.equivalent && !r.survived);
194+
195+
console.log('\n── measured detection power ──');
196+
console.log(` presence gaps : ${presence.length - presenceSurvivors.length}/${presence.length} killed · ${presence.filter(structuralKill).length}/${presence.length} by a CORPUS-FREE structural detector`);
197+
console.log(` correctness/ordering : ${corrOrder.length - corrOrderSurvivors.length}/${corrOrder.length} caught (differential) · ${corrOrderSurvivors.length} survived (measured blind spot)`);
198+
console.log(` equivalent controls : ${falseAlarms.length} false alarm(s) (a precision bug if > 0)`);
199+
200+
// GATE: every real presence gap MUST be killed; no equivalent mutant may be falsely flagged.
201+
// correctness/ordering survivors are the honest, documented boundary — reported, not failed.
202+
const failures = [...presenceSurvivors.map(r => `presence SURVIVED: ${r.grammar}${r.label}`),
203+
...falseAlarms.map(r => `FALSE ALARM on equivalent mutant: ${r.grammar}${r.label}`)];
204+
if (failures.length) { console.log('\n✗ detector defect(s):'); for (const f of failures) console.log(` - ${f}`); process.exit(1); }
205+
console.log(`\n✓ every presence gap killed, no false alarms; correctness/ordering blind spots measured = ${corrOrderSurvivors.length} (the boundary COMPLETENESS.md states).`);
206+
void createParser; void buildRoleMap; void leafRoles; void generateInputs;
207+
}
208+
209+
if ((import.meta as any).main) await main();

typescript.tmLanguage.json

Lines changed: 15 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)