|
| 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(); |
0 commit comments