mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-22 05:59:31 +08:00
`x as Sub` lowered to a call to an interned %class.assert helper that unwrapped a union box, retained the payload twice, compared the class-id range, and threw on mismatch; every use paid a call, three retains, two releases and a pending check (2,152 sites in tsc-ts). Plain operands (locals and field reads, which isSafeToRepeat already admits) now lower to the check itself: per compatible union arm a tag test plus, where the arm is not already the target or a subclass, an instanceOf range test, selecting the downcast payload, with the helper's exact TypeError as the final arm. Other operands keep the helper so they are evaluated once. The backend borrows through the result: instanceOf reads its operand as a receiver instead of retaining a temporary, and downcast/upcast are borrowable, edge-preserving projections, so `(node as Sub).field` and method receivers on a cast no longer touch reference counts. The mismatch arm unwinds directly (previous commit) and stays out of the hot path. Corpus 4432 pins the Node-observable behavior of failing casts (a TypeError before any later read, unwinding through finally blocks and callbacks) and of successful casts from bases, subclasses and unions; a harness test pins the exact messages, which Node cannot oracle.
797 lines
28 KiB
TypeScript
797 lines
28 KiB
TypeScript
/* Error-object behavior Node cannot oracle — scriptc-only.
|
||
*
|
||
* The differential corpus (1300–1306) covers everything observable in
|
||
* lockstep with Node: construction/fields/toString, subclasses, typed-catch
|
||
* narrowing, async rejections, runtime failures caught as instances. What
|
||
* lives here is the uncaught STDERR contract: Node prints a source excerpt
|
||
* and a stack trace (and its display shows the CONSTRUCTOR's name where it
|
||
* differs from .name); scriptc prints one `Uncaught <toString>` line —
|
||
* SEMANTICS.md divergence 11. These pins keep that line's shape exact.
|
||
*
|
||
* SCRIPTC_SAN=1 builds these with ASan + the RC audit like every harness lane.
|
||
*/
|
||
import { execFile } from "node:child_process";
|
||
import { createHash } from "node:crypto";
|
||
import { mkdirSync, writeFileSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { join } from "node:path";
|
||
import { promisify } from "node:util";
|
||
import { describe, expect, test } from "vitest";
|
||
import { compile } from "@scriptc/compiler";
|
||
|
||
const execFileAsync = promisify(execFile);
|
||
const repoRoot = join(import.meta.dirname, "../..");
|
||
const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests");
|
||
const sanitize = process.env["SCRIPTC_SAN"] === "1";
|
||
|
||
interface RunResult {
|
||
stdout: string;
|
||
stderr: string;
|
||
exitCode: number;
|
||
}
|
||
|
||
/** Compiles an inline program and runs the binary, tolerating nonzero exit.
|
||
* `ext` picks the frontend lane — "cjs" for JS-only shapes (deferred
|
||
* fences that tsc's arity families would reject in TS). `extraFiles`
|
||
* materialize beside the entry (multi-module shapes: the CJS link-error
|
||
* pins import sibling .cjs modules). */
|
||
async function compileAndRun(
|
||
name: string,
|
||
source: string,
|
||
ext = "ts",
|
||
extraFiles: Record<string, string> = {},
|
||
): Promise<RunResult> {
|
||
const key = createHash("sha256")
|
||
.update(source)
|
||
.update(JSON.stringify(extraFiles))
|
||
.update(sanitize ? "san" : "plain")
|
||
.digest("hex")
|
||
.slice(0, 16);
|
||
// Multi-module programs live OUTSIDE node_modules: the cache dir sits
|
||
// under it, where the resolver classifies sibling modules as npm files
|
||
// (correctly — for real packages) and the relative import stops
|
||
// resolving into the program.
|
||
const outDir =
|
||
Object.keys(extraFiles).length > 0
|
||
? join(tmpdir(), `scriptc-errors-${key}`)
|
||
: join(cacheDir, `errors-${key}`);
|
||
mkdirSync(outDir, { recursive: true });
|
||
const file = join(outDir, `${name}.${ext}`);
|
||
writeFileSync(file, source);
|
||
for (const [extraName, text] of Object.entries(extraFiles)) {
|
||
writeFileSync(join(outDir, extraName), text);
|
||
}
|
||
// `// @dynamic` on the entry's FIRST line embeds the island engine —
|
||
// for runtime-fence shapes only mixed dynamic graphs can spell (the
|
||
// diagnostics suite's directive, applied to the run-and-observe lane).
|
||
const dynamic = /^\/\/ @dynamic\s*$/.test(source.split("\n", 1)[0] ?? "");
|
||
const result = await compile(file, {
|
||
outPath: join(outDir, name),
|
||
outDir,
|
||
sanitize,
|
||
backend: "llvm",
|
||
dynamic,
|
||
});
|
||
if (!result.ok) {
|
||
throw new Error(
|
||
"errors program failed to compile:\n" +
|
||
result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"),
|
||
);
|
||
}
|
||
try {
|
||
const { stdout, stderr } = await execFileAsync(result.binaryPath, [], { encoding: "utf8" });
|
||
return { stdout, stderr: stripAsanFiberWarning(stderr), exitCode: 0 };
|
||
} catch (err) {
|
||
const e = err as { code?: unknown; stdout?: string; stderr?: string };
|
||
if (typeof e.code !== "number") throw err;
|
||
return {
|
||
stdout: e.stdout ?? "",
|
||
stderr: stripAsanFiberWarning(e.stderr ?? ""),
|
||
exitCode: e.code,
|
||
};
|
||
}
|
||
}
|
||
|
||
/** Linux ASan prints a once-per-process warning to stderr the first time a
|
||
* fiber swapcontext()s (the interceptor has no off switch; Apple's ASan
|
||
* never intercepts ucontext, so the macOS lanes never see it). Sanitizer
|
||
* diagnostic noise, never program output — dropped before any stderr
|
||
* expectation, the RC-audit-skip-notice pattern from differential.test.ts. */
|
||
function stripAsanFiberWarning(s: string): string {
|
||
return s.replace(
|
||
/^==\d+==WARNING: ASan doesn't fully support makecontext\/swapcontext.*\n/gm,
|
||
"",
|
||
);
|
||
}
|
||
|
||
describe(`uncaught error objects (scriptc-only${sanitize ? ", sanitized" : ""})`, () => {
|
||
test("an uncaught builtin error prints Uncaught name: message", async () => {
|
||
const r = await compileAndRun(
|
||
"uncaught-builtin",
|
||
`console.log("before");
|
||
throw new TypeError("boom");
|
||
`,
|
||
);
|
||
expect(r.exitCode).toBe(1);
|
||
expect(r.stdout).toBe("before\n");
|
||
expect(r.stderr).toBe("Uncaught TypeError: boom\n");
|
||
});
|
||
|
||
test("a subclass prints its name PROPERTY — 'Error' when never assigned", async () => {
|
||
// Node's uncaught display would show the constructor name (PlainSub)
|
||
// even though .name is "Error"; we print the property — divergence 11.
|
||
const r = await compileAndRun(
|
||
"uncaught-subclass",
|
||
`class PlainSub extends Error {}
|
||
throw new PlainSub("unnamed sub");
|
||
`,
|
||
);
|
||
expect(r.exitCode).toBe(1);
|
||
expect(r.stderr).toBe("Uncaught Error: unnamed sub\n");
|
||
});
|
||
|
||
test("toString's empty-half rules shape the line", async () => {
|
||
const empty = await compileAndRun("uncaught-empty", `throw new Error();\n`);
|
||
expect(empty.exitCode).toBe(1);
|
||
expect(empty.stderr).toBe("Uncaught Error\n");
|
||
|
||
const nameless = await compileAndRun(
|
||
"uncaught-nameless",
|
||
`const e = new Error("just the message");
|
||
e.name = "";
|
||
throw e;
|
||
`,
|
||
);
|
||
expect(nameless.exitCode).toBe(1);
|
||
expect(nameless.stderr).toBe("Uncaught just the message\n");
|
||
});
|
||
|
||
test("an uncaught checked-dynamic primitive prints its value, like Node", async () => {
|
||
// Node prints the thrown primitive itself (after its source excerpt);
|
||
// the value must never be lost behind a placeholder.
|
||
const text = await compileAndRun(
|
||
"uncaught-unknown-string",
|
||
`console.log("before");
|
||
const v: unknown = process.argv.length > 99 ? 0 : "plain";
|
||
throw v;
|
||
`,
|
||
);
|
||
expect(text.exitCode).toBe(1);
|
||
expect(text.stdout).toBe("before\n");
|
||
expect(text.stderr).toBe("Uncaught plain\n");
|
||
|
||
const num = await compileAndRun(
|
||
"uncaught-unknown-number",
|
||
`const v: unknown = process.argv.length > 99 ? "x" : 42;
|
||
throw v;
|
||
`,
|
||
);
|
||
expect(num.exitCode).toBe(1);
|
||
expect(num.stderr).toBe("Uncaught 42\n");
|
||
});
|
||
|
||
test("an unhandled ERROR rejection renders name: message too", async () => {
|
||
const r = await compileAndRun(
|
||
"unhandled-error-rejection",
|
||
`async function fail(): Promise<void> {
|
||
throw new RangeError("nobody awaits this");
|
||
}
|
||
fail();
|
||
console.log("sync done");
|
||
`,
|
||
);
|
||
expect(r.exitCode).toBe(1);
|
||
expect(r.stdout).toBe("sync done\n");
|
||
expect(r.stderr).toBe("Unhandled promise rejection: RangeError: nobody awaits this\n");
|
||
});
|
||
});
|
||
|
||
// `x as Sub` on a class value is a CHECKED downcast, and `x!` / `u as Arm`
|
||
// on a union value is a checked extraction: a stored value outside the
|
||
// target throws a catchable TypeError where Node's erasure would let the
|
||
// value flow on. The type and message are scriptc's contract (corpus 4432
|
||
// pins the Node-observable part: the failure is a TypeError raised before
|
||
// any later read). Plain operands are tested inline at the use and call
|
||
// operands through the interned helper; both must fail identically.
|
||
describe("checked class downcasts and extractions", () => {
|
||
test("failed downcasts throw the TypeError naming the target class", async () => {
|
||
const r = await compileAndRun(
|
||
"checked-downcast-messages",
|
||
`class Base {
|
||
kind = 0;
|
||
}
|
||
class Sub extends Base {
|
||
text = "sub";
|
||
}
|
||
class Other {
|
||
tag = "other";
|
||
}
|
||
function direct(b: Base): string {
|
||
return (b as Sub).text;
|
||
}
|
||
function optional(b: Base | undefined): string {
|
||
return (b as Sub).text;
|
||
}
|
||
function mixed(v: Base | Other): string {
|
||
return (v as Sub).text;
|
||
}
|
||
function make(b: Base | undefined): Base | undefined {
|
||
return b;
|
||
}
|
||
function called(b: Base | undefined): string {
|
||
return (make(b) as Sub).text;
|
||
}
|
||
function asserted(b: Base | undefined): number {
|
||
return b!.kind;
|
||
}
|
||
function assertedCall(b: Base | undefined): number {
|
||
return make(b)!.kind;
|
||
}
|
||
const runs: Array<() => unknown> = [
|
||
() => direct(new Sub()),
|
||
() => direct(new Base()),
|
||
() => optional(undefined),
|
||
() => optional(new Base()),
|
||
() => mixed(new Other()),
|
||
() => called(new Base()),
|
||
() => called(undefined),
|
||
() => asserted(new Base()),
|
||
() => asserted(undefined),
|
||
() => assertedCall(undefined),
|
||
];
|
||
for (const run of runs) {
|
||
try {
|
||
console.log("ok", run());
|
||
} catch (e) {
|
||
console.log(e instanceof TypeError, String(e));
|
||
}
|
||
}
|
||
direct(new Base());
|
||
`,
|
||
);
|
||
expect(r.stdout).toBe(
|
||
[
|
||
"ok sub",
|
||
"true TypeError: Value is not an instance of 'Sub'",
|
||
"true TypeError: Value is not an instance of 'Sub'",
|
||
"true TypeError: Value is not an instance of 'Sub'",
|
||
"true TypeError: Value is not an instance of 'Sub'",
|
||
"true TypeError: Value is not an instance of 'Sub'",
|
||
"true TypeError: Value is not an instance of 'Sub'",
|
||
"ok 0",
|
||
"true TypeError: undefined is not representable in the target union (a value narrowed or asserted past it still held it)",
|
||
"true TypeError: undefined is not representable in the target union (a value narrowed or asserted past it still held it)",
|
||
"",
|
||
].join("\n"),
|
||
);
|
||
expect(r.stderr).toBe("Uncaught TypeError: Value is not an instance of 'Sub'\n");
|
||
expect(r.exitCode).toBe(1);
|
||
});
|
||
});
|
||
|
||
// `e as C` on a catch binding is a CHECKED cast: a payload outside C's
|
||
// hierarchy throws a catchable TypeError where Node's erasure would read
|
||
// undefined off the value — dynCheck's documented trust-but-verify stance
|
||
// extended to exception payloads. This is exactly the case that cannot be
|
||
// a differential test; pinned so the divergence stays deliberate.
|
||
describe("checked catch-binding casts", () => {
|
||
test("a mismatched `as Error` throws the catchable TypeError", async () => {
|
||
const r = await compileAndRun(
|
||
"caught-check-mismatch",
|
||
`try {
|
||
throw "stringy";
|
||
} catch (err) {
|
||
try {
|
||
console.log((err as Error).message);
|
||
} catch (inner) {
|
||
if (inner instanceof TypeError) console.log("checked:", inner.message);
|
||
}
|
||
}
|
||
`,
|
||
);
|
||
expect(r.exitCode).toBe(0);
|
||
expect(r.stdout).toBe("checked: caught value is not an instance of Error (checked cast)\n");
|
||
expect(r.stderr).toBe("");
|
||
});
|
||
|
||
test("an uncaught mismatch propagates as the TypeError", async () => {
|
||
const r = await compileAndRun(
|
||
"caught-check-uncaught",
|
||
`try {
|
||
throw 42;
|
||
} catch (err) {
|
||
console.log((err as Error).message);
|
||
}
|
||
`,
|
||
);
|
||
expect(r.exitCode).toBe(1);
|
||
expect(r.stderr).toBe(
|
||
"Uncaught TypeError: caught value is not an instance of Error (checked cast)\n",
|
||
);
|
||
});
|
||
|
||
test("an EFFECTFUL surplus argument is a named deferred fence (JS lane)", async () => {
|
||
// Effect-free surplus args drop at compile time, JS-exact (corpus
|
||
// 1760 — the test-string-decoder writeSequences shape). An effectful
|
||
// one has no evaluation slot in the completed call, so instead of
|
||
// silently not running it the build defers a named SC1090 to the
|
||
// call site. JS-only: tsc's arity errors gate the TS lane.
|
||
const r = await compileAndRun(
|
||
"surplus-args-effectful",
|
||
`'use strict';
|
||
function two(a, b) {
|
||
return a + b;
|
||
}
|
||
function loud() {
|
||
console.log('side effect JS would run');
|
||
return 0;
|
||
}
|
||
console.log(two(1, 2, loud()));
|
||
`,
|
||
"cjs",
|
||
);
|
||
expect(r.exitCode).toBe(1);
|
||
expect(r.stdout).toBe("");
|
||
expect(r.stderr).toMatch(
|
||
/^Uncaught Error: surplus arguments with side effects .* are not supported yet \[SC1090 at .*surplus-args-effectful\.cjs:9\]\n$/,
|
||
);
|
||
});
|
||
|
||
test.each([
|
||
{
|
||
label: "Array.from",
|
||
name: "array-from-length-elements",
|
||
source: `const arr = Array.from({ length: 2 }, () => new Date(0));`,
|
||
},
|
||
{
|
||
label: "flatMap",
|
||
name: "flatmap-elements",
|
||
source: `const arr = [1].flatMap(() => new Date(0));`,
|
||
},
|
||
{
|
||
label: "tuple map",
|
||
name: "tuple-map-elements",
|
||
source: `/** @type {[number]} */
|
||
const tuple = [1];
|
||
const arr = tuple.map(() => new Date(0));`,
|
||
},
|
||
])("$label preserves checked callback results (JS lane)", async ({ name, source }) => {
|
||
const program = source + "\nconsole.log(arr.length, arr[0].getTime());\n";
|
||
const r = await compileAndRun(name, program, "js");
|
||
const reference = await execFileAsync(process.execPath, ["--eval", program], {
|
||
encoding: "utf8",
|
||
});
|
||
expect(r).toEqual({ stdout: reference.stdout, stderr: reference.stderr, exitCode: 0 });
|
||
});
|
||
|
||
test("extending a property-assigned class ABOVE its assignment is a named deferred fence (JS lane)", async () => {
|
||
// The property-assigned class-expression family (corpus 2032/2033
|
||
// pins what lowers): `exports.O = class extends exports.I {}` with
|
||
// the base assigned BELOW would extend undefined in Node (TypeError
|
||
// at the derived statement) — the source-order guard defers a named
|
||
// fence instead of pinning a base that doesn't exist yet.
|
||
const r = await compileAndRun(
|
||
"extends-above-assignment",
|
||
`'use strict';
|
||
exports.O = class extends exports.I {};
|
||
exports.I = class {};
|
||
console.log('unreached');
|
||
`,
|
||
"cjs",
|
||
);
|
||
expect(r.exitCode).toBe(1);
|
||
expect(r.stdout).toBe("");
|
||
expect(r.stderr).toMatch(
|
||
/^Uncaught Error: extending 'exports\.I' above the statement that assigns it .* \[SC1090 at .*extends-above-assignment\.cjs:2\]\n$/,
|
||
);
|
||
});
|
||
|
||
test("a REASSIGNED property-assigned class stays fenced, never a silently wrong base (JS lane)", async () => {
|
||
// Two `exports.I = class …` assignments make the binding dynamic (the
|
||
// runtime base is whichever ran last). The second assignment's
|
||
// class-value flow fence fires first (nominal classval slots), and the
|
||
// extends resolver refuses to pin either class — the program fences
|
||
// instead of extending the first assignment's class.
|
||
const r = await compileAndRun(
|
||
"extends-reassigned-property",
|
||
`'use strict';
|
||
exports.I = class { a() { return 1; } };
|
||
exports.I = class { a() { return 2; } };
|
||
exports.O = class extends exports.I {};
|
||
console.log(new exports.O().a());
|
||
`,
|
||
"cjs",
|
||
);
|
||
expect(r.exitCode).toBe(1);
|
||
expect(r.stdout).toBe("");
|
||
expect(r.stderr).toMatch(
|
||
/^Uncaught Error: .* \[SC1090 at .*extends-reassigned-property\.cjs:\d+\]\n$/,
|
||
);
|
||
});
|
||
|
||
test("a non-callable constructor field shadows its method and throws at the call (JS lane)", async () => {
|
||
const r = await compileAndRun(
|
||
"leaf-shadowing-class-defers",
|
||
`'use strict';
|
||
console.log("before");
|
||
class X {
|
||
constructor() {
|
||
this.m = "not callable";
|
||
}
|
||
m() {
|
||
console.log("method");
|
||
}
|
||
}
|
||
const x = new X();
|
||
x.m();
|
||
console.log("unreachable");
|
||
`,
|
||
"cjs",
|
||
);
|
||
expect(r.exitCode).toBe(1);
|
||
expect(r.stdout).toBe("before\n");
|
||
expect(r.stderr).toBe("Uncaught TypeError: x.m is not a function\n");
|
||
});
|
||
|
||
test("a callable constructor field shadows an inherited method (JS lane)", async () => {
|
||
const r = await compileAndRun(
|
||
"derived-shadowing-class-defers",
|
||
`'use strict';
|
||
console.log("before");
|
||
class A {
|
||
foo() {
|
||
return 4;
|
||
}
|
||
}
|
||
class B extends A {
|
||
constructor() {
|
||
super();
|
||
this.foo = () => 3;
|
||
}
|
||
}
|
||
const i = new B();
|
||
console.log(i.foo());
|
||
`,
|
||
"cjs",
|
||
);
|
||
expect(r.exitCode).toBe(0);
|
||
expect(r.stdout).toBe("before\n3\n");
|
||
expect(r.stderr).toBe("");
|
||
});
|
||
|
||
test("destructuring a SURFACED builtin global's member is a named fence at the pattern (JS lane)", async () => {
|
||
// `const { max } = Math` would detach a member whose lowering is
|
||
// keyed to its receiver (Math.max) — the pattern fences by member
|
||
// and global name instead of binding a token whose calls fail late.
|
||
// Corpus 2558 pins the OPAQUE globals, where members DO bind tokens.
|
||
const r = await compileAndRun(
|
||
"destructure-surfaced-global",
|
||
`'use strict';
|
||
const { max } = Math;
|
||
console.log(max(1, 2));
|
||
`,
|
||
"cjs",
|
||
);
|
||
expect(r.exitCode).toBe(1);
|
||
expect(r.stdout).toBe("");
|
||
expect(r.stderr).toMatch(
|
||
/^Uncaught Error: destructuring the member 'max' of the builtin global 'Math' \(a detached member loses its receiver-keyed lowering — call it through the global\) is not supported yet \[SC1031 at .*destructure-surfaced-global\.cjs:2\]\n$/,
|
||
);
|
||
});
|
||
|
||
test("an OPAQUE global's destructured member binds a token; uses fence lazily per site (JS lane)", async () => {
|
||
// The node-suite webcrypto prologue: `const { subtle } =
|
||
// globalThis.crypto` binds the member identity token, so the program
|
||
// RUNS past the destructure (the eager pattern fence used to kill it
|
||
// at that line) and each use meets its own named fence.
|
||
const r = await compileAndRun(
|
||
"destructure-opaque-global",
|
||
`'use strict';
|
||
const { subtle } = globalThis.crypto;
|
||
console.log('bound', !!subtle);
|
||
subtle.digest('SHA-256');
|
||
`,
|
||
"cjs",
|
||
);
|
||
expect(r.exitCode).toBe(1);
|
||
expect(r.stdout).toBe("bound true\n");
|
||
expect(r.stderr).toMatch(/^Uncaught TypeError: subtle\.digest is not a function\n$/);
|
||
});
|
||
|
||
test("a 7000-level expression is a named deferred fence, not a stack overflow (JS lane)", async () => {
|
||
// The binderBinaryExpressionStress depth: an AST deep enough that the
|
||
// PREFLIGHT walks (the checker facade's prefetch sweep, the require
|
||
// scans) used to overflow the JS stack as a RangeError ICE before the
|
||
// expression lowering's 200-level SC1090 fence could fire. Every
|
||
// whole-file walk is iterative now (walkPreorder), so the JS lane
|
||
// compiles and defers the named fence to the statement — the TS lane's
|
||
// compile-time twin is the deep-nesting-preflight diagnostics fixture.
|
||
// Parens carry the depth (a real 6500-term chain spends minutes in
|
||
// per-node type queries; the walk that used to crash is shape-blind).
|
||
const r = await compileAndRun(
|
||
"deep-nesting-chain",
|
||
`'use strict';
|
||
let x = ${"(".repeat(7000)}1${")".repeat(7000)};
|
||
console.log('after');
|
||
`,
|
||
"cjs",
|
||
);
|
||
expect(r.exitCode).toBe(1);
|
||
expect(r.stdout).toBe("");
|
||
expect(r.stderr).toMatch(
|
||
/^Uncaught Error: expressions nested deeper than 200 levels are not supported yet \[SC1090 at .*deep-nesting-chain\.cjs:2\]\n$/,
|
||
);
|
||
});
|
||
|
||
test("process.on with a prototype-member event name fences by NAME", async () => {
|
||
// '__proto__' used to fall into the compiler's own signal table —
|
||
// `{ SIGINT: 2 }["__proto__"]` answered Object.prototype, which rode
|
||
// a numLit into the C as `[object Object]` (invalid C, cc.js death).
|
||
// The own() lookup makes it what every other unsupported process
|
||
// event is: a named deferred fence. Corpus 1761 pins the EMITTER
|
||
// side, where these names work and match Node byte-exactly.
|
||
const r = await compileAndRun(
|
||
"process-on-proto",
|
||
`'use strict';
|
||
process.on('__proto__', () => {});
|
||
`,
|
||
"cjs",
|
||
);
|
||
expect(r.exitCode).toBe(1);
|
||
expect(r.stdout).toBe("");
|
||
expect(r.stderr).toMatch(
|
||
/^Uncaught Error: 'process\.on\("__proto__", \.\.\.\)' is part of the standard library types but has no scriptc lowering yet \[SC2020 at .*process-on-proto\.cjs:2\]\n$/,
|
||
);
|
||
});
|
||
});
|
||
|
||
// Node's CJS named-import LINK error: a named import of an export the CJS
|
||
// lexer cannot detect kills the graph before ANY module evaluates, and
|
||
// scriptc compiles the program to exactly that startup throw. The corpus
|
||
// (1613/1616/1618/1619) pins stdout+exit differentially; what lives here
|
||
// is the MESSAGE — Node's exact SyntaxError text, both flavors, hint
|
||
// construction included — which the exit-1 stdout-only contract cannot see.
|
||
describe("CJS named-import link SyntaxError messages", () => {
|
||
test("an entry-level import gets Node's CommonJS hint form, ` as ` rewritten", async () => {
|
||
const r = await compileAndRun(
|
||
"cjs-link-flavored",
|
||
`import { a as z, vis } from './table.cjs';
|
||
console.log('never runs', z, vis);
|
||
`,
|
||
"mjs",
|
||
{ "table.cjs": "'use strict';\nconst v = 5;\nmodule.exports = { vis: v, a: 7 };\n" },
|
||
);
|
||
expect(r.exitCode).toBe(1);
|
||
expect(r.stdout).toBe("");
|
||
expect(r.stderr).toBe(
|
||
"Uncaught SyntaxError: Named export 'a' not found. The requested module './table.cjs' is a CommonJS module, which may not support all module.exports as named exports.\n" +
|
||
"CommonJS modules can always be imported via the default export, for example using:\n" +
|
||
"\n" +
|
||
"import pkg from './table.cjs';\n" +
|
||
"const { a: z, vis } = pkg;\n" +
|
||
"\n",
|
||
);
|
||
});
|
||
|
||
test("a failure below the entry keeps V8's generic wording", async () => {
|
||
// module_job.js rewrites the message only when the failing specifier
|
||
// is one of the ROOT module's own requests — mid.mjs's is not.
|
||
const r = await compileAndRun(
|
||
"cjs-link-generic",
|
||
`import { a } from './mid.mjs';
|
||
console.log('never runs', a);
|
||
`,
|
||
"mjs",
|
||
{
|
||
"mid.mjs": "export { a } from './table.cjs';\n",
|
||
"table.cjs": "'use strict';\nmodule.exports = { a: 7 };\n",
|
||
},
|
||
);
|
||
expect(r.exitCode).toBe(1);
|
||
expect(r.stdout).toBe("");
|
||
expect(r.stderr).toBe(
|
||
"Uncaught SyntaxError: The requested module './table.cjs' does not provide an export named 'a'\n",
|
||
);
|
||
});
|
||
});
|
||
|
||
// Node's strip-only TypeScript loader keeps ordinary named imports in the
|
||
// ESM request graph. If the requested declaration is type-only, Node refuses
|
||
// during linking even though the TypeScript checker accepts the import. The
|
||
// corpus pins stdout and exit; these assertions pin scriptc's exact uncaught
|
||
// SyntaxError message and the local-name ordering used to choose the first
|
||
// failure.
|
||
describe("native ESM type-import link SyntaxError messages", () => {
|
||
test("an unqualified interface import fails before any module evaluates", async () => {
|
||
const r = await compileAndRun(
|
||
"esm-type-link",
|
||
`import { value, Shape as MissingShape } from './types.ts';
|
||
console.log('never runs', value);
|
||
`,
|
||
"ts",
|
||
{
|
||
"types.ts": `export interface Shape { value: number }
|
||
export const value = 7;
|
||
console.log('types evaluated');
|
||
`,
|
||
},
|
||
);
|
||
expect(r.exitCode).toBe(1);
|
||
expect(r.stdout).toBe("");
|
||
expect(r.stderr).toBe(
|
||
"Uncaught SyntaxError: The requested module './types.ts' does not provide an export named 'Shape'\n",
|
||
);
|
||
});
|
||
|
||
test("a child re-export failure wins before the parent import is linked", async () => {
|
||
const r = await compileAndRun(
|
||
"esm-type-link-reexport",
|
||
`import { Zed as zed, Aa as aaa, value } from './middle.ts';
|
||
console.log('never runs', value);
|
||
`,
|
||
"ts",
|
||
{
|
||
"middle.ts": `export { Shape as Zed, Other as Aa, value } from './types.ts';
|
||
console.log('middle evaluated');
|
||
`,
|
||
"types.ts": `export interface Shape { value: number }
|
||
export type Other = string;
|
||
export const value = 9;
|
||
console.log('types evaluated');
|
||
`,
|
||
},
|
||
);
|
||
expect(r.exitCode).toBe(1);
|
||
expect(r.stdout).toBe("");
|
||
expect(r.stderr).toBe(
|
||
"Uncaught SyntaxError: The requested module './types.ts' does not provide an export named 'Shape'\n",
|
||
);
|
||
});
|
||
|
||
test("direct imports choose the first missing binding by local name", async () => {
|
||
const r = await compileAndRun(
|
||
"esm-type-link-order",
|
||
`import { Shape as zed, Other as aaa } from './types.ts';
|
||
console.log('never runs');
|
||
`,
|
||
"ts",
|
||
{
|
||
"types.ts": `export interface Shape { value: number }
|
||
export type Other = string;
|
||
`,
|
||
},
|
||
);
|
||
expect(r.exitCode).toBe(1);
|
||
expect(r.stdout).toBe("");
|
||
expect(r.stderr).toBe(
|
||
"Uncaught SyntaxError: The requested module './types.ts' does not provide an export named 'Other'\n",
|
||
);
|
||
});
|
||
|
||
test("dynamic islands retain the native ESM link failure", async () => {
|
||
const r = await compileAndRun(
|
||
"esm-type-link-dynamic",
|
||
`// @dynamic
|
||
import { Shape as MissingShape } from './types.ts';
|
||
console.log('never runs');
|
||
`,
|
||
"ts",
|
||
{ "types.ts": "export interface Shape { value: number }\nconsole.log('types evaluated');\n" },
|
||
);
|
||
expect(r.exitCode).toBe(1);
|
||
expect(r.stdout).toBe("");
|
||
expect(r.stderr).toBe(
|
||
"Uncaught SyntaxError: The requested module './types.ts' does not provide an export named 'Shape'\n",
|
||
);
|
||
});
|
||
|
||
test("default interface exports satisfy Node's strip-only default link", async () => {
|
||
const r = await compileAndRun(
|
||
"esm-default-interface-link",
|
||
`import DefaultShape from './types.ts';
|
||
type LocalShape = DefaultShape;
|
||
console.log('ok');
|
||
`,
|
||
"ts",
|
||
{ "types.ts": "export default interface DefaultShape { value: number }\n" },
|
||
);
|
||
expect(r.exitCode).toBe(0);
|
||
expect(r.stdout).toBe("ok\n");
|
||
expect(r.stderr).toBe("");
|
||
});
|
||
|
||
test("explicit module packages do not expose default-interface placeholders", async () => {
|
||
const r = await compileAndRun(
|
||
"esm-default-interface-explicit-module",
|
||
`import DefaultShape from './types.ts';
|
||
type LocalShape = DefaultShape;
|
||
console.log('never runs');
|
||
`,
|
||
"ts",
|
||
{
|
||
"package.json": `{ "type": "module" }\n`,
|
||
"types.ts": "export default interface DefaultShape { value: number }\n",
|
||
},
|
||
);
|
||
expect(r.exitCode).toBe(1);
|
||
expect(r.stdout).toBe("");
|
||
expect(r.stderr).toBe(
|
||
"Uncaught SyntaxError: The requested module './types.ts' does not provide an export named 'default'\n",
|
||
);
|
||
});
|
||
|
||
test("typeless packages preserve default-interface placeholders through re-exports", async () => {
|
||
const r = await compileAndRun(
|
||
"esm-default-interface-reexport",
|
||
`import DefaultShape from './reexport.ts';
|
||
type LocalShape = DefaultShape;
|
||
console.log('ok');
|
||
`,
|
||
"ts",
|
||
{
|
||
"reexport.ts": `export { default } from './types.ts';\n`,
|
||
"types.ts": "export default interface DefaultShape { value: number }\n",
|
||
},
|
||
);
|
||
expect(r.exitCode).toBe(0);
|
||
expect(r.stdout).toBe("ok\n");
|
||
expect(r.stderr).toBe("");
|
||
});
|
||
|
||
test("a child native-ESM failure wins over a later parent CJS failure", async () => {
|
||
const r = await compileAndRun(
|
||
"esm-type-link-before-cjs",
|
||
`import './middle.ts';
|
||
import { missing } from './table.cjs';
|
||
console.log('never runs', missing);
|
||
`,
|
||
"ts",
|
||
{
|
||
"middle.ts": `import { Shape } from './types.ts';
|
||
console.log('middle never runs');
|
||
`,
|
||
"types.ts": "export interface Shape { value: number }\nconsole.log('types evaluated');\n",
|
||
"table.cjs": "module.exports = { missing: 7 };\n",
|
||
},
|
||
);
|
||
expect(r.exitCode).toBe(1);
|
||
expect(r.stdout).toBe("");
|
||
expect(r.stderr).toBe(
|
||
"Uncaught SyntaxError: The requested module './types.ts' does not provide an export named 'Shape'\n",
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("checked-dynamic/island boundary fences (scriptc-only)", () => {
|
||
test("an island-typed argument into a call through 'unknown' runs Node-exactly (the retired SC1101 fence)", async () => {
|
||
// `this` in a plain JS function is the checked-dynamic ambient
|
||
// receiver; a member CALL through it with an 'any'-typed argument
|
||
// used to fence at compile (no jsval→dyn crossing existed — SEMANTICS
|
||
// 383(d)'s "one unbridgeable mix"). The crossing exists now
|
||
// (dynFromJsval, SEMANTICS 394): the argument wraps by reference, the
|
||
// statement compiles for real, and the unbound-`this` member read
|
||
// throws Node's OWN TypeError — the divergence this pin used to own
|
||
// is retired.
|
||
const r = await compileAndRun(
|
||
"island-arg-dyn-call",
|
||
`// @dynamic
|
||
'use strict';
|
||
function register(item) {
|
||
try {
|
||
this.registry(item);
|
||
return "unreachable";
|
||
} catch (e) {
|
||
return "caught: " + e.message;
|
||
}
|
||
}
|
||
console.log(\`\${register(7)}\`);
|
||
`,
|
||
"mjs",
|
||
{ "tsconfig.json": '{"compilerOptions":{"strict":true,"noImplicitAny":false}}\n' },
|
||
);
|
||
expect(r.exitCode).toBe(0);
|
||
expect(r.stdout).toBe("caught: Cannot read properties of undefined (reading 'registry')\n");
|
||
expect(r.stderr).toBe("");
|
||
});
|
||
});
|