// Correctness oracle for downlevel mode (docs/architecture.md, "Downlevel mode"). // Usage: node test/downlevel.mjs [filter] // node test/downlevel.mjs --self-test // // See docs/downlevel-oracle.md for the full method. In short, per case: // 1. Runtime equivalence against ts.transpileModule, evaluated in a vm sandbox. // 2. Line preservation: same line count as the input, and every line with no // down-leveled construct (see spansOf below) is byte-identical to our own // erase-mode output for that line. // 3. Erasable parity: with no down-leveled constructs at all, downlevel mode output // equals strip output exactly. // 4. Errors: downlevel mode must report no error inside a down-leveled construct, and // must report the same errors (by position) as erase mode everywhere else. // // This file re-execs itself under --experimental-vm-modules the first time, since // evaluating ESM output needs vm.SourceTextModule. import vm from "node:vm"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; if (typeof vm.SourceTextModule !== "function") { const r = spawnSync(process.execPath, ["--experimental-vm-modules", "--no-warnings", fileURLToPath(import.meta.url), ...process.argv.slice(2)], { stdio: "inherit" }); process.exit(r.status ?? 1); } import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import ts from "typescript"; import { transformSync } from "../src/index.js"; const root = path.resolve(import.meta.dirname, ".."); const SK = ts.SyntaxKind; // --------------------------------------------------------------------------- // Down-leveled span detection (judge's own rule, not the engine's; documented // in docs/downlevel-oracle.md). // --------------------------------------------------------------------------- function hasModifier(node, kind) { return (node.modifiers ?? []).some((m) => m.kind === kind); } function isDeclareAmbient(node) { return hasModifier(node, SK.DeclareKeyword); } const PARAM_PROPERTY_MODIFIERS = new Set([SK.PublicKeyword, SK.PrivateKeyword, SK.ProtectedKeyword, SK.ReadonlyKeyword, SK.OverrideKeyword]); function isParameterProperty(param) { const mods = (param.modifiers ?? []); return mods.some((m) => PARAM_PROPERTY_MODIFIERS.has(m.kind)); } /** TS's actual "instantiated module" rule is checker-level; this is a syntactic * approximation: a module/namespace body is instantiated if it has any statement * other than a type-only declaration or a non-instantiated nested namespace. */ function isInstantiatedModuleBody(body) { if (!body) return false; // `namespace A.B.C { ... }` parses as nested ModuleDeclarations (A's body is // the ModuleDeclaration for B, B's body is C's), not a ModuleBlock directly. if (ts.isModuleDeclaration(body)) return !isDeclareAmbient(body) && isInstantiatedModuleBody(body.body); if (!ts.isModuleBlock(body)) return false; for (const stmt of body.statements) { if (ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)) continue; if (ts.isModuleDeclaration(stmt)) { if (!isDeclareAmbient(stmt) && isInstantiatedModuleBody(stmt.body)) return true; continue; } return true; } return false; } /** Descends into whatever child determines `node`'s own first token — a legacy cast's operand is a * UnaryExpression, so a postfix chain built on it (`x.y`/`x()`/`x!`/x\`tag\`) is *part of* the * operand, not a wrapper around the cast, but the postfix chain's own first token is still whatever * its own leftmost operand's is (`{a:1}.a`'s operand is `{a:1}.a`, whose first token is `{`, not * its own kind, PropertyAccessExpression). Mirrors the engine's own check exactly: it only ever looks * at the one character right after `>`, regardless of what's built on top of or chained after it. A * parenthesized expression's own first token is the paren itself — never ambiguous — so stops there. */ function firstTokenNode(node) { while (true) { if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node) || ts.isCallExpression(node) || ts.isNonNullExpression(node)) { node = node.expression; } else if (ts.isTaggedTemplateExpression(node)) { node = node.tag; } else { return node; } } } /** True when `node` (a legacy type-assertion cast) sits at one of the four positions downlevel mode's own * erasure guards (engine/strip.zena genericArrow, F_RESTRICT/F_ARROWBODY/F_STMTSTART) leave unerased * rather than risk changing the parse. `ctx` is threaded down through the visitor below (one of * "restricted" | "arrowBody" | "stmtStart" | null) rather than read off `node.parent`, since `sf` * isn't built with parent pointers here. A chained cast (`{...}`) only has its *innermost* * cast's own operand ever match `{`/`function`/`class`, and the visitor propagates the same `ctx` * into a TypeAssertionExpression's own operand (mirroring F_RESTRICT/F_ARROWBODY/F_STMTSTART's own * persistence across the chain on one frame), so each level is checked independently, same as the * engine. */ function castEraseIsUnsafe(node, sf, ctx) { if (!ctx) return false; const castStart = node.getStart(sf); const operandStart = node.expression.getStart(sf); if (ctx === "restricted") return sf.text.slice(castStart, operandStart).includes("\n"); // arrowBody / stmtStart: the operand's own first token, not the whole gap — a multi-line type with // no hazard here (unlike "restricted") is still safe to erase. const opKind = firstTokenNode(node.expression).kind; if (opKind === SK.ObjectLiteralExpression) return true; if (ctx === "stmtStart" && (opKind === SK.FunctionExpression || opKind === SK.ClassExpression)) return true; return false; } /** Character-offset [start,end) spans that the judge treats as down-leveled, plus `holes`: positions * of a construct the judge has *already* recognized (same visitor, same per-construct rule) as one * downlevel mode explicitly declines to down-level — right now, just an unsafe-to-erase legacy cast's own * `` prefix (castEraseIsUnsafe below) — so downlevel mode is expected to report an error there, exactly * like erase mode, regardless of what encloses it. A hole only matters when it's nested inside one of * `spans` (e.g. an unsafe cast as a statement inside an otherwise down-leveled instantiated namespace): * see its one use in judgeCase's errors check for why. */ function downleveledSpans(sf, cjsReports) { const spans = []; const holes = []; function visit(node, ctx) { if (ts.isEnumDeclaration(node)) { if (!isDeclareAmbient(node)) spans.push([node.getStart(sf), node.getEnd()]); } else if (ts.isModuleDeclaration(node)) { if (!isDeclareAmbient(node) && isInstantiatedModuleBody(node.body)) spans.push([node.getStart(sf), node.getEnd()]); } else if (ts.isImportEqualsDeclaration(node)) { // Not a span when this is the `import x = require(...)` form and it conflicts with a // value-level ESM import/export declaration already seen (moduleKindReports, // "Mixed module styles in one file" in docs/downlevel-oracle.md): downlevel reports it and leaves it // unconverted instead, same as erase mode, so it is *not* a down-leveled construct here. if (!cjsReports.has(node)) spans.push([node.getStart(sf), node.getEnd()]); } else if (ts.isExportAssignment(node) && node.isExportEquals) { // `export default x` is the same AST node with isExportEquals false/undefined; downlevel mode doesn't // down-level it (it's ordinary ESM, left in place exactly like erase mode), so it isn't one of // these spans. Nor is a mixed-module-style `export =` (see the `import x =` branch above). if (!cjsReports.has(node)) spans.push([node.getStart(sf), node.getEnd()]); } else if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) { // A constructor can have overload signatures (bodyless ConstructorDeclaration // nodes) before the implementation; only the implementation's parameters can // carry property modifiers, so skip signatures when picking the constructor. const ctor = node.members.find((m) => ts.isConstructorDeclaration(m) && m.body); if (ctor && ctor.parameters.some(isParameterProperty)) spans.push([node.getStart(sf), node.getEnd()]); } else if (ts.isTypeAssertionExpression(node)) { // Legacy `expr` cast: downlevel mode erases just the `` prefix (unlike erase mode, which // still reports it, docs/modes.md) — so only that prefix, not the operand, is the down- // leveled span: the operand's own text is always copied through unchanged in both modes, so it // never needs excluding from line comparison, and (crucially for a chained cast, below) leaving // it out means an unsafe *inner* cast's own span — or lack of one — is never swallowed by an // outer safe one that happens to contain it. Guarded the same way as the engine: skipped when // one of F_RESTRICT/F_ARROWBODY/F_STMTSTART's own checks says erasing it isn't safe, in which // case downlevel mode's output is expected to be identical to strip's here, same as ordinary code. if (castEraseIsUnsafe(node, sf, ctx)) holes.push([node.getStart(sf), node.expression.getStart(sf)]); else spans.push([node.getStart(sf), node.expression.getStart(sf)]); if (node.expression) visit(node.expression, ctx); return; } if (ts.isReturnStatement(node) || ts.isThrowStatement(node)) { if (node.expression) visit(node.expression, "restricted"); return; } if (ts.isYieldExpression(node)) { if (node.expression) visit(node.expression, node.asteriskToken ? null : "restricted"); return; } if (ts.isArrowFunction(node) && !ts.isBlock(node.body)) { for (const p of node.parameters) visit(p, null); visit(node.body, "arrowBody"); return; } if (ts.isExpressionStatement(node)) { visit(node.expression, "stmtStart"); return; } // A legacy cast always consumes the rest of its own UnaryExpression as its operand (`x.y` is // `(x.y)`, cast outermost), so a postfix chain never needs ctx propagated through it — but a // lower-precedence construct built *on top of* a cast (`{} && x`, `{} ? a : b`) does: its // own first token is still whatever the cast's turns out to be, and the engine's own check runs // the moment the cast itself is processed, before any of this outer structure exists yet, so it // doesn't care what's built on top of it either. if (ctx) { if (ts.isBinaryExpression(node)) { visit(node.left, ctx); visit(node.right, null); return; } if (ts.isConditionalExpression(node)) { visit(node.condition, ctx); visit(node.whenTrue, null); visit(node.whenFalse, null); return; } if (ts.isPostfixUnaryExpression(node)) { visit(node.operand, ctx); return; } if (ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) { visit(node.expression, ctx); if (node.type) visit(node.type, null); return; } } ts.forEachChild(node, (child) => visit(child, null)); } visit(sf, null); return { spans, holes }; } function downleveledLines(sf, spans) { const lines = new Set(); for (const [start, end] of spans) { const a = sf.getLineAndCharacterOfPosition(start).line; const b = sf.getLineAndCharacterOfPosition(Math.max(start, end - 1)).line; for (let l = a; l <= b; l++) lines.add(l); } return lines; } /** Walks `sf` depth-first in source order, calling `fn(node, insideNamespace)` for every * node that is not itself inside an ambient context: a `declare`-flagged ModuleDeclaration * (`declare module "x" {}` / `declare global {}` / `declare namespace N {}`) and everything * nested inside one. Ambient content is never emitted at runtime, so neither the engine * (engine/strip.zena's blankDepth gate, entered via enterBlank for exactly this case) nor * this judge should treat anything inside it as a real import/export/CommonJS construct * — an `export =` inside `declare module "x" {}` does not make the *enclosing* file need * CommonJS (it was `needsCommonJS`'s own bug before this helper existed: see * docs/downlevel-oracle.md, "module kind decided from ambient code", and * moduleAugmentationDisallowedExtensions::x.ts in test/baselines/downlevel-typescript.txt). * `insideNamespace` is true for a node reached through *any* ModuleDeclaration's body * (ambient or not) — isEsmModuleDecl below needs it to tell a real top-level ESM export * apart from a namespace member export of the same shape (`namespace N { export function * f() {} }`), the same distinction engine/downlevel.zena's own moduleEsmSeen() makes with * getSp()/belowSt(). `fn` is still called for the ModuleDeclaration node itself when it * isn't ambient (and for everything else at the top level), just not for anything *inside* * an ambient one. Shared by needsCommonJS and moduleKindReports below, so both use exactly * the same ambient-exclusion rule as the mixed-module-style spec (docs/downlevel-oracle.md). */ function forEachNonAmbient(sf, fn) { function visit(node, ambient, insideNamespace) { if (ts.isModuleDeclaration(node)) { const nowAmbient = ambient || isDeclareAmbient(node); if (!ambient) fn(node, insideNamespace); ts.forEachChild(node, (c) => visit(c, nowAmbient, true)); return; } if (!ambient) fn(node, insideNamespace); ts.forEachChild(node, (c) => visit(c, ambient, insideNamespace)); } visit(sf, false, false); } function needsCommonJS(sf) { let found = false; forEachNonAmbient(sf, (node) => { if (found) return; if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) found = true; // TS represents both `export = x` and `export default x` as ExportAssignment, distinguished only // by isExportEquals; only the former needs CommonJS (the latter is ordinary ESM). else if (ts.isExportAssignment(node) && node.isExportEquals) found = true; }); return found; } /** A value-level ESM import/export declaration: one that survives type erasure (not * `import type`/`export type`/an all-type-only clause), per the mixed-module-style spec * (docs/downlevel-oracle.md). `export default x` is always one (an ExportAssignment with * isExportEquals false/undefined is never type-only); a named `export {}`/`export *` * counts unless the whole declaration is `export type`. Matches the engine's own * classification (engine/strip.zena's importKind()/declEnd()), not a reinvented rule: both * only look at the declaration-level `type` keyword, the same syntactic approximation * ts-blank-space's own erasure already uses — a named specifier individually marked * `type` (`import { type A, b }`) is handled by blanking just that specifier, never by * elision of the whole declaration, so it plays no part in this classification either. * `insideNamespace` (forEachNonAmbient) excludes every shape here when reached through a * namespace body: real TS syntax only allows ImportDeclaration/ExportDeclaration/a plain * `export default` at the module's true top level anyway, and an `export`-modified * declaration of the same shape inside a namespace is a namespace member export, not a * module declaration (engine/downlevel.zena's moduleEsmSeen() excludes it the same way, for * every one of its own call sites, not just the modifier-led one). */ function isEsmModuleDecl(node, insideNamespace) { if (insideNamespace) return false; if (ts.isImportDeclaration(node)) return !(node.importClause && node.importClause.isTypeOnly); if (ts.isExportDeclaration(node)) return !node.isTypeOnly; if (ts.isExportAssignment(node)) return !node.isExportEquals; // `export class`/`function`/`enum`/`var`/`let`/`const`: a value-level ESM export declaration the same // way (engine/strip.zena's stmtDecl routes every ambient or interface/type export away before reaching // its own moduleEsmSeen() call, same exclusions here). Deliberately excludes an exported namespace/ // module: those are handled entirely by engine/downlevel.zena's own namespaceStart/namespaceEnd fast path, // which this change does not instrument for module-kind tracking (kept out of scope — see the report). if (ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isModuleDeclaration(node)) return false; return hasModifier(node, SK.ExportKeyword) && !isDeclareAmbient(node); } /** A CommonJS-only construct, per the mixed-module-style spec: `import x = require(...)` * (not a plain namespace alias `import x = A.B`, which is neither CommonJS nor ESM for * this purpose — matches needsCommonJS and the engine's own isRequire check) or * `export =`. */ function isCjsOnlyConstruct(node) { if (ts.isImportEqualsDeclaration(node)) return ts.isExternalModuleReference(node.moduleReference); return ts.isExportAssignment(node) && node.isExportEquals; } /** Mirrors engine/downlevel.zena's moduleEsmSeen/moduleCjsSeen exactly (see "Module-kind * tracking" there): the shared rule for whether, and which construct, downlevel reports * when a file mixes CommonJS-only syntax with value-level ESM, derived from the same TS * AST walk as isEsmModuleDecl/isCjsOnlyConstruct/forEachNonAmbient above rather than a * separately invented one. `esmReports` holds the one ESM declaration (if any) that * itself gets reported — only when a CommonJS-only construct was already committed * (converted) before it was reached, the streaming "report the later conflicting * construct" case; `cjsReports` holds every CommonJS-only construct that gets reported * (and so stays unconverted) instead of being down-leveled. */ function moduleKindReports(sf) { let mkEsm = false, mkCjs = false, mixed = false; const esmReports = new Set(); const cjsReports = new Set(); forEachNonAmbient(sf, (node, insideNamespace) => { if (isEsmModuleDecl(node, insideNamespace)) { if (mixed) return; if (mkCjs) { mixed = true; esmReports.add(node); return; } mkEsm = true; return; } if (isCjsOnlyConstruct(node)) { if (mixed) { cjsReports.add(node); return; } if (mkEsm) { mixed = true; cjsReports.add(node); return; } mkCjs = true; } }); return { esmReports, cjsReports }; } // --------------------------------------------------------------------------- // Reference transpile (ts.transpileModule) and runtime harnesses. // --------------------------------------------------------------------------- // transpileModule only reports syntax errors, so a CommonJS case that mixes `import x = require()` // with ESM `export` (TS1287 under verbatimModuleSyntax) would pass it and be unpassable here. const INVALID_IN_CJS = new Set([1202, 1203, 1286, 1287]); function invalidCjsCase(src) { const options = { target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.CommonJS, verbatimModuleSyntax: true, noEmit: true, noLib: true, types: [] }; const host = ts.createCompilerHost(options); const getSourceFile = host.getSourceFile; host.getSourceFile = (f, lang) => (f === "case.cts" ? ts.createSourceFile(f, src, lang) : getSourceFile(f, lang)); const program = ts.createProgram(["case.cts"], options, host); const bad = [...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics()].find((d) => INVALID_IN_CJS.has(d.code)); return bad && `TS${bad.code}`; } function tsOutputFor(src, cjs) { const compilerOptions = { target: ts.ScriptTarget.ESNext, module: cjs ? ts.ModuleKind.CommonJS : ts.ModuleKind.ESNext, verbatimModuleSyntax: true, useDefineForClassFields: true, }; return ts.transpileModule(src, { compilerOptions }).outputText; } /** Fixed stand-ins for `require(...)` targets used by import=/export= cases, shared * by both the reference and candidate evaluation so the comparison stays fair. */ const MOCK_MODULES = { "./math": { add: (a, b) => a + b, PI: 3 }, "./greeter": { hello: (name) => `hi ${name}` }, }; // A Proxy whose every property reads as `undefined`: the stand-in for a module // the judge can't resolve (a non-existent file, or the other half of a split // multi-file test). Both the reference and the candidate get the same stub for // the same specifier, so a binding read through it compares equal on both sides. function stubModuleExports() { return new Proxy({}, { get: () => undefined, has: () => true }); } // --------------------------------------------------------------------------- // Permissive stubs (docs/downlevel-oracle.md, "Permissive stubs"). // // The all-`undefined` stub above is correct for the hand-written corpus (every // case is self-contained, so it never fires) but wrong for real application // files: a real module imports real dependencies and reads real globals // (`document`, `window`, `process.hrtime`, ...). Reading `undefined.foo` or // calling `undefined()` throws immediately, at module top level, before any // down-leveled construct even runs — that's the `reference-throws` flood // docs/downlevel-oracle.md, "Permissive stubs" describes. // // `permissiveStub()` instead returns a Proxy that behaves like "any value you // could want": callable, constructible, iterable (empty), coercible to a // primitive, and every property access on it returns another permissive // stub (memoized per key, so repeated access and `===` checks on the same // property are stable). This lets real code run past an unresolved import or // an undeclared global instead of throwing there. // // A stub's `typeof` is "function", and `shapeOf` collapses every function to // "[[function]]" before recursing, so no content a stub fabricates reaches the // comparison. That holds only while stubs stand in for names nothing else // defines: a stubbed built-in (`Object`, `String`) silently turns real values // into "[[function]]" on both sides and drops `Object.defineProperty` members // on one, so the global fallback must never shadow the realm's intrinsics. // `permissiveSelfTest` proves broken constructs are still rejected through // this linker, and that built-ins inside it are the realm's own. function permissiveStub() { const fn = function permissiveStub() {}; const children = new Map(); let proxy; const handler = { get(target, prop) { if (prop === Symbol.toPrimitive) return (hint) => (hint === "number" ? 0 : ""); if (prop === Symbol.iterator) return function* () {}; if (prop === Symbol.asyncIterator) return async function* () {}; if (prop === Symbol.toStringTag) return "PermissiveStub"; if (prop === Symbol.hasInstance) return () => false; if (prop === "then") return undefined; // never treated as a thenable/promise if (prop === "constructor") return proxy; if (typeof prop === "symbol") return undefined; if (!children.has(prop)) children.set(prop, permissiveStub()); return children.get(prop); }, has() { return true; }, apply() { return proxy; }, construct() { return proxy; }, getPrototypeOf() { return Object.prototype; }, }; proxy = new Proxy(fn, handler); return proxy; } /** Wraps an existing object so any string-keyed property *not already on it* * (own or inherited) reads as a fresh permissive stub instead of `undefined`, * while every existing property keeps its real behavior. Used for structured * stubs like `process` (so `process.hrtime()` evaluates) and as the * prototype of a permissive context's global object (see makeSandbox). */ function withPermissiveFallback(target, onMiss) { return new Proxy(target, { get(t, prop, receiver) { if (typeof prop === "symbol" || Reflect.has(t, prop)) return Reflect.get(t, prop, receiver); onMiss?.(prop); return permissiveStub(); }, has() { return true; }, }); } /** Makes every undeclared global in `context` resolve to a permissive stub, * without shadowing anything the context already has. * * The fallback sits on the context's own global object's prototype chain * (global -> fallback Proxy -> the realm's Object.prototype), so a name is * stubbed only after ordinary lookup misses: the realm's intrinsics (`Object`, * `String`, `JSON`, ...) are own properties of that global and resolve to * themselves, with `({}).constructor === Object` holding. Wrapping the sandbox * object itself in a Proxy (or giving it a Proxy prototype) instead stubs the * intrinsics too: Node's contextify interceptor consults the sandbox before * the realm's own globals, and does so inconsistently for a Proxy's `has`. * * Returns the set of names the fallback answered, so the judge can tell a * candidate that reads a global the reference never read (an unqualified * namespace member, a dropped top-level binding) from one that only shares * the reference's unresolved globals. */ function installPermissiveGlobalFallback(context) { const misses = new Set(); const global = vm.runInContext("globalThis", context); Object.setPrototypeOf(global, withPermissiveFallback(Object.getPrototypeOf(global), (name) => misses.add(name))); return misses; } const globalMisses = new WeakMap(); function mockRequire(id, { stub, permissive } = {}) { if (id in MOCK_MODULES) return MOCK_MODULES[id]; if (stub) return permissive ? permissiveStub() : stubModuleExports(); throw new Error(`downlevel oracle: no mock registered for require(${JSON.stringify(id)})`); } // Real-corpus fixtures can contain genuinely pathological code (an infinite // loop, or a top-level await that never settles) that a hand-written corpus // never would. Every evaluation is bounded, on both the reference and the // candidate side identically, and a timeout is its own classification // (`evaluation-timed-out`) rather than "the engine threw" or "the engine // hung" — this judge isn't equipped to tell those apart, so it doesn't try. const EVAL_TIMEOUT_MS = 2000; class EvalTimeoutError extends Error {} function asTimeout(e) { return /execution timed out/.test(e?.message ?? "") ? new EvalTimeoutError(e.message) : e; } /** Races a promise against a wall-clock timer, for the one case `vm`'s own * `timeout` option can't catch: a suspended `await` on a promise that never * settles. There's no CPU running to interrupt, so vm's interrupt-based * timeout never fires (confirmed empirically); only an ordinary timer does. * The raced-away promise is abandoned, not cancelled — harmless here since * an unsettled promise with no pending I/O doesn't keep the process alive. */ function withWallClock(promise, ms) { let timer; const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new EvalTimeoutError(`evaluation exceeded ${ms}ms wall clock`)), ms); }); return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); } // A real-corpus file can contain a top-level `process.exit(...)` as // perfectly ordinary code for its own purpose (confirmed: a Bombshell // "spike entrypoint" script in the wild does exactly this) — not an attack, // just code that was never written to be evaluated by anything but Node // directly. Running in the real realm, that call doesn't throw, it kills // *this* process, silently, mid-batch (observed directly: the batch child // exits 0 with no output and no result file, because `process.exit` never // returns to let `judgePathsMode`'s `writeFileSync` run). Every evaluation // therefore gets its own fresh `vm` context with an inert stand-in for // `process` (and no ambient access to `fs`/`child_process`/etc. — nothing // in JS reaches those without `require`, which `mockRequire`'s stub mode // already intercepts for every id). A side benefit: a file that monkey- // patches a global (`Array.prototype.map`, say) can't corrupt every other // file judged afterward in the same batch process, since each gets a new // global object. /** `permissive`: install the global fallback (installPermissiveGlobalFallback) * and wrap `process`, so an undeclared global (`document`, `window`, ...) or * an unlisted `process` member (`process.hrtime`) evaluates instead of * throwing a `ReferenceError`/`TypeError`, while the realm's built-ins stay * real. Corpus mode and the strict `--self-test` cases never pass this (every * hand-written case is self-contained, so nothing should hit the fallback); * `--dir` mode and `permissiveSelfTest` always do. */ function makeSandbox({ permissive = false } = {}) { const processStub = { env: {}, argv: ["node", "case.js"], platform: process.platform, version: process.version, exit() {}, exitCode: undefined, nextTick: queueMicrotask, on() {}, once() {}, removeListener() {}, stdout: { write() { return true; } }, stderr: { write() { return true; } }, }; const sandbox = { console, setTimeout, clearTimeout, setInterval, clearInterval, queueMicrotask, URL, URLSearchParams, TextEncoder: globalThis.TextEncoder, TextDecoder: globalThis.TextDecoder, structuredClone: globalThis.structuredClone, Buffer: globalThis.Buffer, process: permissive ? withPermissiveFallback(processStub) : processStub, }; const context = vm.createContext(sandbox); // Reference and candidate are separate evaluations, so randomness and the // wall clock must replay identically on both: a fixed-seed generator // (mulberry32) for Math.random, and a clock that starts at a fixed instant // and advances 1ms per read (so a polling loop still terminates). vm.runInContext(`{ let a = 0x9e3779b9; Math.random = function random() { 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; }; const RealDate = Date; let now = 1700000000000; const tick = () => now++; const PinnedDate = function Date(...args) { if (!new.target) return new RealDate(tick()).toString(); return Reflect.construct(RealDate, args.length ? args : [tick()], new.target); }; PinnedDate.prototype = RealDate.prototype; PinnedDate.now = tick; PinnedDate.parse = RealDate.parse; PinnedDate.UTC = RealDate.UTC; Object.defineProperty(RealDate.prototype, "constructor", { value: PinnedDate, writable: true, configurable: true }); globalThis.Date = PinnedDate; }`, context); if (permissive) globalMisses.set(context, installPermissiveGlobalFallback(context)); return context; } function runCJS(code, { stub, permissive } = {}, sandbox) { sandbox.module = { exports: {} }; sandbox.require = (id) => mockRequire(id, { stub, permissive }); // vm.Script#runInContext's `timeout` interrupts a long-running // *synchronous* script (CJS output has no top-level await, so this is // the only hang class possible here). `new Function` has no such hook. const wrapped = `(function(module,exports,require){\n${code}\n})(module,module.exports,require);`; try { new vm.Script(wrapped).runInContext(sandbox, { timeout: EVAL_TIMEOUT_MS }); } catch (e) { throw asTimeout(e); } return sandbox.module.exports; } // Named imports a SyntheticModule stub must declare up front, per specifier, // gathered from the real import/export declarations so linking doesn't choke // on "export doesn't exist". `import * as ns` and default imports don't need // any name pre-declared (namespace objects and `default` need no listing). function namedImportsBySpecifier(sf) { const bySpecifier = new Map(); function add(specifier, name) { if (!bySpecifier.has(specifier)) bySpecifier.set(specifier, new Set()); bySpecifier.get(specifier).add(name); } for (const stmt of sf.statements) { if (ts.isImportDeclaration(stmt) && stmt.importClause?.namedBindings && ts.isNamedImports(stmt.importClause.namedBindings)) { const specifier = stmt.moduleSpecifier.text; for (const el of stmt.importClause.namedBindings.elements) add(specifier, (el.propertyName ?? el.name).text); } else if (ts.isExportDeclaration(stmt) && stmt.moduleSpecifier && stmt.exportClause && ts.isNamedExports(stmt.exportClause)) { const specifier = stmt.moduleSpecifier.text; for (const el of stmt.exportClause.elements) add(specifier, (el.propertyName ?? el.name).text); } } return bySpecifier; } async function runESM(code, { stub, namedImports, permissive } = {}, sandbox) { const mod = new vm.SourceTextModule(code, { context: sandbox, importModuleDynamically: undefined, }); const run = (async () => { await mod.link(async (specifier) => { if (!stub) throw new Error("downlevel oracle: unexpected import in case source"); const names = [...(namedImports?.get(specifier) ?? []), "default"]; // `context` must match the parent module's own context (`sandbox`) or // linking throws ERR_VM_MODULE_DIFFERENT_CONTEXT — without it, every // ESM file with an unresolved import silently fell into `oracle-error` // instead of being judged, regardless of `permissive`. const synthetic = new vm.SyntheticModule(names, function () { for (const n of names) this.setExport(n, permissive ? permissiveStub() : undefined); }, { context: sandbox }); return synthetic; }); try { // Interrupts a synchronous infinite loop. Doesn't catch a suspended // `await` — that's what the outer withWallClock below is for. await mod.evaluate({ timeout: EVAL_TIMEOUT_MS }); } catch (e) { throw asTimeout(e); } return { ...mod.namespace }; })(); return withWallClock(run, EVAL_TIMEOUT_MS + 1000); } async function evaluate(code, cjs, opts = {}, sandbox = makeSandbox({ permissive: opts.permissive })) { return cjs ? runCJS(code, opts, sandbox) : await runESM(code, opts, sandbox); } // --------------------------------------------------------------------------- // Structural (order-sensitive) deep comparison of evaluated exports. // --------------------------------------------------------------------------- function shapeOf(value, seen = new Map()) { if (value === null || typeof value !== "object" && typeof value !== "function") { if (typeof value === "number" && Number.isNaN(value)) return "NaN"; if (typeof value === "bigint") return `bigint:${value}`; return value; } if (typeof value === "function") return "[[function]]"; // A `setTimeout`/`setInterval` handle (Node's internal `Timeout`/`Immediate` // class) links into the process-wide libuv timer list, which every // evaluation shares (vm contexts don't get their own event loop). Its // internal pointers (`_idleNext`/`_idlePrev`, `_idleStart`) depend on what // else is scheduled and on process uptime at creation time, not on the // down-leveled code's own correctness — reference and candidate are two // sequential evaluations in the same process, so the second one's handle // always differs structurally from the first's even when both sides are // byte-identical. Collapsed to an opaque marker for the same reason a // function is: real content never needs to be inspected here, and a // candidate that doesn't produce a real timer handle at all (wrong type, // or `undefined`) still fails, since the marker only applies once the // value's own constructor name already matches. The delay and repeat // interval come from the code, not the event loop, so they stay compared. if (value.constructor?.name === "Timeout" || value.constructor?.name === "Immediate") { return `[[${value.constructor.name} ${value._idleTimeout} ${value._repeat}]]`; } if (seen.has(value)) return "[[cycle]]"; seen.set(value, true); if (Array.isArray(value)) return value.map((v) => shapeOf(v, seen)); const keys = Object.keys(value); return { ctor: value.constructor?.name ?? null, keys, values: keys.map((k) => shapeOf(value[k], seen)) }; } function deepEqual(a, b) { return JSON.stringify(shapeOf(a)) === JSON.stringify(shapeOf(b)); } function classify(err) { return err?.constructor?.name ?? typeof err; } // --------------------------------------------------------------------------- // Engine plumbing (mode: "erase" | "downlevel"). // --------------------------------------------------------------------------- function runEngine(src, mode) { const errors = []; let out; try { out = transformSync(src, { mode, onError: (e) => errors.push({ start: e.start, end: e.end }) }); } catch (e) { return { threw: e }; } return { out, errors }; } function splitLines(text) { return text.split(/\r\n|\r|\n/); } // --------------------------------------------------------------------------- // One case, independent of where its inputs came from: this is what both the // corpus runner and --self-test call, so self-test can feed fabricated engine // output directly without going through the real wasm engine. // --------------------------------------------------------------------------- function judgeCase(src, { downlevelResult, eraseResult }) { const problems = []; const sf = ts.createSourceFile("case.ts", src, ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS); if (sf.parseDiagnostics.length) return [`input does not parse: ${sf.parseDiagnostics[0].messageText}`]; const { esmReports, cjsReports } = moduleKindReports(sf); // A mixed-module-style file (moduleKindReports finds at least one conflicting construct) is exactly // the shape invalidCjsCase's own TS1202/1203/1286/1287 check exists to catch — mixing plain `import`/ // `export` with `import x = require(...)`/`export =` is invalid under module:CommonJS + // verbatimModuleSyntax, so `ts.transpileModule` would silently miscompile it rather than throw // (docs/downlevel-oracle.md, "Mixed module styles in one file"). That's no longer a reason to bail out // of judging the case here: downlevel is expected to report it directly (never run through // judgeRuntime at all, since skipRuntime below is true once downlevelErrors is non-empty), so the // "invalid case" bailout below only applies to a file this judge still can't fairly runtime-compare. const mixed = esmReports.size > 0 || cjsReports.size > 0; const cjs = needsCommonJS(sf); if (cjs && !mixed) { const invalid = invalidCjsCase(src); if (invalid) return [`invalid case: ${invalid} under CommonJS + verbatimModuleSyntax`]; } const { spans, holes } = downleveledSpans(sf, cjsReports); const dlLines = downleveledLines(sf, spans); if (downlevelResult.threw) return [`downlevel threw: ${downlevelResult.threw.message}`]; if (eraseResult.threw) return [`strip threw: ${eraseResult.threw.message}`]; const { out: downlevelOut, errors: downlevelErrors } = downlevelResult; const { out: eraseOut, errors: eraseErrors } = eraseResult; // 3. Erasable parity. if (spans.length === 0 && downlevelOut !== eraseOut) { problems.push(`erasable parity: downlevel output differs from erase output with no down-leveled construct`); } // 2. Line preservation. const srcLines = splitLines(src); const downlevelLines = splitLines(downlevelOut); const eraseLines = splitLines(eraseOut); if (downlevelLines.length !== srcLines.length) { problems.push(`line count: downlevel output has ${downlevelLines.length} lines, input has ${srcLines.length}`); } else { for (let i = 0; i < srcLines.length; i++) { if (dlLines.has(i)) continue; if (downlevelLines[i] !== eraseLines[i]) { problems.push(`line ${i + 1} (not down-leveled) differs from erase output:\n erase : ${JSON.stringify(eraseLines[i])}\n downlevel: ${JSON.stringify(downlevelLines[i])}`); } } } // 4. Errors. const insideSpan = (e) => spans.some(([s, en]) => e.start >= s && e.start < en); const insideHole = (e) => holes.some(([s, en]) => e.start >= s && e.start < en); // A mixed-module-style file (moduleKindReports) adds one expected error erase mode never reports: // the one ESM import/export declaration that itself gets reported in place of an earlier // CommonJS-only construct downlevel already converted and streamed (can't un-emit that output, so // the *later* conflicting construct is reported instead — see engine/downlevel.zena's moduleEsmSeen). // Every CommonJS-only construct downlevel reports instead of down-leveling is already covered by // excluding it from `spans` above, so erase mode's own always-reported error for it survives the // `insideSpan` filter unaided; sorted back into position order since the engine's own errors are // always emitted in source order. const expectedDownlevelErrors = eraseErrors.filter((e) => !insideSpan(e)) .concat([...esmReports].map((node) => ({ start: node.getStart(sf), end: node.getStart(sf) + 1 }))) .sort((a, b) => a.start - b.start); // An error inside a hole (an unsafe cast's own `` prefix, same rule as the span check above) // is only exempted from the exact want/got match when it's *also* nested inside an enclosing span // (e.g. that cast sitting as a statement inside an instantiated namespace downlevel mode down-levels // cleanly): there, strip's own error for the whole enclosing construct is itself filtered out of // `want` above (strip never resolves the enclosing namespace at all, so its one generic error sits // at the namespace's own start, inside its own span), so nothing in `want` could ever line up with // downlevel mode's — correctly unsuppressed — report of the inner, unrelated cast at its own position. A // hole with no enclosing span (an unsafe cast anywhere else) keeps the exact match: downlevel mode must // still report it at precisely the position strip does, same as before this exemption existed. const downlevelErrorsForCompare = downlevelErrors.filter((e) => !(insideHole(e) && insideSpan(e))); const want = JSON.stringify(expectedDownlevelErrors.map((e) => [e.start, e.end])); const got = JSON.stringify(downlevelErrorsForCompare.map((e) => [e.start, e.end])); if (want !== got) { problems.push(`errors: want ${want} got ${got}`); } return { problems, cjs, downlevelErrors, skipRuntime: downlevelErrors.length > 0 }; } async function judgeRuntime(src, downlevelOut, cjs) { const tsOut = tsOutputFor(src, cjs); let wantValue, wantErr, gotValue, gotErr; try { wantValue = await evaluate(tsOut, cjs); } catch (e) { wantErr = e; } try { gotValue = await evaluate(downlevelOut, cjs); } catch (e) { gotErr = e; } if (wantErr || gotErr) { if (!wantErr || !gotErr) return [`runtime: one threw and the other didn't (want ${wantErr?.message ?? "no throw"}, got ${gotErr?.message ?? "no throw"})`]; if (classify(wantErr) !== classify(gotErr)) return [`runtime: error class mismatch want ${classify(wantErr)} (${wantErr.message}) got ${classify(gotErr)} (${gotErr.message})`]; return []; } if (!deepEqual(wantValue, gotValue)) { return [`runtime: exports differ\n want: ${JSON.stringify(shapeOf(wantValue))}\n got : ${JSON.stringify(shapeOf(gotValue))}`]; } return []; } // --------------------------------------------------------------------------- // Directory-mode oracle: judging arbitrary files that weren't written to // export observable results (docs/downlevel-oracle.md, "Real-corpus method"). // --------------------------------------------------------------------------- /** Every identifier a BindingName introduces, recursing through destructuring. */ function bindingNames(name, out) { if (ts.isIdentifier(name)) out.push(name.text); else if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) { for (const el of name.elements) if (!ts.isOmittedExpression(el)) bindingNames(el.name, out); } } /** The module/namespace's own top-level name, unwrapping `namespace A.B.C`'s * nested ModuleDeclaration chain down to the first (outermost) segment. */ function moduleTopName(node) { return ts.isIdentifier(node.name) ? node.name.text : null; } /** Every binding the input declares at top level: the observable surface the * accessor reads back, so a case doesn't need to hand-write an `export const * out = ...` the way corpus/downlevel.jsonl cases do. Ambient (`declare`) * declarations are skipped: they have no runtime value on either side. */ function collectTopLevelBindings(sf) { const names = []; for (const stmt of sf.statements) { if (isDeclareAmbient(stmt)) continue; if (ts.isVariableStatement(stmt)) { for (const d of stmt.declarationList.declarations) bindingNames(d.name, names); } else if (ts.isFunctionDeclaration(stmt) && stmt.name) names.push(stmt.name.text); else if ((ts.isClassDeclaration(stmt) || ts.isClassExpression(stmt)) && stmt.name) names.push(stmt.name.text); else if (ts.isEnumDeclaration(stmt)) names.push(stmt.name.text); else if (ts.isModuleDeclaration(stmt)) { const n = moduleTopName(stmt); if (n) names.push(n); } else if (ts.isImportEqualsDeclaration(stmt)) names.push(stmt.name.text); else if (ts.isImportDeclaration(stmt) && stmt.importClause) { if (stmt.importClause.name) names.push(stmt.importClause.name.text); const nb = stmt.importClause.namedBindings; if (nb && ts.isNamespaceImport(nb)) names.push(nb.name.text); else if (nb && ts.isNamedImports(nb)) for (const el of nb.elements) names.push(el.name.text); } } // De-dupe (namespace merging declares the same name more than once) and drop // anything that isn't a valid plain identifier (shouldn't happen for input // that parsed, but guards the generated accessor against injection). return [...new Set(names)].filter((n) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(n)); } /** Appended to candidate/reference output alike, after the judged output has * already been measured for line/column preservation: reads every top-level * binding and hands the resulting object to a real host-side callback * (`__downlevelObserve`, installed on the sandbox before the script runs), under * a `typeof` guard so a binding missing on one side (an engine bug) reads as * `undefined` there instead of throwing and aborting the whole evaluation — * which would hide every *other* binding's comparison behind one * ReferenceError. * * The callback is called as a bare identifier, which resolves through the * contextified sandbox like any global. (An earlier Proxy sandbox broke dotted * `globalThis.x` access; the sandbox is a plain object again, but a bare call * is the form that has always worked.) */ function observeSnippet(names) { const entries = names.map((n) => `${JSON.stringify(n)}:(typeof ${n}!=="undefined"?${n}:undefined)`).join(","); return `\n;__downlevelObserve({${entries}});`; } /** Real-corpus fixtures sometimes `console.log` as a side effect of the code * under test (e.g. constructorDeclarations/quotedConstructors.ts); silence * the console for the duration of one evaluation so the judge's own output * stays readable. Restored in a `finally` even if evaluation throws. */ async function withSilencedConsole(fn) { const saved = { log: console.log, error: console.error, warn: console.warn, info: console.info }; console.log = console.error = console.warn = console.info = () => {}; try { return await fn(); } finally { Object.assign(console, saved); } } async function evaluateObserved(code, cjs, names, opts) { const sandbox = makeSandbox({ permissive: opts?.permissive }); let observed; sandbox.__downlevelObserve = (v) => { observed = v; }; await withSilencedConsole(() => evaluate(code + observeSnippet(names), cjs, opts, sandbox)); return { observed, misses: globalMisses.get(sandbox) ?? new Set() }; } /** Like judgeRuntime, but against the binding accessor instead of a case's * hand-written `export const out`, and tolerant of a reference that throws * for reasons unrelated to down-leveling (dead code TS's checker would flag, * not syntax downlevel mode touches) — the caller classifies that separately rather * than scoring it as an engine failure. */ async function judgeRuntimeByBindings(src, downlevelOut, cjs, names, evalOpts) { if (names.length === 0) return { problems: [], referenceThrew: false }; let tsOut; try { tsOut = tsOutputFor(src, cjs); } catch (e) { return { problems: [], referenceThrew: true, referenceError: e }; } let want, wantErr, got, gotErr; try { want = await evaluateObserved(tsOut, cjs, names, evalOpts); } catch (e) { wantErr = e; } if (wantErr) { if (wantErr instanceof EvalTimeoutError) return { problems: [], referenceThrew: true, timedOut: true }; return { problems: [], referenceThrew: true, referenceError: wantErr }; } try { got = await evaluateObserved(downlevelOut, cjs, names, evalOpts); } catch (e) { gotErr = e; } if (gotErr) { if (gotErr instanceof EvalTimeoutError) return { problems: [], referenceThrew: false, timedOut: true }; return { problems: [`runtime: candidate threw where reference didn't (${classify(gotErr)}: ${gotErr.message})`], referenceThrew: false }; } if (!deepEqual(want.observed, got.observed)) { return { problems: [`runtime: bindings differ\n want: ${JSON.stringify(shapeOf(want.observed))}\n got : ${JSON.stringify(shapeOf(got.observed))}`], referenceThrew: false }; } // Without the permissive fallback, each of these reads would have thrown a // ReferenceError on the candidate side only. const extraMisses = [...got.misses].filter((n) => !want.misses.has(n)); if (extraMisses.length) { return { problems: [`runtime: candidate read undeclared global(s) the reference didn't: ${extraMisses.join(", ")}`], referenceThrew: false }; } return { problems: [], referenceThrew: false }; } // --------------------------------------------------------------------------- // Construct-level fallback judge (docs/downlevel-oracle.md, "Construct-level // fallback"). Whole-module evaluation (above) still // fails for plenty of real files: a top-level side effect calls a real // dependency's API in a way no stub can fake meaningfully, or throws for a // reason that has nothing to do with down-leveling. This fallback narrows the // unit under test to just the file's *declarations* (every top-level enum, // namespace, class, function, variable, import, and type/interface — type- // level declarations vanish anyway), in their original order up to and // including the construct being judged, dropping every top-level // *expression statement* (the main source of throws this fallback exists to // route around). It's deliberately narrower than "the construct and its // transitive dependencies": a dependency declared *after* the construct, or // one whose own correctness this fallback doesn't separately verify, is // still included verbatim but not re-checked — self-contained units only, // as the task scopes it; coverage is reported honestly in the bucket name // (`construct-*`) rather than conflated with whole-module `pass`. function isDeclarationStatement(stmt) { return ( ts.isEnumDeclaration(stmt) || ts.isModuleDeclaration(stmt) || ts.isClassDeclaration(stmt) || ts.isFunctionDeclaration(stmt) || ts.isVariableStatement(stmt) || ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt) || ts.isImportDeclaration(stmt) || ts.isImportEqualsDeclaration(stmt) || ts.isExportDeclaration(stmt) || ts.isExportAssignment(stmt) ); } /** A constructor parameter's declared type, as a best-effort literal to * probe-construct the class with. No type annotation or a shape this can't * guess defaults to `1` — the probe only needs *some* value that doesn't * throw, not a type-correct one; what's compared afterward is the * constructed instance's own properties, not the argument. */ function probeArgLiteral(param, sf) { const t = param.type; if (!t) return "1"; switch (t.kind) { case SK.StringKeyword: return '"probe"'; case SK.BooleanKeyword: return "true"; case SK.NumberKeyword: return "1"; case SK.ArrayType: return "[]"; case SK.FunctionType: return "(()=>{})"; default: if (ts.isTypeReferenceNode(t) && t.typeName.getText(sf) === "Array") return "[]"; return "1"; } } /** One self-contained unit to judge in isolation: the extracted source text * (declarations only, up to and including the construct) plus the probe * statement appended to make the construct's own result observable as a * top-level binding, reusing the exact same binding-accessor machinery as * whole-file judging. */ function extractConstructUnit(sf, src, stmt, index, topLevel) { const prefix = topLevel.slice(0, index + 1).filter(isDeclarationStatement).map((s) => src.slice(s.getStart(sf), s.getEnd())).join("\n"); if (ts.isEnumDeclaration(stmt)) { return { src: prefix, probeName: stmt.name.text, kind: "enum" }; } if (ts.isClassDeclaration(stmt) && stmt.name) { const ctor = stmt.members.find((m) => ts.isConstructorDeclaration(m) && m.body); const args = (ctor?.parameters ?? []).map((p) => probeArgLiteral(p, sf)).join(", "); const probeName = "__downlevel_construct_probe__"; const appended = `\nconst __downlevel_probe_instance__ = new ${stmt.name.text}(${args});\nexport const ${probeName} = __downlevel_probe_instance__;`; return { src: prefix + appended, probeName, kind: "paramprop" }; } return null; } /** Judges one extracted unit the same way judgeRuntimeByBindings judges a * whole file: compile it with ts.transpileModule, down-level it with our own * engine directly (not a slice of the whole file's output — the unit is its * own complete program), and compare the probe binding through the same * permissive-stub harness. Returns null (not judged) rather than a bucket * when the extracted unit itself doesn't parse or isn't self-contained * enough to evaluate (e.g. a top-level `await`, or syntax the extraction * accidentally orphaned) — the caller counts that as a coverage gap, not a * pass or a fail. */ async function judgeConstructUnit(unit) { let sf; try { sf = ts.createSourceFile("unit.ts", unit.src, ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS); } catch { return null; } if (grammarRejectedCode(sf)) return null; const cjs = needsCommonJS(sf); if (cjs && invalidCjsCase(unit.src)) return null; if (semanticGrammarCode(unit.src, cjs)) return null; const downlevelResult = runEngine(unit.src, "downlevel"); if (downlevelResult.threw) return null; const namedImports = cjs ? null : namedImportsBySpecifier(sf); const evalOpts = { stub: true, namedImports, permissive: true }; let runtime; try { runtime = await judgeRuntimeByBindings(unit.src, downlevelResult.out, cjs, [unit.probeName], evalOpts); } catch { return null; } if (runtime.timedOut || runtime.referenceThrew) return null; return runtime.problems.length > 0 ? { ok: false, detail: runtime.problems[0] } : { ok: true }; } /** Attempts construct-level judging for every top-level enum/paramprop-class * in the file (namespaces are a known gap — see docs/downlevel-oracle.md). * Returns null when nothing qualified (the file has no top-level construct * of a kind this fallback covers, e.g. everything down-leveled is nested or * a namespace), otherwise one verdict for the file: `fail` if any unit * disagreed, else `pass` with how many units were actually judged. */ async function judgeConstructsFallback(sf, src) { const topLevel = sf.statements; let judgedCount = 0; for (let i = 0; i < topLevel.length; i++) { const stmt = topLevel[i]; const isEnum = ts.isEnumDeclaration(stmt) && !isDeclareAmbient(stmt); const isParamPropClass = ts.isClassDeclaration(stmt) && stmt.name && (stmt.members.find((m) => ts.isConstructorDeclaration(m) && m.body)?.parameters ?? []).some(isParameterProperty); if (!isEnum && !isParamPropClass) continue; const unit = extractConstructUnit(sf, src, stmt, i, topLevel); if (!unit) continue; const verdict = await judgeConstructUnit(unit); if (!verdict) continue; // coverage gap for this one construct, not a failure judgedCount++; if (!verdict.ok) return { bucket: "fail", detail: `construct ${unit.kind} ${unit.probeName}: ${verdict.detail}` }; } if (judgedCount === 0) return null; return { bucket: "pass", detail: null, judgedCount }; } /** `// @filename: foo.ts` markers (the TS test-harness multi-file format) * split one source file into several virtual files. Returns null when the * input isn't a multi-file harness case. */ function splitFilenameMarkers(src) { const lines = src.split(/\r\n|\r|\n/); const markers = []; lines.forEach((line, i) => { const m = /^\/\/\s*@filename\s*:\s*(.+?)\s*$/i.exec(line.trim()); if (m) markers.push({ line: i, name: m[1] }); }); if (markers.length === 0) return null; return markers.map((marker, k) => { const start = marker.line + 1; const end = k + 1 < markers.length ? markers[k + 1].line : lines.length; return { filename: marker.name, src: lines.slice(start, end).join("\n") }; }); } /** 1xxx diagnostics (grammar TS itself rejects) vs. everything else: these get * their own bucket rather than being scored as engine failures, since the * engine's behavior on syntax TS doesn't accept isn't part of its contract. */ function grammarRejectedCode(sf) { const bad = sf.parseDiagnostics?.find((d) => d.code >= 1000 && d.code < 2000); return bad ? bad.code : null; } // --------------------------------------------------------------------------- // Grammar diagnostics outside the parser's own pass (docs/downlevel-oracle.md, // "What counts as a grammar diagnostic"). // // grammarRejectedCode above only ever sees ts.createSourceFile's own // parseDiagnostics — pure parse-time errors. TypeScript's checker raises a // large second category of "grammar" diagnostics *after* parsing, during // binding/checking (grammarErrorOnNode and friends, defined in // src/compiler/checker.ts, plus a few structural binder/checker rules that // use the checker's ordinary `error()` call but decide purely from AST shape, // never from an inferred type) — these land in getSemanticDiagnostics(), not // getSyntacticDiagnostics(), so grammarRejectedCode never sees them. A file // that fails only because of one of these (not an ordinary type error) is // the same kind of "TS itself rejects this input" case grammarRejectedCode // already carves out — matching downlevel mode's output against tsc's there isn't a // meaningful contract, same reasoning, just caught one layer later. // // GRAMMAR_SEMANTIC_CODES is derived mechanically, not by listing which codes // a known-bad file happens to emit: every Diagnostics.* identifier passed to // one of checker.ts's five grammarError* helpers (grammarErrorOnNode, // grammarErrorOnFirstToken, grammarErrorAtPos, grammarErrorOnNodeSkippedOn, // grammarErrorAfterFirstToken; program.ts's one program-level // grammarErrorOnNode call for import-attribute resolution-mode is included // too), resolved to its numeric code — MINUS any identifier that also // appears anywhere else in checker.ts outside one of those five calls (by // character offset, not just "in the same statement"), since that means the // same code can also be raised by an ordinary, non-grammar check elsewhere // (`Cannot_find_name_0`/TS2304 is the clearest example: raised via // grammarErrorOnNode only for a private-identifier-specific case, but // overwhelmingly an ordinary unresolved-identifier type error everywhere // else it's used — correctly excluded). This mechanical pass alone yields // 177 codes. // // A further 9 codes are added by hand, each because the TypeScript source // shows its *only* call site(s) decide purely from AST shape (node kind, // parent chain, modifier set, declaration order) with no call into the // checker's type-relation machinery (getTypeOfNode/isTypeAssignableTo/ // checkExpression/…) — i.e. they're grammar in every sense except happening // to use the checker's ordinary `error()` helper instead of one of the five // grammarError* ones: // - 1141 (String_literal_expected) / 1194 (Export_declarations_are_not_ // permitted_in_a_namespace): both from checkExternalImportOrExportDeclaration, // deciding from node.parent.kind/isStringLiteral(moduleName) alone. 1147 // (Import_declarations_in_a_namespace_cannot_reference_a_module, the // task's own example) is the third branch of this exact function but // already in the mechanical 177 by coincidence of another call site; 1194 // and 1141 aren't, so are listed explicitly here. // - 1061 (Enum_member_must_have_initializer): a computed enum member // lacking an initializer — purely syntactic. // - 2451 (Cannot_redeclare_block_scoped_variable_0): a binder/checker // symbol-merge conflict — "the binder's grammar errors" the task names // explicitly; decided by symbol flags and declaration positions, not // types. (Already TS's own call: program.ts's plainJSErrors — the set // it filters plain-JS-file diagnostics down to — lists this one too, // under its own "binder errors" heading.) // - 1319 (A_default_export_can_only_be_used_in_an_ECMAScript_style_module): // two call sites (an ExportAssignment's own check, and checkGrammar // Modifiers' default-modifier placement check), both deciding purely // from node.parent.kind/container.kind — genuinely grammar at both, just // flagged "ambiguous" by the mechanical pass because it's *also* called // outside the five grammarError* helpers (not because either call site // depends on a type). // - 2391/2393 (Function_implementation_is_missing_or_not_immediately_ // following_the_declaration / Duplicate_function_implementation): // function-overload sequencing — purely about declaration order and // presence of a body, each with exactly one call site. // - 2332 (this_cannot_be_referenced_in_current_location): `this` used // inside an EnumDeclaration — decided from the container node's kind. // - 2651 (A_member_initializer_in_a_enum_declaration_cannot_reference_ // members_declared_after_it…): an enum member initializer's forward // reference — decided from declaration order (isBlockScopedNameDeclared // BeforeUse), not from any member's inferred type or value. // // A further 20 codes, present after the mechanical pass and the 9 additions // above, are deliberately dropped again — found by running this check over // the whole pinned OSS corpus (code review, not just vendor/TypeScript) and // auditing every file it flagged: // - 1206/1207/1249/18036 (Decorators_are_not_valid_here and three // siblings): all decided by `nodeCanBeDecorated(legacyDecorators, …)`, // which depends on the `experimentalDecorators` option — parameter // decorators are only legal when it's on, decorated class *expressions* // only when it's off. There's no single setting that's permissive for // both, so rather than pick one and leave the other a blind spot, these // are dropped entirely. (Found via angular/nest/vscode: 3,092 real // parameter-decorator files — DI constructors — spuriously flagged // under the implicit "modern decorators" default.) // - 1268 (An_index_signature_parameter_type_must_be_string_number_symbol_ // or_a_template_literal_type): its condition calls // getTypeFromTypeNode(parameter.type) and checks the *resolved* type — // not grammar at all, despite grammarErrorOnNode. Under noLib with an // unresolved import as the annotation, the type resolves to `any`/ // error and this fires spuriously. (Found via effect's Match.ts.) // - 1202/1203/1218 and the whole import-attributes/assertions cluster // (1454, 2821/2822/2823/2836/2856/2857/2880) and the dynamic-import-only // module cluster (1323/1324) and 18057/18060: every one of these is // gated by `moduleKind`/`getEmitSyntaxForModuleSpecifierExpression` — // which module kind a statement compiles under, or which modules // support a feature at all. 1202/1203 already have purpose-built // handling (invalidCjsCase, INVALID_IN_CJS above) that checks them // under the *right* module kind for the construct, which a flat // code-membership check here can't replicate faithfully — so they're // left to that existing path instead of being duplicated, inconsistently, // into this one. (Found via typeorm's codemod fixtures.) // // Target-gated codes (Private_identifiers_are_only_available_when_targeting_ // ECMAScript_2015_and_higher and its siblings, BigInt_literals_are_not_ // available_when_targeting_lower_than_ES2020) are kept: semanticGrammarCode // always uses ts.ScriptTarget.ESNext, already the single most permissive // target, so these can never fire spuriously regardless of what a real // project's own tsconfig says. // // Still deliberately excluded, unchanged from the first pass, because their // only call sites genuinely depend on inferred types or flow analysis, or // are themselves code-ambiguous with a real type check and can't be told // apart from a diagnostic alone: TS2454 (used before being assigned — // definite-assignment flow analysis), TS2332's sibling TS2683 and // TS7005/7006/7010 (implicit-any inference), TS2749/2304 (name/type // resolution), TS18033 (type assignability), and TS2300 // (Duplicate_identifier_0 — real grammar use is object-literal // duplicate-property detection specifically; everywhere else, including // invalidInstantiatedModule.ts's and missingFunctionImplementation.ts's own // instances, it's the ordinary binder/checker duplicate-declaration check). const GRAMMAR_SEMANTIC_CODES = new Set([ // Mechanical pass minus the dropped decorator/type/module-cluster codes (see above). 17, 1003, 1005, 1015, 1016, 1017, 1018, 1019, 1020, 1021, 1022, 1024, 1028, 1029, 1030, 1031, 1035, 1036, 1038, 1039, 1040, 1042, 1044, 1046, 1047, 1048, 1049, 1051, 1052, 1053, 1054, 1070, 1071, 1079, 1089, 1090, 1092, 1093, 1094, 1095, 1096, 1097, 1098, 1099, 1106, 1107, 1108, 1110, 1111, 1113, 1114, 1117, 1118, 1119, 1120, 1123, 1142, 1155, 1163, 1166, 1171, 1172, 1173, 1174, 1175, 1176, 1182, 1183, 1184, 1187, 1191, 1193, 1196, 1197, 1200, 1211, 1216, 1221, 1222, 1242, 1243, 1246, 1247, 1248, 1254, 1257, 1265, 1266, 1273, 1274, 1275, 1276, 1277, 1300, 1312, 1317, 1318, 1325, 1326, 1330, 1331, 1332, 1333, 1334, 1335, 1337, 1338, 1354, 1358, 1363, 1392, 1433, 1450, 1451, 1491, 1492, 1493, 1494, 1495, 1545, 1546, 1547, 1548, 2206, 2207, 2410, 2480, 2492, 2501, 2566, 2633, 2639, 2666, 2667, 2714, 2737, 2803, 5076, 5085, 5086, 5087, 7059, 7060, 7061, 8020, 17001, 17012, 18006, 18007, 18019, 18028, 18038, 18041, 18045, 18058, 18059, 18061, // Manual additions (see above): 1141, 1194, 1061, 2451, 1319, 2391, 2393, 2332, 2651. 1141, 1194, 1061, 2451, 1319, 2391, 2393, 2332, 2651, ]); /** `// @option: value` directives from a TypeScript conformance test's own * leading comments (vendor/TypeScript/tests/cases/**) — a well-known * convention this judge otherwise never reads (it always picks its own * fixed options for the real transpile/runtime comparison). Honored here, * narrowly, only for the handful of options semanticGrammarCode's remaining * code set could ever be sensitive to, as a second line of defense beyond * dropping every option-gated code above: if a future addition to the set * turns out to be gated by one of these, a test file that opts into the * relevant setting still won't misfire. Multiple `// @x:` comments (one per * line, TS's own multi-file convention) all apply; an unrecognized value is * ignored rather than thrown. */ function testFileDirectives(src) { const out = {}; for (const m of src.matchAll(/^\/\/\s*@(\w+)\s*:\s*(.+)$/gm)) { out[m[1].toLowerCase()] = m[2].trim().toLowerCase(); } return out; } function directiveBool(v) { return v === "true" ? true : v === "false" ? false : undefined; } const TARGET_BY_DIRECTIVE = { es3: ts.ScriptTarget.ES3, es5: ts.ScriptTarget.ES5, es6: ts.ScriptTarget.ES2015, es2015: ts.ScriptTarget.ES2015, es2016: ts.ScriptTarget.ES2016, es2017: ts.ScriptTarget.ES2017, es2018: ts.ScriptTarget.ES2018, es2019: ts.ScriptTarget.ES2019, es2020: ts.ScriptTarget.ES2020, es2021: ts.ScriptTarget.ES2021, es2022: ts.ScriptTarget.ES2022, esnext: ts.ScriptTarget.ESNext, }; const MODULE_BY_DIRECTIVE = { none: ts.ModuleKind.None, commonjs: ts.ModuleKind.CommonJS, amd: ts.ModuleKind.AMD, umd: ts.ModuleKind.UMD, system: ts.ModuleKind.System, es6: ts.ModuleKind.ES2015, es2015: ts.ModuleKind.ES2015, es2020: ts.ModuleKind.ES2020, es2022: ts.ModuleKind.ES2022, esnext: ts.ModuleKind.ESNext, node16: ts.ModuleKind.Node16, node18: ts.ModuleKind.Node18, node20: ts.ModuleKind.Node20, nodenext: ts.ModuleKind.NodeNext, preserve: ts.ModuleKind.Preserve, }; /** Builds a full Program for `src` (no lib, no type-reference resolution — * fast, and irrelevant to whether a *grammar* diagnostic fires) and returns * the first semantic/declaration diagnostic whose code is in * GRAMMAR_SEMANTIC_CODES, or null. Only called when grammarRejectedCode * found nothing (parse-time grammar errors are cheaper and already * exhaustive for their own category). `cjs` picks the same module kind * tsOutputFor/invalidCjsCase would use for this file, so a file that's * legitimately CommonJS-shaped doesn't spuriously trip an ESM-only * diagnostic (or vice versa) that has nothing to do with its real grammar; * a `// @module:`/`// @target:`/etc. directive in `src` (see * testFileDirectives above) overrides that default the same way it would * override tsc's own command-line default. */ function semanticGrammarCode(src, cjs) { const directives = testFileDirectives(src); const options = { target: TARGET_BY_DIRECTIVE[directives.target] ?? ts.ScriptTarget.ESNext, module: MODULE_BY_DIRECTIVE[directives.module] ?? (cjs ? ts.ModuleKind.CommonJS : ts.ModuleKind.ESNext), noEmit: true, noLib: true, types: [], }; if ("experimentaldecorators" in directives) options.experimentalDecorators = directiveBool(directives.experimentaldecorators); if ("isolatedmodules" in directives) options.isolatedModules = directiveBool(directives.isolatedmodules); if ("verbatimmodulesyntax" in directives) options.verbatimModuleSyntax = directiveBool(directives.verbatimmodulesyntax); if ("usedefineforclassfields" in directives) options.useDefineForClassFields = directiveBool(directives.usedefineforclassfields); const host = ts.createCompilerHost(options); const getSourceFile = host.getSourceFile; host.getSourceFile = (f, lang) => (f === "case.ts" ? ts.createSourceFile(f, src, lang) : getSourceFile(f, lang)); let program; try { program = ts.createProgram(["case.ts"], options, host); } catch { return null; } const bad = ts.getPreEmitDiagnostics(program).find((d) => GRAMMAR_SEMANTIC_CODES.has(d.code)); return bad ? bad.code : null; } /** Walk a directory for `.ts`/`.tsx`/`.mts`/`.cts` files (never `.d.ts`), * skipping dirs that start with `.`, `node_modules`, and any directory * carrying a `.sweep-skip` marker, same convention as test/erase.mjs. * `grep`, when given, keeps only files whose contents match it. */ function* walkForDownlevel(dir, grep, top = true) { if (!top && fs.existsSync(path.join(dir, ".sweep-skip"))) return; let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const e of entries) { if (e.name.startsWith(".") || e.name === "node_modules") continue; const full = path.join(dir, e.name); if (e.isDirectory()) yield* walkForDownlevel(full, grep, false); else if (/\.(m|c)?ts$/.test(e.name) && !/\.d\.(m|c)?ts$/.test(e.name)) { if (grep) { let src; try { src = fs.readFileSync(full, "utf8"); } catch { continue; } if (!grep.test(src)) continue; yield { path: full, src }; } else yield { path: full }; } } } /** One file (or one `@filename` segment of one), run through the same * criteria 1-4 as a corpus case, plus the binding-accessor runtime check in * place of a hand-written `export const out`. Returns a bucket name and, * for `fail`, the first problem string. */ async function judgeFile(name, src, transpile) { let sf; try { sf = ts.createSourceFile("case.ts", src, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS); } catch (e) { return { bucket: "oracle-error", detail: `parse threw: ${e.message}` }; } const grammarCode = grammarRejectedCode(sf); if (grammarCode) return { bucket: "grammar-rejected", detail: `TS${grammarCode}` }; const cjs = needsCommonJS(sf); if (cjs) { const invalid = invalidCjsCase(src); if (invalid) return { bucket: "invalid-cjs", detail: invalid }; } const semGrammarCode = semanticGrammarCode(src, cjs); if (semGrammarCode) return { bucket: "grammar-rejected", detail: `TS${semGrammarCode}` }; const downlevelResult = runEngine(src, "downlevel"); const eraseResult = runEngine(src, "erase"); const judged = judgeCase(src, { downlevelResult, eraseResult }); const problems = Array.isArray(judged) ? judged : judged.problems; if (Array.isArray(judged)) return { bucket: "oracle-error", detail: problems[0] }; const namedImports = cjs ? null : namedImportsBySpecifier(sf); const names = collectTopLevelBindings(sf); const evalOpts = { stub: true, namedImports, permissive: true }; // Oxidase sanity check: run the same binding-accessor comparison against // oxidase's output. Purely informational (never decides downlevel mode's bucket), // and skipped for inputs the judge already can't score for our own engine // (grammar-rejected/invalid-cjs are filtered out above; everything that // reaches here is eligible). let oxidase; if (transpile) { try { const oxOut = transpile(src); const oxJudged = judgeCase(src, { downlevelResult: { out: oxOut, errors: [] }, eraseResult }); const oxProblems = Array.isArray(oxJudged) ? oxJudged : [...oxJudged.problems]; if (!Array.isArray(oxJudged) && !oxJudged.skipRuntime) { const r = await judgeRuntimeByBindings(src, oxOut, cjs, names, evalOpts); if (!r.referenceThrew) oxProblems.push(...r.problems); } oxidase = { agree: oxProblems.length === 0, detail: oxProblems[0] }; } catch (e) { oxidase = { agree: false, detail: `oxidase threw: ${e.message}` }; } } if (problems.length > 0) return { bucket: "fail", detail: problems[0], oxidase }; if (judged.skipRuntime) return { bucket: "pass-unhandled", detail: null, oxidase }; let runtime; try { runtime = await judgeRuntimeByBindings(src, downlevelResult.out, cjs, names, evalOpts); } catch (e) { return { bucket: "oracle-error", detail: `judge threw: ${e.message}`, oxidase }; } if (runtime.timedOut) return { bucket: "evaluation-timed-out", detail: null, oxidase }; if (runtime.referenceThrew) { // Whole-module evaluation has no trustworthy ground truth (the reference // itself throws for reasons unrelated to down-leveling), but individual // enum/paramprop-class constructs in the file may still be judgeable in // isolation (docs/downlevel-oracle.md, "Construct-level fallback"). let fallback; try { fallback = await judgeConstructsFallback(sf, src); } catch { fallback = null; } if (fallback) return { bucket: fallback.bucket === "fail" ? "fail" : "construct-pass", detail: fallback.detail, oxidase }; return { bucket: "reference-throws", detail: runtime.referenceError?.message ?? "", oxidase }; } if (runtime.problems.length > 0) return { bucket: "fail", detail: runtime.problems[0], oxidase }; return { bucket: names.length ? "pass" : "pass-no-bindings", detail: null, oxidase }; } /** Judges a flat list of already-resolved file paths (grep already applied), * reading and multi-file-splitting each itself. Shared by in-process runs * and by each batch child (see `dirMode` below). */ async function judgeEntries(paths, transpile) { const buckets = {}; const failures = []; let oxidaseRun = 0, oxidaseAgree = 0; const oxidaseDisagreements = []; function record(name, { bucket, detail, oxidase }) { buckets[bucket] = (buckets[bucket] ?? 0) + 1; if (bucket === "fail") failures.push([name, detail]); if (oxidase) { oxidaseRun++; if (oxidase.agree) oxidaseAgree++; else oxidaseDisagreements.push([name, oxidase.detail]); } } // judgeFile is the per-file "unknown input" boundary: real-world files can // do things no corpus case would (stack-overflow the parser on pathological // nesting, for instance). Everything it can anticipate is already its own // bucket; this catches what it can't, so one such file is a single // oracle-error, not a crash that discards the whole batch's results. async function judgeSafely(name, src, transpile) { try { return await judgeFile(name, src, transpile); } catch (e) { return { bucket: "oracle-error", detail: `judgeFile threw: ${e.message}` }; } } for (const p of paths) { let src; try { src = fs.readFileSync(p, "utf8"); } catch { continue; } const segments = splitFilenameMarkers(src); if (!segments) { record(p, await judgeSafely(p, src, transpile)); continue; } let any = false; for (const seg of segments) { if (!/\.(m|c)?tsx?$/.test(seg.filename) || /\.d\.(m|c)?ts$/.test(seg.filename)) continue; any = true; record(`${p}::${seg.filename}`, await judgeSafely(`${p}::${seg.filename}`, seg.src, transpile)); } if (!any) record(p, { bucket: "multi-file-skipped", detail: "no runnable @filename segment" }); } return { buckets, failures, oxidaseRun, oxidaseAgree, oxidaseDisagreements }; } function mergeResults(into, from) { for (const [k, v] of Object.entries(from.buckets)) into.buckets[k] = (into.buckets[k] ?? 0) + v; into.failures.push(...from.failures); into.oxidaseRun += from.oxidaseRun; into.oxidaseAgree += from.oxidaseAgree; into.oxidaseDisagreements.push(...from.oxidaseDisagreements); return into; } // A long sweep (the full `~/Developer` tree, or `tests/cases/compiler`) can // touch tens of thousands of distinct sources; each `vm.SourceTextModule` // compiled from one never seems to be released by V8's module registry // within one process, so memory grows roughly linearly with the number of // *distinct* files judged and a long enough run reliably hits // `--max-old-space-size` (confirmed empirically: a single-process run over // the ~/Developer sweep OOMs well before finishing). Chunking into // subprocesses bounds one process's lifetime (and therefore its module // registry) to BATCH_SIZE files, independent of how many batches the whole // sweep needs. const BATCH_SIZE = 300; // Defense in depth, on top of the per-evaluation timeouts above: if a batch // child somehow still hangs (a judge-side cost unrelated to evaluation — // e.g. a pathologically deep parse), `spawnSync`'s own `timeout` guarantees // the *sweep* still terminates. A batch killed this way contributes no // per-file buckets of its own; its files are counted `batch-timed-out` // rather than silently vanishing from the totals. function batchTimeoutMs(n) { return n * EVAL_TIMEOUT_MS * 6 + 30_000; } async function runBatch(paths) { // Handed off through a file, not stdout: a batch with enough "bindings // differ" detail in it produces a result line well past typical pipe // buffering, and a huge single line through spawnSync's stdout capture // was observed to truncate (confirmed: a JSON.parse failure at a clean // ~64KB boundary) despite a generous `maxBuffer`. A file has no such limit. const dir = fs.mkdtempSync(path.join(os.tmpdir(), "downlevel-oracle-")); const inFile = path.join(dir, "batch.json"); const outFile = path.join(dir, "result.json"); fs.writeFileSync(inFile, JSON.stringify(paths)); const r = spawnSync( process.execPath, ["--experimental-vm-modules", "--no-warnings", fileURLToPath(import.meta.url), "--judge-paths", inFile, outFile], { encoding: "utf8", maxBuffer: 1024 * 1024 * 64, timeout: batchTimeoutMs(paths.length), killSignal: "SIGKILL" }, ); let result; if (!r.signal && !r.error && fs.existsSync(outFile)) { try { result = JSON.parse(fs.readFileSync(outFile, "utf8")); } catch { /* fall through to error handling below */ } } fs.rmSync(dir, { recursive: true, force: true }); if (result) return result; if (r.signal || r.error?.code === "ETIMEDOUT") { return { buckets: { "batch-timed-out": paths.length }, failures: [], oxidaseRun: 0, oxidaseAgree: 0, oxidaseDisagreements: [] }; } throw new Error(`batch child produced no result (status ${r.status}, stderr: ${r.stderr?.slice(0, 2000)})`); } async function judgePathsMode(listFile, outFile) { const paths = JSON.parse(fs.readFileSync(listFile, "utf8")); const transpile = await loadOxidase(); const result = await judgeEntries(paths, transpile); fs.writeFileSync(outFile, JSON.stringify(result)); } async function dirMode(dirs, { grep, baseline, write, minutes = 20 } = {}) { const paths = []; for (const dir of dirs) for (const entry of walkForDownlevel(path.resolve(dir), grep)) paths.push(entry.path); console.error(`downlevel --dir: ${paths.length} files to judge, budget ${minutes}min`); let result; if (paths.length <= BATCH_SIZE) { result = await judgeEntries(paths, await loadOxidase()); } else { result = { buckets: {}, failures: [], oxidaseRun: 0, oxidaseAgree: 0, oxidaseDisagreements: [] }; const deadline = Date.now() + minutes * 60_000; let done = 0; for (let i = 0; i < paths.length; i += BATCH_SIZE) { if (Date.now() > deadline) { const remaining = paths.length - done; result.buckets["budget-exceeded-skipped"] = (result.buckets["budget-exceeded-skipped"] ?? 0) + remaining; console.error(` ... wall-clock budget (${minutes}min) exceeded, skipping remaining ${remaining} files`); break; } mergeResults(result, await runBatch(paths.slice(i, i + BATCH_SIZE))); done = Math.min(paths.length, i + BATCH_SIZE); console.error(` ... ${done}/${paths.length} files judged`); } } const { buckets, failures, oxidaseRun, oxidaseAgree, oxidaseDisagreements } = result; for (const [n, p] of failures.slice(0, Number(process.env.SHOW ?? 20))) console.log(`FAIL ${n}: ${p}`); console.log(`downlevel --dir: ${JSON.stringify(buckets)}`); if (oxidaseRun > 0) { console.log(`oxidase: ${oxidaseAgree}/${oxidaseRun} agree with ts.transpileModule`); if (process.env.SHOW_OXIDASE) for (const [n, p] of oxidaseDisagreements.slice(0, 20)) console.log(` oxidase disagrees ${n}: ${p}`); } else { console.log("oxidase: not available, skipped"); } // Baseline identifiers are relative to the repo root, like // test/baselines/erase-typescript.txt's, not absolute — an absolute path is tied to one // machine's (or one worktree's) checkout location and would never match // on another, silently turning every known failure "fresh" there. const relName = (n) => { const [filePath, segment] = n.split("::"); const rel = path.relative(root, filePath); return segment ? `${rel}::${segment}` : rel; }; if (write && baseline) { fs.writeFileSync(baseline, failures.map(([n]) => relName(n)).sort().join("\n") + "\n"); } const known = new Set(baseline && fs.existsSync(baseline) ? fs.readFileSync(baseline, "utf8").split("\n").filter(Boolean) : []); const fresh = failures.filter(([n]) => !known.has(relName(n))); if (baseline) { const failingNow = new Set(failures.map(([n]) => relName(n))); const fixed = [...known].filter((n) => !failingNow.has(n)); console.log(`baseline ${path.relative(root, baseline)}: ${known.size} known, ${fresh.length} new, ${fixed.length} now passing${fixed.length ? " (shrink the baseline)" : ""}`); } process.exitCode = fresh.length ? 1 : 0; } // --------------------------------------------------------------------------- // Corpus loading. // --------------------------------------------------------------------------- function* loadCorpus() { const file = path.join(root, "corpus/downlevel.jsonl"); const lines = fs.readFileSync(file, "utf8").split("\n").filter(Boolean); for (const line of lines) yield JSON.parse(line); } // --------------------------------------------------------------------------- // Self-test: the judge must reject deliberately broken engine output. // --------------------------------------------------------------------------- async function selfTest() { // A self-referencing enum so member-init order is externally observable. const src = [ "enum E { A = 1, B = E.A + 2 }", "export const out = [E.A, E.B, E[E.A], E[E.B]];", ].join("\n"); const correctDownlevel = [ "var E;(function(E){E[E.A=1]='A';E[E.B=E.A+2]='B';}).call(E||(E={}),E); ", "export const out = [E.A, E.B, E[E.A], E[E.B]];", ].join("\n"); const strip = runEngine(src, "erase"); const good = judgeCase(src, { downlevelResult: { out: correctDownlevel, errors: [] }, eraseResult: strip }); const runtimeGood = good.skipRuntime ? [] : await judgeRuntime(src, correctDownlevel, good.cjs); const sanity = [...good.problems, ...runtimeGood]; const broken = { "missing reverse mapping": correctDownlevel.replace("E[E.A=1]='A';", "E.A=1; "), "wrong init order": correctDownlevel.replace("E[E.A=1]='A';E[E.B=E.A+2]='B';", "E[E.B=E.A+2]='B';E[E.A=1]='A'; "), "lost line": correctDownlevel.split("\n").join(" "), "extra error": null, // handled specially below }; let ok = sanity.length === 0; if (!ok) console.log(`FAIL self-test: the correct fixture was rejected\n ${sanity.join("\n ")}`); for (const [name, downlevelOut] of Object.entries(broken)) { let problems; if (name === "extra error") { const r = judgeCase(src, { downlevelResult: { out: correctDownlevel, errors: [{ start: 0, end: 4 }] }, eraseResult: strip }); problems = r.problems; } else { const r = judgeCase(src, { downlevelResult: { out: downlevelOut, errors: [] }, eraseResult: strip }); problems = [...r.problems]; if (!r.skipRuntime) problems.push(...await judgeRuntime(src, downlevelOut, r.cjs)); } const rejected = problems.length > 0; console.log(`${rejected ? "ok" : "FAIL"} self-test rejects "${name}"${rejected ? "" : " (judge accepted broken output!)"}`); ok = ok && rejected; } const holeOk = await holeExemptionSelfTest(); const grammarOk = await grammarSemanticSelfTest(); const permissiveOk = await permissiveSelfTest(); const mixedOk = mixedModuleSelfTest(); console.log(ok && holeOk && grammarOk && permissiveOk && mixedOk ? "self-test: pass" : "self-test: FAIL"); process.exitCode = ok && holeOk && grammarOk && permissiveOk && mixedOk ? 0 : 1; } // --------------------------------------------------------------------------- // Mixed-module-style self-test: proves the judge's own moduleKindReports (the // mixed-module-style spec, docs/downlevel-oracle.md) accepts a candidate that // correctly reports the conflicting construct(s) in a mixed file (landing it // in the "pass" bucket here, "pass-unhandled" in --dir mode, since // skipRuntime is true once any error is reported) in both streaming // directions — ESM import before `import x =`, and `import x =` before ESM // import — and, symmetrically, that it still FAILS a candidate that reports // nothing for the same mixed file: the judge must never silently accept a // mixed file the candidate didn't flag. // --------------------------------------------------------------------------- function mixedModuleSelfTest() { const cases = [ ["ESM import before `import x =`", "import { add } from \"./math\";\nimport math = require(\"./math\");\nexport const out = add(1, 2) + math.PI;\n"], ["`import x =` before ESM import", "import math = require(\"./math\");\nimport { add } from \"./math\";\nexport const out = math.PI + add(1, 2);\n"], ]; let ok = true; for (const [label, src] of cases) { const strip = runEngine(src, "erase"); // The real engine's own output and errors: must be accepted, and must skip the runtime // comparison (every mixed file reports at least one error, by construction). const reporting = runEngine(src, "downlevel"); const good = judgeCase(src, { downlevelResult: reporting, eraseResult: strip }); const goodOk = good.problems.length === 0 && good.skipRuntime; if (!goodOk) console.log(`FAIL mixed-module self-test (${label}): the real engine's own reporting output was rejected\n ${good.problems.join("\n ")}`); console.log(`${goodOk ? "ok" : "FAIL"} mixed-module self-test accepts a candidate that reports the conflict (${label})`); // A broken candidate that silently converts everything and reports nothing: the judge // must still reject this, proving it can't be satisfied by a candidate that ignores the // mix entirely (reporting.out, stripped of its errors, is exactly the shape a candidate // without this feature would have produced before this change). const silent = { out: reporting.out, errors: [] }; const broken = judgeCase(src, { downlevelResult: silent, eraseResult: strip }); const brokenRejected = broken.problems.length > 0; console.log(`${brokenRejected ? "ok" : "FAIL"} mixed-module self-test rejects a candidate that reports nothing (${label})${brokenRejected ? "" : " (judge accepted a silently-mixed file!)"}`); ok = ok && goodOk && brokenRejected; } return ok; } // --------------------------------------------------------------------------- // Grammar-semantic self-test: proves semanticGrammarCode (docs/downlevel- // oracle.md, "What counts as a grammar diagnostic") (a) does route a real // grammar-only diagnostic to the grammar-rejected bucket through judgeFile, // the actual --dir entry point, and (b) does NOT treat a type-error-only // source (valid grammar) the same way — that one must still reach the // ordinary runtime comparison, and still fail there when the candidate is // broken, exactly as it would without this change. // --------------------------------------------------------------------------- async function grammarSemanticSelfTest() { // (a) TS1030 ('export' modifier already seen) — grammar-only: not caught // by grammarRejectedCode (sf.parseDiagnostics is empty for this; TS treats // the duplicate modifier as a checker-time grammar error, not a parse // error), only by semanticGrammarCode. const grammarSrc = "export export var x = 1;\nexport const out = x;\n"; const sfGrammar = ts.createSourceFile("case.ts", grammarSrc, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS); const parseLevel = grammarRejectedCode(sfGrammar); const semLevel = semanticGrammarCode(grammarSrc, needsCommonJS(sfGrammar)); const grammarJudged = await judgeFile("grammar-self-test", grammarSrc, null); let ok = parseLevel === null && semLevel === 1030 && grammarJudged.bucket === "grammar-rejected"; console.log(`${ok ? "ok" : "FAIL"} grammar-semantic self-test routes a TS1030-only file to grammar-rejected${ok ? "" : ` (parseLevel=${parseLevel} semLevel=${semLevel} bucket=${grammarJudged.bucket})`}`); // (b) A type-error-only source (TS2322, valid grammar) with a down-leveled // enum: semanticGrammarCode must say null (this must stay runtime-judged), // and feeding a broken downlevel output through the same path judgeFile uses // must still fail — the type error doesn't exempt it. const typeErrSrc = [ "enum E { A = 1, B = 2 }", 'let notANumber: number = "nope";', "export const out = [E.A, E.B, E[E.A], notANumber];", ].join("\n"); const sfType = ts.createSourceFile("case.ts", typeErrSrc, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS); const typeSemLevel = semanticGrammarCode(typeErrSrc, needsCommonJS(sfType)); const typeJudgedGood = await judgeFile("type-self-test-good", typeErrSrc, null); const eraseForType = runEngine(typeErrSrc, "erase"); const goodDownlevelOut = runEngine(typeErrSrc, "downlevel").out; const brokenOut = goodDownlevelOut.replace('this.A=A;this[A]="A";', "this.A=A; "); const brokenJudged = judgeCase(typeErrSrc, { downlevelResult: { out: brokenOut, errors: [] }, eraseResult: eraseForType }); const brokenRuntime = brokenJudged.skipRuntime ? [] : await judgeRuntime(typeErrSrc, brokenOut, brokenJudged.cjs); const brokenRejected = brokenJudged.problems.length > 0 || brokenRuntime.length > 0; const bOk = typeSemLevel === null && typeJudgedGood.bucket !== "grammar-rejected" && brokenRejected; console.log(`${bOk ? "ok" : "FAIL"} grammar-semantic self-test still runtime-judges (and rejects a broken candidate for) a type-error-only file${bOk ? "" : ` (typeSemLevel=${typeSemLevel} goodBucket=${typeJudgedGood.bucket} brokenRejected=${brokenRejected})`}`); return ok && bOk; } // --------------------------------------------------------------------------- // Hole-exemption self-test: proves judgeCase's errors check (4.) still // catches a real mismatch after the holes/spans exemption added for // namespace-swallows-unrelated-errors (a cast whose own erasure guard // declines to erase it, nested inside an otherwise-instantiated namespace // downlevel mode down-levels cleanly — see downleveledSpans' `holes` and judgeCase's // `insideHole`/`insideSpan`). The exemption only lets such a position's // error go unmatched against `want` when nested inside an enclosing span; // everywhere else the exact want/got match from before this change still // applies, including a wrong-but-nearby error inside that *same* span. // --------------------------------------------------------------------------- async function holeExemptionSelfTest() { const src = [ "namespace M {", " {a:1}.a;", " export var z = 1;", "}", "export const out = M.z;", ].join("\n"); const strip = runEngine(src, "erase"); const castStart = src.indexOf(""); // The real engine's own output and error (the fix): accepted. const correctDownlevel = runEngine(src, "downlevel"); const good = judgeCase(src, { downlevelResult: correctDownlevel, eraseResult: strip }); let ok = good.problems.length === 0; if (!ok) console.log(`FAIL hole-exemption self-test: the correct fixture was rejected\n ${good.problems.join("\n ")}`); console.log(`${ok ? "ok" : "FAIL"} hole-exemption self-test accepts the real fix's output`); // A bogus error at the *wrong* position, still inside the same enclosing // namespace span: the hole exemption must not swallow this — it isn't at // the hole's own position, so it still needs to match `want` (empty here, // since strip's own error for the whole namespace is itself filtered out // of `want` by the enclosing span) and doesn't. const wrongPosition = judgeCase(src, { downlevelResult: { out: correctDownlevel.out, errors: [{ start: 0, end: 1 }] }, eraseResult: strip, }); const rejectedWrongPosition = wrongPosition.problems.length > 0; console.log(`${rejectedWrongPosition ? "ok" : "FAIL"} hole-exemption self-test rejects "error at the wrong position inside the same span"${rejectedWrongPosition ? "" : " (judge accepted broken output!)"}`); // The pre-fix bug itself: the real cast error dropped (errors: []), output // otherwise unchanged. judgeCase's structural checks can't see this (by // the same reasoning the exemption above relies on — nothing in `want` is // at the cast's own position either way), so it's the *runtime* check that // must still catch it: the output literally keeps the unerased `` // text, which throws evaluating it, unlike the reference. const droppedError = { out: correctDownlevel.out, errors: [] }; const droppedJudged = judgeCase(src, { downlevelResult: droppedError, eraseResult: strip }); const runtimeProblems = droppedJudged.skipRuntime ? [] : await judgeRuntime(src, droppedError.out, droppedJudged.cjs); const rejectedDropped = droppedJudged.problems.length > 0 || runtimeProblems.length > 0; console.log(`${rejectedDropped ? "ok" : "FAIL"} hole-exemption self-test rejects "cast error silently dropped"${rejectedDropped ? "" : " (judge accepted broken output!)"}`); ok = ok && rejectedWrongPosition && rejectedDropped; if (castStart < 0) ok = false; // sanity: the fixture's own shape didn't move under us return ok; } // --------------------------------------------------------------------------- // Permissive-stub self-test: proves the `--dir`-mode linker (docs/ // downlevel-oracle.md, "Permissive stubs" above) still rejects a broken // down-leveled construct even when the file also imports an unresolved // module — i.e. the permissive stub standing in for that import can't paper // over a real bug. Each fixture imports a name from a module that doesn't // exist, uses it in the exported binding (so a stub that leaked real content // into the comparison would show up here), and is judged the same way `--dir` // mode judges a real file: `judgeRuntimeByBindings` with `permissive: true`. // --------------------------------------------------------------------------- async function permissiveFixtureCheck(label, src, downlevelOut) { const sf = ts.createSourceFile("case.ts", src, ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS); const cjs = needsCommonJS(sf); const names = collectTopLevelBindings(sf); const namedImports = cjs ? null : namedImportsBySpecifier(sf); const evalOpts = { stub: true, permissive: true, namedImports }; const r = await judgeRuntimeByBindings(src, downlevelOut, cjs, names, evalOpts); if (r.referenceThrew) return { ok: false, detail: `reference threw unexpectedly: ${r.referenceError?.message ?? "(timed out)"}` }; return { ok: r.problems.length === 0, detail: r.problems[0] }; } async function permissiveSelfTest() { const fixtures = [ { category: "enum", src: [ 'import { helper } from "nonexistent-unresolved-pkg";', "enum E { A = 1, B = E.A + 2 }", "export const out = [E.A, E.B, E[E.A], E[E.B], typeof helper];", ].join("\n"), correct: [ 'import { helper } from "nonexistent-unresolved-pkg";', "var E;(function(E){E[E.A=1]='A';E[E.B=E.A+2]='B';}).call(E||(E={}),E);", "export const out = [E.A, E.B, E[E.A], E[E.B], typeof helper];", ].join("\n"), broken: { "enum: missing reverse mapping": (c) => c.replace("E[E.A=1]='A';", "E.A=1;"), "enum: wrong init order": (c) => c.replace("E[E.A=1]='A';E[E.B=E.A+2]='B';", "E[E.B=E.A+2]='B';E[E.A=1]='A';"), }, }, { category: "namespace", src: [ 'import { helper } from "nonexistent-unresolved-pkg";', "namespace N { export let x = 1; export function bump() { x++; } }", "N.bump();", "export const out = [N.x, typeof helper];", ].join("\n"), correct: [ 'import { helper } from "nonexistent-unresolved-pkg";', "var N;(function(N){N.x=1;function bump(){N.x++;}N.bump=bump;}).call(N||(N={}),N);", "N.bump();", "export const out = [N.x, typeof helper];", ].join("\n"), broken: { // The oxidase-class bug: a one-time copy instead of a live binding, so // the mutation inside bump() never reaches N.x. "namespace: non-live exported var": (c) => c.replace("N.x=1;function bump(){N.x++;}", "let x=1;function bump(){x++;}N.x=x;"), }, }, { // The engine's live-binding shape: a mutable export becomes a getter/setter // via `Object.defineProperty`, so a stubbed `Object` drops the member. category: "namespace using built-ins", src: [ 'import { helper } from "nonexistent-unresolved-pkg";', "namespace N { export let x = 10; export const s = String(JSON.stringify({ m: Math.max(1, 3) })); export function bump() { x++; } }", "N.bump();", "export const out = [N.x, N.s, typeof helper];", ].join("\n"), correct: [ 'import { helper } from "nonexistent-unresolved-pkg";', 'var N;(function(N){let x=10;Object.defineProperty(this,"x",{get(){return x},set(x$){x=x$},enumerable:true,configurable:true});const s=String(JSON.stringify({m:Math.max(1,3)}));this.s=s;function bump(){x++;}this.bump=bump;}).call(N||(N={}),N);', "N.bump();", "export const out = [N.x, N.s, typeof helper];", ].join("\n"), broken: { "namespace using built-ins: missing member": (c) => c.replace("this.s=s;", ""), "namespace using built-ins: wrong value": (c) => c.replace("Math.max(1,3)", "Math.min(1,3)"), }, }, { // The broken outputs below leave every observed value equal: only the // global fallback's read log can tell them apart from the correct one. category: "namespace ambient member", src: [ 'import { helper } from "nonexistent-unresolved-pkg";', "namespace N { export declare var a: number; export var b = a; }", "N.b = 1;", "export const out = [N.b, typeof helper];", ].join("\n"), correct: [ 'import { helper } from "nonexistent-unresolved-pkg";', "var N;(function(N){var b=this.a;this.b=b;}).call(N||(N={}),N);", "N.b = 1;", "export const out = [N.b, typeof helper];", ].join("\n"), broken: { "namespace ambient member: unqualified reference": (c) => c.replace("var b=this.a;", "var b=a;"), }, }, { // An ambient member's whole point is that it's supplied from outside (another merged declaration, a // runtime, or a plain assignment after the namespace closes) -- a disconnected local that only ever // reads its own never-assigned value is the exact bug `60257aa`'s first version shipped (silently // wrong, not even a throw): the write from outside never reaches a bare reference inside the body. category: "namespace ambient member (external write)", // `f` comes first, the ambient declaration second: a property's position in iteration order is fixed // at first definition regardless of when its `enumerable` flag later flips (confirmed directly — a // `defineProperty` that only changes `enumerable` never moves the key), so ordering `x` after `f` here // keeps `tsc`'s own order (`f` assigned inside the IIFE, `x` only once the write below runs) matching // what the engine can realistically produce, the same way the engine's own tAmbientLive does when (as // in every real case found) the ambient declaration already comes after its siblings in source. src: [ 'import { helper } from "nonexistent-unresolved-pkg";', "namespace N { export function f() { return x; } export declare var x: number; }", "N.x = 5;", "export const out = [N.f(), typeof helper];", ].join("\n"), correct: [ 'import { helper } from "nonexistent-unresolved-pkg";', 'var N;(function(N){function f(){return x;}this.f=f;var x;Object.defineProperty(this,"x",{get(){return x},set(x$){x=x$;Object.defineProperty(this,"x",{enumerable:true})},enumerable:false,configurable:true});}).call(N||(N={}),N);', "N.x = 5;", "export const out = [N.f(), typeof helper];", ].join("\n"), broken: { "namespace ambient member: external write not observed (disconnected local)": (c) => c.replace( 'Object.defineProperty(this,"x",{get(){return x},set(x$){x=x$;Object.defineProperty(this,"x",{enumerable:true})},enumerable:false,configurable:true});', "", ), }, }, { category: "top-level binding", src: [ 'import { helper } from "nonexistent-unresolved-pkg";', "enum E { A }", "function f() { return E.A; }", "export const out = [E.A, typeof helper];", ].join("\n"), correct: [ 'import { helper } from "nonexistent-unresolved-pkg";', "var E;(function(E){E[E.A=0]='A';}).call(E||(E={}),E);", "function f() { return E.A; }", "export const out = [E.A, typeof helper];", ].join("\n"), broken: { "top-level binding: dropped function": (c) => c.replace("function f() { return E.A; }", ""), }, }, { category: "paramprop", src: [ 'import { helper } from "nonexistent-unresolved-pkg";', "class P { constructor(public a: number, private b: number) {} sum() { return this.a + this.b; } }", "export const out = [new P(1, 2).sum(), typeof helper];", ].join("\n"), correct: [ 'import { helper } from "nonexistent-unresolved-pkg";', "class P { constructor(a, b) { this.a = a; this.b = b; } sum() { return this.a + this.b; } }", "export const out = [new P(1, 2).sum(), typeof helper];", ].join("\n"), broken: { "paramprop: dropped field assignment": (c) => c.replace("this.a = a; this.b = b; ", "this.a = a; "), }, }, { // No down-level construct at all — this fixture exists only to exercise // `shapeOf`'s `[[timer]]` collapse. Reference and candidate are two separate evaluations in // the same process, so `setTimeout`'s returned `Timeout` handle always // differs structurally between them (its internal libuv list pointers // depend on what else is scheduled at creation time) even though // `correct` here is the untransformed source verbatim: before the // `[[timer]]` collapse, this fixture alone would fail self-test by // rejecting its own correct output. category: "timer handle", src: [ 'import { helper } from "nonexistent-unresolved-pkg";', "const handle = setTimeout(() => {}, 30000);", "handle.unref();", "export const out = [typeof handle, typeof helper];", ].join("\n"), correct: [ 'import { helper } from "nonexistent-unresolved-pkg";', "const handle = setTimeout(() => {}, 30000);", "handle.unref();", "export const out = [typeof handle, typeof helper];", ].join("\n"), broken: { // A real engine bug that drops the timer entirely still needs to be // caught: `handle` becomes `undefined` instead of a `Timeout`, a // type mismatch the collapse must not paper over. "timer handle: dropped timer": (c) => c.replace("const handle = setTimeout(() => {}, 30000);", "const handle = undefined;"), "timer handle: wrong delay": (c) => c.replace("30000", "3000"), "timer handle: interval for timeout": (c) => c.replace("setTimeout(", "setInterval("), }, }, ]; let ok = true; for (const f of fixtures) { const good = await permissiveFixtureCheck(f.category, f.src, f.correct); if (!good.ok) { console.log(`FAIL permissive self-test: correct "${f.category}" fixture was rejected (${good.detail})`); ok = false; } for (const [name, mutate] of Object.entries(f.broken)) { const bad = await permissiveFixtureCheck(name, f.src, mutate(f.correct)); const rejected = !bad.ok; console.log(`${rejected ? "ok" : "FAIL"} permissive self-test rejects "${name}"${rejected ? "" : " (judge accepted broken output through permissive stubs!)"}`); ok = ok && rejected; } } return (await permissiveRealmProbe()) && ok; } /** Built-ins inside a permissive sandbox must be the context realm's own * intrinsics, not stubs, while an undeclared host global is still a stub. */ async function permissiveRealmProbe() { const code = [ "const probe = [", " Object === ({}).constructor, [] instanceof Array, typeof String, String(1),", " JSON.stringify({ a: Math.max(1, 2) }), new Map([[1, 2]]).get(1),", " typeof document, typeof window.location.href,", " new Date() instanceof Date, new Date().constructor === Date, new Date(0).getTime(), typeof Date(),", " Date.now() < Date.now(), Math.random() !== Math.random(),", "];", ].join("\n"); const want = JSON.stringify([true, true, "function", "1", '{"a":2}', 2, "function", "function", true, true, 0, "string", true, true]); let ok = true; for (const cjs of [false, true]) { const { observed } = await evaluateObserved(code, cjs, ["probe"], { stub: true, permissive: true }); const got = JSON.stringify(observed?.probe); const pass = got === want; console.log(`${pass ? "ok" : "FAIL"} permissive self-test: real intrinsics, stubbed host globals (${cjs ? "cjs" : "esm"})${pass ? "" : `\n want: ${want}\n got : ${got}`}`); ok = ok && pass; } return ok; } // --------------------------------------------------------------------------- // Oxidase cross-check: run every case through oxidase too, as a sanity check // of the judge, not of our engine. Disagreements with ts.transpileModule are // investigated and written up in docs/downlevel-oracle.md. // --------------------------------------------------------------------------- async function loadOxidase() { try { const m = await import(path.join(root, "bench/node_modules/oxidase/wasm_node/oxidase_wasm_bindings.js")); return m.transpile; } catch { return null; } } // --------------------------------------------------------------------------- // Main. // --------------------------------------------------------------------------- function parseDirArgs(argv) { const dirs = []; let grep, baseline, write, minutes = 20; for (let i = 0; i < argv.length; i++) { if (argv[i] === "--dir") dirs.push(argv[++i]); else if (argv[i] === "--grep") grep = new RegExp(argv[++i]); else if (argv[i] === "--baseline") baseline = path.resolve(argv[++i]); else if (argv[i] === "--baseline-write") write = true; else if (argv[i] === "--minutes") minutes = Number(argv[++i]); } return { dirs, grep, baseline, write, minutes }; } async function main() { const arg = process.argv[2]; if (arg === "--self-test") return selfTest(); if (arg === "--judge-paths") return judgePathsMode(process.argv[3], process.argv[4]); if (arg === "--dir") { const { dirs, grep, baseline, write, minutes } = parseDirArgs(process.argv.slice(2)); return dirMode(dirs, { grep, baseline, write, minutes }); } const transpile = await loadOxidase(); let pass = 0; const failures = []; const tally = {}; const categoryTotals = {}; const categoryPass = {}; let oxidaseRun = 0, oxidasePass = 0; const oxidaseDisagreements = []; for (const { name, category, src } of loadCorpus()) { if (arg && arg !== "--self-test" && !name.includes(arg) && !(category ?? "").includes(arg)) continue; categoryTotals[category] = (categoryTotals[category] ?? 0) + 1; const downlevelResult = runEngine(src, "downlevel"); const eraseResult = runEngine(src, "erase"); const judged = judgeCase(src, { downlevelResult, eraseResult }); const problems = Array.isArray(judged) ? judged : judged.problems; if (!Array.isArray(judged) && !judged.skipRuntime) { problems.push(...await judgeRuntime(src, downlevelResult.out, judged.cjs)); } // The "cast" category exists entirely to pin downlevel mode's new legacy-cast erasure // (docs/modes.md, README.md) without touching erase mode's own contract: strip // still reports every legacy `x` cast as unsupported, unchanged. This is the one // place that's asserted directly, since judgeCase only ever uses eraseResult.errors to // compute what downlevel mode is expected to report, never as a standalone assertion on erase mode. if (category === "cast" && eraseResult.errors.length === 0) { problems.push(`erase-mode regression: no error reported for a legacy cast in a "cast"-category case`); } if (problems.length === 0) { pass++; categoryPass[category] = (categoryPass[category] ?? 0) + 1; } else { const kind = problems[0].split(":")[0]; tally[kind] = (tally[kind] ?? 0) + 1; failures.push([name, problems[0]]); } // Skip the "unhandled" category: it exists to pin our engine's own known // gaps (legacy angle casts, as/satisfies precedence), which oxidase erases // just fine, so comparing against it there would be a false disagreement. if (transpile && category !== "unhandled") { oxidaseRun++; try { const oxOut = transpile(src); const oxJudged = judgeCase(src, { downlevelResult: { out: oxOut, errors: [] }, eraseResult }); const oxProblems = [...oxJudged.problems]; // Oxidase doesn't report errors at all through this API, so only compare what we can: runtime + line-preservation. if (!oxJudged.skipRuntime) oxProblems.push(...await judgeRuntime(src, oxOut, oxJudged.cjs)); if (oxProblems.length === 0) oxidasePass++; else oxidaseDisagreements.push([name, oxProblems[0]]); } catch (e) { oxidaseDisagreements.push([name, `oxidase threw: ${e.message}`]); } } } for (const [n, p] of failures.slice(0, Number(process.env.SHOW ?? 20))) console.log(`FAIL ${n}: ${p}`); console.log(`downlevel: ${pass} pass, ${failures.length} fail`, failures.length ? JSON.stringify(tally) : ""); console.log("by category:", JSON.stringify(Object.fromEntries(Object.keys(categoryTotals).map((c) => [c, `${categoryPass[c] ?? 0}/${categoryTotals[c]}`])))); if (transpile) { console.log(`oxidase: ${oxidasePass}/${oxidaseRun} agree with ts.transpileModule`); if (process.env.SHOW_OXIDASE) for (const [n, p] of oxidaseDisagreements.slice(0, 20)) console.log(` oxidase disagrees ${n}: ${p}`); } else { console.log("oxidase: not available, skipped"); } process.exitCode = failures.length ? 1 : 0; } main().catch((e) => { console.error(e); process.exitCode = 1; });