// The erase-mode judge: equivalence with ts-blank-space (docs/correctness.md, "The erase-mode contract"). // Usage: node test/erase.mjs [substring | directory | file.jsonl] import fs from "node:fs"; import path from "node:path"; import ts from "typescript"; import { blankSourceFile } from "../vendor/ts-blank-space/out/index.js"; import { transformSync, engine } from "../src/index.js"; import { cases } from "./corpus.mjs"; const arg = process.argv[2]; const SK = ts.SyntaxKind; const ALLOWED = new Set([" ", ";", "(", ")"]); // A directory holding a `.sweep-skip` marker (vendor/TypeScript's test suites, which get their own run) is left out of tree sweeps. function* walk(dir, top = true) { if (!top && fs.existsSync(path.join(dir, ".sweep-skip"))) return; for (const e of fs.readdirSync(dir, { withFileTypes: true })) { if (e.name.startsWith(".")) continue; const full = path.join(dir, e.name); if (e.isDirectory()) yield* walk(full, false); else if (/\.[cm]?ts$/.test(e.name) && !e.name.endsWith(".d.ts.map")) yield full; } } function* inputs() { if (arg && fs.existsSync(arg) && fs.statSync(arg).isDirectory()) { const seen = new Set(); for (const f of walk(arg)) { let src; try { src = fs.readFileSync(f, "utf8"); } catch { continue; } if (seen.has(src)) continue; seen.add(src); const sf = ts.createSourceFile("input.ts", src, ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS); if (sf.parseDiagnostics.length === 0) yield { name: f, src, sf }; } } else if (arg?.endsWith(".jsonl")) { const lines = fs.readFileSync(arg, "utf8").split("\n").filter(Boolean); for (const [i, line] of lines.entries()) yield { name: `${path.basename(arg)}/${i}`, src: JSON.parse(line) }; } else { for (const c of cases({ only: arg })) yield c; } } function jsAst(code) { const sf = ts.createSourceFile("out.js", code, ts.ScriptTarget.ESNext, false, ts.ScriptKind.JS); const out = []; (function visit(n) { if (n.kind === SK.EmptyStatement || n.kind === SK.SemicolonClassElement) return; out.push(n.kind); if (ts.isIdentifier(n) || ts.isPrivateIdentifier(n) || ts.isLiteralExpression(n) || ts.isTemplateLiteralToken?.(n)) out.push(JSON.stringify(n.text)); ts.forEachChild(n, visit); out.push(-1); })(sf); return { ok: sf.parseDiagnostics.length === 0, ast: out.join(",") }; } function positional(src, out) { if (out.length !== src.length) return `length ${out.length} != input ${src.length}`; for (let i = 0; i < src.length; i++) { const a = src[i], b = out[i]; if (a === b) continue; if (a === "\n" || a === "\r") return `line terminator changed at ${i}`; if (!ALLOWED.has(b)) return `invented ${JSON.stringify(b)} at ${i}`; } return null; } function* parts(src) { let seed = src.length; for (let i = 0; i < src.length; ) { seed = (seed * 1103515245 + 12345) & 0x7fffffff; const n = 1 + (seed % 4096); yield src.slice(i, i + n); i += n; } } function chunked(src) { let out = ""; for (const piece of transformSync(parts(src), { onError() {} })) out += piece; return out; } let pass = 0; // Inputs the oracle itself cannot handle (TypeScript overflowing its stack on deep nesting) are not judged. let oracleFailed = 0; const failures = []; const tally = {}; for (const { name, src, sf: parsed } of inputs()) { const sf = parsed ?? ts.createSourceFile("input.ts", src, ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS); const wantErrors = []; let want; try { want = blankSourceFile(sf, (n) => wantErrors.push(n.getStart(sf))); jsAst(want); } catch { oracleFailed++; continue; } const gotErrors = []; let got; try { got = transformSync(src, { onError: (e) => gotErrors.push(e.start) }); } catch (e) { failures.push([name, "threw", e.message]); tally.threw = (tally.threw ?? 0) + 1; continue; } const problem = positional(src, got) ?? (() => { const w = jsAst(want); let g; try { g = jsAst(got); } catch (e) { return `TypeScript could not parse our output: ${e.message}`; } if (w.ok && !g.ok) return "JS parse failed where ts-blank-space's output parses"; if (w.ast !== g.ast) return "JS AST differs from ts-blank-space's output"; if (wantErrors.join() !== gotErrors.join()) return `errors want [${wantErrors}] got [${gotErrors}]`; if (engine().push) { let streamed; try { streamed = chunked(src); } catch (e) { return `streamed input threw: ${e.message}`; } if (streamed !== got) return "streamed output differs from whole-input output"; } return null; })(); if (!problem) { pass++; continue; } const kind = problem.split(" ").slice(0, 3).join(" "); tally[kind] = (tally[kind] ?? 0) + 1; let i = 0; while (i < want.length && got[i] === want[i]) i++; failures.push([name, problem, `near ${i}\n src : ${JSON.stringify(src.slice(Math.max(0, i - 40), i + 40))}\n want: ${JSON.stringify(want.slice(Math.max(0, i - 40), i + 40))}\n got : ${JSON.stringify(got.slice(Math.max(0, i - 40), i + 40))}`]); } // A baseline file lists known failures (one path per line): those don't fail the run, anything else does, // so the count can only go down. ERASE_BASELINE_WRITE=1 rewrites it from this run. const baselinePath = process.env.ERASE_BASELINE; if (baselinePath && process.env.ERASE_BASELINE_WRITE) fs.writeFileSync(baselinePath, failures.map(([n]) => path.relative(process.cwd(), n)).sort().join("\n") + "\n"); const known = new Set(baselinePath && fs.existsSync(baselinePath) ? fs.readFileSync(baselinePath, "utf8").split("\n").filter(Boolean) : []); const isKnown = ([n]) => known.has(path.relative(process.cwd(), n)); const fresh = failures.filter((f) => !isKnown(f)); for (const [n, p, d] of fresh.slice(0, Number(process.env.SHOW ?? 15))) console.log(`FAIL ${n}: ${p}\n ${d}\n`); console.log(`erase: ${pass} pass, ${failures.length} fail`, failures.length ? JSON.stringify(tally) : "", oracleFailed ? `(${oracleFailed} skipped: oracle failed)` : ""); if (known.size) { const failing = new Set(failures.map(([n]) => path.relative(process.cwd(), n))); const fixed = [...known].filter((n) => !failing.has(n)); console.log(`baseline: ${known.size} known, ${fresh.length} new, ${fixed.length} now passing${fixed.length ? " (shrink the baseline)" : ""}`); } process.exitCode = fresh.length ? 1 : 0;