Add differential fuzzer

This commit is contained in:
developtheweb 2026-07-09 19:18:51 -04:00
parent 42ade676db
commit bc298b528e
2 changed files with 567 additions and 0 deletions

242
conformance/DIVERGENCES.md Normal file
View file

@ -0,0 +1,242 @@
# DIVERGENCES — where the two parsers disagree
Each entry is a minimized program that one parser accepts and the other
rejects (JS = the parse phase of `js/mpl.js`; ANTLR = the grammar via
`./gradlew parseCheck`). Divergences are recorded, never auto-fixed —
which parser is right is a ratification question for Stage 3. Rulings
happen in `JUDGMENT_CALLS.md`.
Entries below are appended by `conformance/harness/fuzz.mjs`
(deterministic: seed and index reproduce the original program), except
where a different source is noted.
## Divergence (found during C3 corpus construction)
```
✎ "drop\qme";
```
- JS interpreter: accepts (prints `dropqme` — an unknown string escape
drops the backslash silently)
- ANTLR grammar: rejects (token recognition error at: '"drop\q')
- RULING: pending (see JUDGMENT_CALLS.md)
## Divergence (fuzz seed 20260709, index 49)
```
```
- JS interpreter: rejects (err_unexpected at 1:1)
- ANTLR grammar: accepts (parses)
- RULING: pending (see JUDGMENT_CALLS.md)
## Divergence (fuzz seed 20260709, index 11)
```
"a\zb"
```
- JS interpreter: accepts (runs)
- ANTLR grammar: rejects (1:0: token recognition error at: '"a\z')
- RULING: pending (see JUDGMENT_CALLS.md)
## Divergence (fuzz seed 20260709, index 86)
```
((f);)
```
- JS interpreter: rejects (err_expect at 1:5)
- ANTLR grammar: accepts (parses)
- RULING: pending (see JUDGMENT_CALLS.md)
## Divergence (fuzz seed 20260709, index 247)
```
(42;)
```
- JS interpreter: rejects (err_expect at 1:4)
- ANTLR grammar: accepts (parses)
- RULING: pending (see JUDGMENT_CALLS.md)
## Divergence (fuzz seed 20260709, index 119)
```
(a;)
```
- JS interpreter: rejects (err_expect at 1:3)
- ANTLR grammar: accepts (parses)
- RULING: pending (see JUDGMENT_CALLS.md)
## Divergence (fuzz seed 20260709, index 9)
```
(f∘g)
```
- JS interpreter: rejects (err_expect at 1:3)
- ANTLR grammar: accepts (parses)
- RULING: pending (see JUDGMENT_CALLS.md)
## Divergence (fuzz seed 20260709, index 144)
```
({"",(a)})
```
- JS interpreter: rejects (err_expect at 1:5)
- ANTLR grammar: accepts (parses)
- RULING: pending (see JUDGMENT_CALLS.md)
## Divergence (fuzz seed 20260709, index 456)
```
({"a b",2})
```
- JS interpreter: rejects (err_expect at 1:8)
- ANTLR grammar: accepts (parses)
- RULING: pending (see JUDGMENT_CALLS.md)
## Divergence (fuzz seed 20260709, index 182)
```
({(007),true})
```
- JS interpreter: rejects (err_expect at 1:8)
- ANTLR grammar: accepts (parses)
- RULING: pending (see JUDGMENT_CALLS.md)
## Divergence (fuzz seed 20260709, index 284)
```
({(1),⊥})
```
- JS interpreter: rejects (err_expect at 1:6)
- ANTLR grammar: accepts (parses)
- RULING: pending (see JUDGMENT_CALLS.md)
## Divergence (fuzz seed 20260709, index 276)
```
({5,"a b"})
```
- JS interpreter: rejects (err_expect at 1:4)
- ANTLR grammar: accepts (parses)
- RULING: pending (see JUDGMENT_CALLS.md)
## Divergence (fuzz seed 20260709, index 427)
```
({b:y})
```
- JS interpreter: rejects (err_expect at 1:4)
- ANTLR grammar: accepts (parses)
- RULING: pending (see JUDGMENT_CALLS.md)
## Divergence (fuzz seed 20260709, index 101)
```
({c,0.5})
```
- JS interpreter: rejects (err_expect at 1:4)
- ANTLR grammar: accepts (parses)
- RULING: pending (see JUDGMENT_CALLS.md)
## Divergence (fuzz seed 20260709, index 425)
```
({g:⊥})
```
- JS interpreter: rejects (err_expect at 1:4)
- ANTLR grammar: accepts (parses)
- RULING: pending (see JUDGMENT_CALLS.md)
## Divergence (fuzz seed 20260709, index 194)
```
({x,(5)})
```
- JS interpreter: rejects (err_expect at 1:4)
- ANTLR grammar: accepts (parses)
- RULING: pending (see JUDGMENT_CALLS.md)
## Divergence (fuzz seed 20260709, index 212)
```
({y:"hi"})
```
- JS interpreter: rejects (err_expect at 1:4)
- ANTLR grammar: accepts (parses)
- RULING: pending (see JUDGMENT_CALLS.md)
## Divergence (fuzz seed 20260709, index 25)
```
({y:0})
```
- JS interpreter: rejects (err_expect at 1:4)
- ANTLR grammar: accepts (parses)
- RULING: pending (see JUDGMENT_CALLS.md)
## Divergence (fuzz seed 20260709, index 347)
```
({{},{}})
```
- JS interpreter: rejects (err_expect at 1:5)
- ANTLR grammar: accepts (parses)
- RULING: pending (see JUDGMENT_CALLS.md)
## Divergence (fuzz seed 20260709, index 52)
```
({λ})
```
- JS interpreter: rejects (err_expect at 1:4)
- ANTLR grammar: accepts (parses)
- RULING: pending (see JUDGMENT_CALLS.md)
## Divergence (fuzz seed 20260709, index 76)
```
λ(b):(42)
```
- JS interpreter: accepts (runs)
- ANTLR grammar: rejects (1:4: mismatched input ':' expecting {<EOF>, ';', PARALLEL, LEFTARROW, IMPLIES, OR, AND, '=', NEQ, '<', '>', LEQ, GEQ, APPROX, SIM, '+', '-', TIMES, DIV, AST, COMPOSE, DEFINITION, HANDLE, ALLOC, RELEASE, '(', '|', MIDDOT})
- RULING: pending (see JUDGMENT_CALLS.md)
## Divergence (fuzz seed 20260709, index 13)
```
λ(f):42
```
- JS interpreter: accepts (runs)
- ANTLR grammar: rejects (1:4: mismatched input ':' expecting {<EOF>, ';', PARALLEL, LEFTARROW, IMPLIES, OR, AND, '=', NEQ, '<', '>', LEQ, GEQ, APPROX, SIM, '+', '-', TIMES, DIV, AST, COMPOSE, DEFINITION, HANDLE, ALLOC, RELEASE, '(', '|', MIDDOT})
- RULING: pending (see JUDGMENT_CALLS.md)
## Divergence (fuzz seed 20260709, index 29)
```
λ(y):f
```
- JS interpreter: accepts (runs)
- ANTLR grammar: rejects (1:4: mismatched input ':' expecting {<EOF>, ';', PARALLEL, LEFTARROW, IMPLIES, OR, AND, '=', NEQ, '<', '>', LEQ, GEQ, APPROX, SIM, '+', '-', TIMES, DIV, AST, COMPOSE, DEFINITION, HANDLE, ALLOC, RELEASE, '(', '|', MIDDOT})
- RULING: pending (see JUDGMENT_CALLS.md)

View file

@ -0,0 +1,325 @@
// Differential fuzzer: generates bounded, seeded, deterministic M0-core
// programs and feeds each to both parsers — the JS interpreter's parse
// phase and the ANTLR grammar via the ParseCheck CLI. Accept/reject
// disagreements are minimized (greedy unit removal preserving the
// disagreement) and appended to conformance/DIVERGENCES.md.
//
// Divergences are recorded, never auto-fixed: which parser is right is a
// ratification question (Stage 3), not a fuzzer's call.
//
// Usage: node conformance/harness/fuzz.mjs [--seed=N] [--n=N]
// Defaults: seed 20260709, n 500. Same seed + n → identical stdout, so
// two consecutive runs diff clean (the DIVERGENCES.md append is
// deduplicated by repro text and does not affect stdout).
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { runMPL } = require('../../js/mpl.js');
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO = path.join(HERE, '..', '..');
const DIVERGENCES = path.join(HERE, '..', 'DIVERGENCES.md');
const argOf = (name, dflt) => {
const a = process.argv.find(x => x.startsWith(`--${name}=`));
return a ? Number(a.split('=')[1]) : dflt;
};
const SEED = argOf('seed', 20260709);
const N = argOf('n', 500);
// ---------------------------------------------------------------- PRNG --
function mulberry32(seed) {
let a = seed >>> 0;
return () => {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ----------------------------------------------------------- generator --
// Sticks to the M0-core surface SURFACE.md documents (no records, sets,
// modules, …) plus "spice" templates probing known-risky syntax edges.
// ‖ appears only as a parse probe; its semantics stay unpinned (decision 6).
function makeGen(rng) {
const pick = xs => xs[Math.floor(rng() * xs.length)];
const ids = ['a', 'b', 'c', 'f', 'g', 'x', 'y'];
const nums = ['0', '1', '2', '5', '42', '1.5', '0.5', '007'];
const strs = ['"hi"', '"a b"', '"x\\ny"', '"مرحبا"', '""'];
function atom(d) {
const r = rng();
if (d <= 0) return r < 0.5 ? pick(nums) : pick(ids);
if (r < 0.25) return pick(nums);
if (r < 0.35) return pick(strs);
if (r < 0.45) return pick(ids);
if (r < 0.52) return pick(['true', 'false', '⊥']);
if (r < 0.62) return `[${list(d - 1)}]`;
if (r < 0.72) return `(${expr(d - 1)})`;
if (r < 0.80) return `λ${params()}: ${expr(d - 1)}`;
if (r < 0.86) return `${pick(ids)} ∈ [${list(d - 1)}]: ${expr(d - 1)}`;
if (r < 0.93) return `{${expr(d - 1)}; ${expr(d - 1)}}`;
return `${pick(ids)}(${rng() < 0.2 ? '' : list(d - 1)})`;
}
function params() {
const n = rng() < 0.7 ? 1 : 2;
const ps = Array.from({ length: n }, () => pick(ids)).join(', ');
return rng() < 0.15 ? `${ps}` : ps;
}
function list(d) {
const n = Math.floor(rng() * 3);
return Array.from({ length: n + 1 }, () => expr(Math.max(0, d))).join(', ');
}
function expr(d) {
if (d <= 0) return atom(0);
const r = rng();
if (r < 0.30) return `${expr(d - 1)} ${pick(['+', '-', '×', '÷', '/', ''])} ${atom(d - 1)}`;
if (r < 0.42) return `${atom(d - 1)} ${pick(['=', '≠', '<', '>', '≤', '≥'])} ${atom(d - 1)}`;
if (r < 0.50) return `${atom(d - 1)} ${pick(['∧', ''])} ${atom(d - 1)}`;
if (r < 0.62) return `(${expr(d - 1)}${expr(d - 1)}) | ${atom(d - 1)}`;
if (r < 0.68) return `${atom(d - 1)}`;
if (r < 0.74) return `-${atom(d - 1)}`;
if (r < 0.78) return `${pick(ids)}${atom(d - 1)}`;
return atom(d);
}
function stmt(d) {
const r = rng();
if (r < 0.35) return `${pick(ids)}${expr(d)};`;
if (r < 0.55) return `${pick(ids)}${expr(d)};`;
if (r < 0.8) return `✎(${expr(d)});`;
return `${expr(d)};`;
}
// Known-risky syntax edges. Each is plausible M0-core spelling whose
// acceptance the two parsers might not agree on.
const spice = [
d => `(${expr(d)}; ${expr(d)});`,
d => `f ≜ λ(${pick(ids)}, ${pick(ids)}): ${expr(d)};`,
() => `✎ "a\\${pick(['q', 'w', 'z'])}b";`,
() => `✎(1e3);`,
d => `✎(${atom(d)} < ${atom(d)} < ${atom(d)});`,
() => `✎(f ∘ g);`,
d => `a ≜ b ≜ ${expr(d)};`,
d => `✎ ✎ ${atom(d)};`,
d => `✎(- -${atom(d)});`,
() => `f ≜ λ: 1;`,
() => ``,
d => `✎({${atom(d)}, ${atom(d)}});`,
d => `✎({${pick(ids)}: ${atom(d)}});`,
() => `✎(\\ast 2 3);`,
d => `${expr(d)}`, // no trailing semicolon
d => `;${stmt(d)}`, // leading semicolon
() => `✎ 1;; ✎ 2;`, // empty statement
() => `✎ .5;`, // leading-dot number
() => `✎ 1.;`, // trailing-dot number
d => `${pick(ids)}${atom(d)}: ${stmt(d)}`, // stmt as ∀ body
];
return function program(idx) {
const rBody = () => Array.from({ length: 1 + Math.floor(rng() * 3) },
() => stmt(1 + Math.floor(rng() * 2))).join('\n');
if (rng() < 0.35) {
const s = pick(spice)(1);
return rng() < 0.5 ? s : `${rBody()}\n${s}`;
}
return rBody();
};
}
// --------------------------------------------------------- JS verdict --
const PARSE_KEYS = new Set(['err_char', 'err_escape', 'err_string',
'err_comment', 'err_expect', 'err_unexpected', 'err_def_target',
'err_assign_target']);
// accepts=true means "the JS parse phase accepted it" — runtime errors and
// keyless host crashes happen after parsing and count as accept.
function jsVerdict(src) {
try {
runMPL(src, () => {});
return { accepts: true, detail: 'runs' };
} catch (e) {
if (e.key && PARSE_KEYS.has(e.key)) {
return { accepts: false, detail: `${e.key} at ${e.line}:${e.col}` };
}
return { accepts: true, detail: e.key ? `runtime ${e.key}` : `host ${e.constructor.name}` };
}
}
// ------------------------------------------------------ ANTLR verdict --
// ParseCheck is the CLI seam. Invoking it through gradle costs ~1s of JVM
// and build-system startup per call, which the minimizer cannot afford, so
// the classpath is resolved once and ParseCheck runs under plain `java`.
// Falls back to `./gradlew parseCheck` if the classpath cannot be found.
function resolveParseCheck() {
execFileSync('./gradlew', ['-q', 'classes'], { cwd: REPO, stdio: 'ignore' });
const classes = path.join(REPO, 'build', 'classes', 'java', 'main');
const cache = path.join(os.homedir(), '.gradle', 'caches', 'modules-2',
'files-2.1', 'org.antlr', 'antlr4-runtime');
let jar = null;
if (fs.existsSync(cache) && fs.existsSync(classes)) {
const stack = [cache];
while (stack.length) {
const d = stack.pop();
for (const f of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, f.name);
if (f.isDirectory()) stack.push(p);
else if (f.name.endsWith('.jar') && !f.name.includes('sources')) jar = p;
}
}
}
if (jar) return files => ['java', ['-cp', `${classes}:${jar}`, 'com.mpl.tools.ParseCheck', ...files]];
return files => ['./gradlew', ['-q', 'parseCheck', `--args=${files.join(' ')}`]];
}
const parseCheckCmd = resolveParseCheck();
const antlrCache = new Map();
function antlrVerdicts(programs) {
const result = new Array(programs.length);
const misses = [];
programs.forEach((p, i) => {
if (antlrCache.has(p)) result[i] = antlrCache.get(p);
else misses.push(i);
});
if (misses.length) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mplfuzz-'));
const files = misses.map((mi, k) => {
const f = path.join(dir, `p${String(k).padStart(4, '0')}.mpl`);
fs.writeFileSync(f, programs[mi]);
return f;
});
let stdout = '';
const [cmd, args] = parseCheckCmd(files);
try {
stdout = execFileSync(cmd, args, { cwd: REPO, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
} catch (e) {
stdout = e.stdout || '';
}
const rejected = new Map();
for (const line of stdout.split('\n')) {
const m = line.match(/p(\d{4})\.mpl:(.*)$/);
if (m) rejected.set(Number(m[1]), m[2].trim());
}
misses.forEach((mi, k) => {
const v = rejected.has(k)
? { accepts: false, detail: rejected.get(k) }
: { accepts: true, detail: 'parses' };
antlrCache.set(programs[mi], v);
result[mi] = v;
});
fs.rmSync(dir, { recursive: true, force: true });
}
return result;
}
// -------------------------------------------------------- minimization --
// Greedy unit removal: units are strings, escape words, identifiers,
// numbers, or single characters. Each round batch-checks every single-unit
// deletion and keeps the first candidate that preserves the disagreement.
const UNIT = /"(?:[^"\\]|\\.)*"|\\[a-zA-Z]+|[a-zA-Z][a-zA-Z0-9_]*|[0-9]+(?:\.[0-9]+)?|\s+|./gsu;
// One reduction round over an array of parts: try deleting a window of w
// consecutive parts at every offset (largest w first), batch-checking the
// survivors; returns the first reduced array that preserves the
// disagreement, or null when no deletion does.
function reduceOnce(parts, joiner, wanted) {
// All window sizes go into ONE batched ParseCheck call; the largest
// window that preserves the disagreement wins.
const candidates = [];
for (let w = Math.min(8, parts.length - 1); w >= 1; w = Math.floor(w / 2)) {
for (let i = 0; i + w <= parts.length; i++) {
const cand = parts.slice(0, i).concat(parts.slice(i + w));
if (jsVerdict(cand.join(joiner)).accepts === wanted.js) candidates.push({ w, cand });
}
}
if (!candidates.length) return null;
const antlr = antlrVerdicts(candidates.map(c => c.cand.join(joiner)));
const hit = candidates.findIndex((c, i) => antlr[i].accepts === wanted.antlr);
return hit === -1 ? null : candidates[hit].cand;
}
function minimize(src, wanted) {
// Phase 1: whole lines. Phase 2: lexical units.
let lines = src.split('\n').filter(l => l.trim() !== '');
for (let r = 0; r < 30 && lines.length > 1; r++) {
const red = reduceOnce(lines, '\n', wanted);
if (!red) break;
lines = red;
}
let units = lines.join('\n').match(UNIT) || [];
for (let r = 0; r < 60 && units.length > 1; r++) {
const red = reduceOnce(units, '', wanted);
if (!red) break;
units = red;
}
return units.join('').trim();
}
// ---------------------------------------------------------------- run --
const rng = mulberry32(SEED);
const gen = makeGen(rng);
console.log(`fuzz: seed=${SEED} n=${N}`);
const programs = Array.from({ length: N }, (_, i) => gen(i));
const js = programs.map(jsVerdict);
const antlr = antlrVerdicts(programs);
let agreeAccept = 0, agreeReject = 0;
const divergences = [];
for (let i = 0; i < N; i++) {
if (js[i].accepts === antlr[i].accepts) {
js[i].accepts ? agreeAccept++ : agreeReject++;
continue;
}
divergences.push(i);
}
console.log(`agreement: ${agreeAccept} both-accept, ${agreeReject} both-reject`);
console.log(`disagreements: ${divergences.length}`);
// Minimize, dedupe by canonical shape (identifiers, numbers and string
// bodies normalized — `({b: y})` and `({g: ⊥})` share one root cause),
// report deterministically.
const canon = s => s
.replace(/"(?:[^"\\]|\\.)*"/g, '"s"')
.replace(/\b(?!true\b|false\b)[a-zA-Z][a-zA-Z0-9_]*\b/g, 'v')
.replace(/[0-9]+(?:\.[0-9]+)?/g, '1')
.replace(/\s+/g, ' ');
const seen = new Set();
const findings = [];
for (const i of divergences) {
const wanted = { js: js[i].accepts, antlr: antlr[i].accepts };
const repro = minimize(programs[i], wanted);
const key = `${canon(repro)}|js=${wanted.js}`;
if (seen.has(key)) continue;
seen.add(key);
const [jsV] = [jsVerdict(repro)];
const [antlrV] = antlrVerdicts([repro]);
findings.push({ repro, index: i, jsV, antlrV });
}
findings.sort((a, b) => a.repro < b.repro ? -1 : a.repro > b.repro ? 1 : 0);
for (const f of findings) {
console.log('---');
console.log(`repro (seed ${SEED}, index ${f.index}):`);
console.log(f.repro);
console.log(` js: ${f.jsV.accepts ? 'ACCEPTS' : 'REJECTS'} (${f.jsV.detail})`);
console.log(` antlr: ${f.antlrV.accepts ? 'ACCEPTS' : 'REJECTS'} (${f.antlrV.detail})`);
}
console.log(`${findings.length} unique minimized divergences (recorded in conformance/DIVERGENCES.md)`);
// Append new findings to DIVERGENCES.md (dedup by repro text so reruns are
// idempotent; stdout above never depends on what was already in the file).
let doc = fs.existsSync(DIVERGENCES) ? fs.readFileSync(DIVERGENCES, 'utf8') : '';
let appended = '';
for (const f of findings) {
if (doc.includes('```\n' + f.repro + '\n```')) continue;
appended += `\n## Divergence (fuzz seed ${SEED}, index ${f.index})\n\n` +
'```\n' + f.repro + '\n```\n\n' +
`- JS interpreter: ${f.jsV.accepts ? 'accepts' : 'rejects'} (${f.jsV.detail})\n` +
`- ANTLR grammar: ${f.antlrV.accepts ? 'accepts' : 'rejects'} (${f.antlrV.detail})\n` +
`- RULING: pending (see JUDGMENT_CALLS.md)\n`;
}
if (appended) fs.writeFileSync(DIVERGENCES, doc + appended);