mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-19 05:29:46 +08:00
* feat: preserve class and child handles through unknown * fix: hide private fields from unknown views
995 lines
34 KiB
TypeScript
995 lines
34 KiB
TypeScript
/* Checked-cast FAILURE behavior — scriptc-only, deliberately NOT in the
|
|
* differential corpus.
|
|
*
|
|
* The testing split for the dynamic boundary:
|
|
* - tests/corpus/1000..1007 cover VALID casts differentially (Node and the
|
|
* native binary agree byte-for-byte — under Node a valid `as` is a no-op).
|
|
* - A LYING cast cannot be tested differentially: Node never checks an `as`
|
|
* (it silently proceeds on mismatched data), while scriptc THROWS — the
|
|
* headline documented divergence (SEMANTICS.md). So mismatch programs are
|
|
* compiled and run here with scriptc ALONE: assert exit code 1 and that
|
|
* stderr carries the TypeError with the offending PATH.
|
|
* - Exact parse-error message strings are asserted in the runtime C tests
|
|
* (packages/runtime/test/test_json.c), not here.
|
|
*
|
|
* SCRIPTC_SAN=1 builds these with ASan + the RC audit, turning every failure
|
|
* path (partially-built records/arrays released mid-validation, dyn values
|
|
* unwinding through catch) into a leak/double-free test.
|
|
*/
|
|
import { execFile } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
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` selects the source lane: "cjs" for the JS lane's per-site checked
|
|
* lowerings (dyn member dispatch exists only there). */
|
|
async function compileAndRun(name: string, source: string, ext: "ts" | "cjs" = "ts", dynamic = false): Promise<RunResult> {
|
|
const key = createHash("sha256")
|
|
.update(source)
|
|
.update(sanitize ? "san" : "plain")
|
|
.update(dynamic ? "dyn" : "")
|
|
.digest("hex")
|
|
.slice(0, 16);
|
|
const outDir = join(cacheDir, `dyncheck-${key}`);
|
|
mkdirSync(outDir, { recursive: true });
|
|
const file = join(outDir, `${name}.${ext}`);
|
|
writeFileSync(file, source);
|
|
// Pinned: the exact TypeError text and path rendering of failed checked
|
|
// casts are C-reference pins; lane identity stays fixed so a diff means
|
|
// the dyn boundary changed, never that the default backend moved.
|
|
const result = await compile(file, { outPath: join(outDir, name), outDir, sanitize, backend: "c", dynamic });
|
|
if (!result.ok) {
|
|
throw new Error(
|
|
"dyncheck 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, 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: e.stderr ?? "", exitCode: e.code };
|
|
}
|
|
}
|
|
|
|
describe(`dynamic-boundary checks (scriptc-only${sanitize ? ", sanitized" : ""})`, () => {
|
|
test("wrong-typed field throws with the path", async () => {
|
|
const r = await compileAndRun(
|
|
"wrong-type",
|
|
`type Config = { host: string; port: number };
|
|
const c = JSON.parse('{"host":"h","port":"eighty"}') as Config;
|
|
console.log("unreachable", c.host);
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stdout).toBe("");
|
|
expect(r.stderr).toContain("Uncaught TypeError: expected number at $.port, got string");
|
|
});
|
|
|
|
test("missing field throws as undefined", async () => {
|
|
const r = await compileAndRun(
|
|
"missing-field",
|
|
`type Config = { host: string; port: number };
|
|
const c = JSON.parse('{"host":"h"}') as Config;
|
|
console.log("unreachable", c.port);
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain("Uncaught TypeError: expected number at $.port, got undefined");
|
|
});
|
|
|
|
test("optional field: a PRESENT wrong-typed key throws with the arms named", async () => {
|
|
// (A MISSING key producing the undefined arm is corpus-tested
|
|
// differentially — 1009-json-optional-fields; this covers the failure
|
|
// wording when the key IS there but fits no arm.)
|
|
const r = await compileAndRun(
|
|
"optional-present-wrong",
|
|
`type Cfg = { host: string; port?: number };
|
|
const c = JSON.parse('{"host":"h","port":"eighty"}') as Cfg;
|
|
console.log("unreachable", c.host);
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain(
|
|
"Uncaught TypeError: expected number | undefined at $.port, got string",
|
|
);
|
|
});
|
|
|
|
test("JSON null matches nothing", async () => {
|
|
const r = await compileAndRun(
|
|
"null-value",
|
|
`const n = JSON.parse("null") as number;
|
|
console.log("unreachable", n);
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain("Uncaught TypeError: expected number at $, got null");
|
|
});
|
|
|
|
test("a plain dynamic object cannot acquire a class brand", async () => {
|
|
const r = await compileAndRun(
|
|
"class-brand",
|
|
`class Point {
|
|
x: number = 0;
|
|
}
|
|
const point = JSON.parse("{}") as Point;
|
|
console.log("unreachable", point.x);
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain("Uncaught TypeError: expected Point at $, got object");
|
|
});
|
|
|
|
test("JSON null misses a null-armed union's OTHER arm with the arms named", async () => {
|
|
// (JSON null MATCHING a null arm is corpus-tested differentially —
|
|
// 1008-json-null-arms; this covers the failure wording.)
|
|
const r = await compileAndRun(
|
|
"null-arm-miss",
|
|
`type Rec = { a: string | null };
|
|
const x = JSON.parse('{"a": 5}') as Rec;
|
|
console.log("unreachable", x.a === null);
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain("Uncaught TypeError: expected null | string at $.a, got number");
|
|
});
|
|
|
|
test("union with no matching arm throws with all arms named", async () => {
|
|
const r = await compileAndRun(
|
|
"union-no-match",
|
|
`type Shape = { kind: "circle"; r: number } | { h: number; kind: "rect"; w: number };
|
|
const s = JSON.parse('{"kind":"circle"}') as Shape; // r missing: matches NO arm
|
|
console.log("unreachable", s.kind);
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain("Uncaught TypeError: expected object | object at $, got object");
|
|
});
|
|
|
|
test("nested paths point at the offending element", async () => {
|
|
const r = await compileAndRun(
|
|
"nested-path",
|
|
`type Deploy = { server: { host: string; ports: number[] } };
|
|
const d = JSON.parse('{"server":{"host":"h","ports":[80,443,"oops"]}}') as Deploy;
|
|
console.log("unreachable", d.server.host);
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain(
|
|
"Uncaught TypeError: expected number at $.server.ports[2], got string",
|
|
);
|
|
});
|
|
|
|
test("RECURSIVE shapes validate depth-first: the path names the deep offender", async () => {
|
|
// The named-recursive-shape walker calls itself per level (JSON input
|
|
// is a tree, so the recursion terminates); a failure three knots down
|
|
// still renders the full path.
|
|
const r = await compileAndRun(
|
|
"recursive-path",
|
|
`interface TreeNode { label: string; children: TreeNode[] }
|
|
const t = JSON.parse('{"label":"r","children":[{"label":"a","children":[{"label":7,"children":[]}]}]}') as TreeNode;
|
|
console.log("unreachable", t.label);
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain(
|
|
"Uncaught TypeError: expected string at $.children[0].children[0].label, got number",
|
|
);
|
|
});
|
|
|
|
test("array where a record is expected names the mismatch at the root", async () => {
|
|
const r = await compileAndRun(
|
|
"root-kind",
|
|
`type Config = { port: number };
|
|
const c = JSON.parse("[1,2,3]") as Config;
|
|
console.log("unreachable", c.port);
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain("Uncaught TypeError: expected object at $, got array");
|
|
});
|
|
|
|
test("a failed check is CATCHABLE and execution recovers", async () => {
|
|
const r = await compileAndRun(
|
|
"catchable",
|
|
`type Config = { port: number };
|
|
function load(raw: string): number {
|
|
try {
|
|
return (JSON.parse(raw) as Config).port;
|
|
} catch {
|
|
return -1;
|
|
}
|
|
}
|
|
console.log(load('{"port":8080}'), load('{"port":"x"}'), load('{"port":1}'));
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toBe("8080 -1 1\n");
|
|
expect(r.stderr).toBe("");
|
|
});
|
|
|
|
test("width tolerance really is check-and-extract: extras dropped, no throw", async () => {
|
|
const r = await compileAndRun(
|
|
"width-tolerant",
|
|
`type Slim = { a: number };
|
|
const s = JSON.parse('{"a":1,"b":{"huge":[1,2,3]},"c":"ignored"}') as Slim;
|
|
console.log(s.a, JSON.stringify(s));
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toBe('1 {"a":1}\n');
|
|
});
|
|
|
|
test("tuple targets validate arity exactly", async () => {
|
|
const r = await compileAndRun(
|
|
"tuple-arity",
|
|
`const rows = JSON.parse('[["a",1],["b",2,3]]') as [string, number][];
|
|
console.log("unreachable", rows.length);
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain("Uncaught TypeError: expected array of length 2 at $[1], got array");
|
|
});
|
|
|
|
test("tuple positions fail with index paths and per-position types", async () => {
|
|
const r = await compileAndRun(
|
|
"tuple-pos",
|
|
`type Span = { range: [number, number] };
|
|
const s = JSON.parse('{"range":[0,"nine"]}') as Span;
|
|
console.log("unreachable", s.range[0]);
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain("Uncaught TypeError: expected number at $.range[1], got string");
|
|
});
|
|
|
|
test("an object where a tuple is expected names the array shape", async () => {
|
|
const r = await compileAndRun(
|
|
"tuple-kind",
|
|
`const t = JSON.parse('{"0":"a","1":1}') as [string, number];
|
|
console.log("unreachable", t[1]);
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain("Uncaught TypeError: expected array at $, got object");
|
|
});
|
|
|
|
test("tuple failure-path RC stress: partial tuples release on unwind", async () => {
|
|
const r = await compileAndRun(
|
|
"tuple-rc-failure",
|
|
`let recovered = 0;
|
|
for (let i = 0; i < 100; i = i + 1) {
|
|
try {
|
|
const rows = JSON.parse('[["ok","fine"],["bad",7]]') as [string, string][];
|
|
recovered = recovered - rows.length;
|
|
} catch {
|
|
recovered = recovered + 1;
|
|
}
|
|
}
|
|
console.log(recovered);
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toBe("100\n");
|
|
});
|
|
|
|
test("failure-path RC stress: repeated mismatches through catch stay clean", async () => {
|
|
// Meaningful mostly under SCRIPTC_SAN=1 (ASan + RC audit at exit): every
|
|
// iteration builds a partial record/array that must release on unwind.
|
|
const r = await compileAndRun(
|
|
"rc-failure-stress",
|
|
`type Entry = { tags: string[]; v: number };
|
|
let recovered = 0;
|
|
for (let i = 0; i < 100; i = i + 1) {
|
|
try {
|
|
const e = JSON.parse('{"tags":["a","b",7],"v":1}') as Entry; // tags[2] wrong
|
|
recovered = recovered - e.v;
|
|
} catch {
|
|
recovered = recovered + 1;
|
|
}
|
|
try {
|
|
const e2 = JSON.parse('{"tags":[],"v":"no"}') as Entry; // v wrong, after tags built
|
|
recovered = recovered - e2.v;
|
|
} catch {
|
|
recovered = recovered + 1;
|
|
}
|
|
}
|
|
console.log(recovered);
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toBe("200\n");
|
|
});
|
|
|
|
test("index-signature shapes CAPTURE extras — and a wrong-typed extra throws with its key path", async () => {
|
|
// Width tolerance becomes width capture on index-signature shapes: the
|
|
// extras must fit the signature's value type. (The capture SUCCESS
|
|
// path is corpus-tested differentially — 909-records-index-json.)
|
|
const r = await compileAndRun(
|
|
"index-capture-wrong",
|
|
`const bag = JSON.parse('{"a":"1","b":7}') as Record<string, string>;
|
|
console.log("unreachable", bag["a"]);
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain("Uncaught TypeError: expected string at $.b, got number");
|
|
});
|
|
|
|
test("a dynamic write colliding with a declared field validates: mismatch throws, field untouched", async () => {
|
|
// JS would store anything under the key; scriptc validates against
|
|
// the declared slot's type instead (SEMANTICS.md divergence) — and the
|
|
// throw is catchable with the field's own type in the message.
|
|
const r = await compileAndRun(
|
|
"index-write-collision",
|
|
`interface P { input?: string; [k: string]: unknown }
|
|
const p: P = { input: "1" };
|
|
const k = "in" + "put";
|
|
try {
|
|
p[k] = 42;
|
|
console.log("unreachable");
|
|
} catch (e) {
|
|
if (e instanceof TypeError) console.log("caught:", e.message);
|
|
}
|
|
console.log("kept:", p.input !== undefined ? p.input : "gone");
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toBe("caught: expected string | undefined at $.input, got number\nkept: 1\n");
|
|
});
|
|
|
|
test("overflow churn through failed collisions stays clean", async () => {
|
|
// Meaningful mostly under SCRIPTC_SAN=1: every iteration hands ownership
|
|
// of a fresh dyn value to the write helper, which must release it on
|
|
// the validation-failure path.
|
|
const r = await compileAndRun(
|
|
"index-write-rc",
|
|
`interface P { host: string; [k: string]: unknown }
|
|
const p: P = { host: "h" };
|
|
const k = "ho" + "st";
|
|
let recovered = 0;
|
|
for (let i = 0; i < 100; i = i + 1) {
|
|
try {
|
|
p[k] = i; // number into the string slot: throws, value released
|
|
} catch {
|
|
recovered = recovered + 1;
|
|
}
|
|
p["ok" + i] = "v" + i; // and a successful overflow insert each round
|
|
}
|
|
console.log(recovered, p.host);
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toBe("100 h\n");
|
|
});
|
|
|
|
/* ── the checked-dynamic FUNCTION boundary ─────────────────────────
|
|
* Happy paths (boxing, exact unwrap identity, adaptation, mustCall
|
|
* end-to-end) are corpus-tested differentially (1650-1654). What CANNOT
|
|
* be tested against Node lives here: JS never checks an `as`, and where
|
|
* Node would coerce a mismatched argument, scriptc throws (SEMANTICS.md
|
|
* 117 — loud, never silently wrong). */
|
|
|
|
test("casting a non-function dyn value to a function type throws with the kind", async () => {
|
|
const r = await compileAndRun(
|
|
"dynfn-cast-nonfn",
|
|
`const u: unknown = 42;
|
|
const f = u as (x: number) => number;
|
|
console.log("unreachable", f(1));
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain("Uncaught TypeError: expected function at $, got number");
|
|
});
|
|
|
|
test("a boxed function's thunk validates arguments per declared param, positionally", async () => {
|
|
const r = await compileAndRun(
|
|
"dynfn-arg-mismatch",
|
|
`function twice(x: number): number { return x * 2; }
|
|
const u: unknown = twice;
|
|
const s = u as (x: string) => unknown;
|
|
s("nope");
|
|
console.log("unreachable");
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain("Uncaught TypeError: expected number at $[0], got string");
|
|
});
|
|
|
|
test("a MISSING argument is undefined and fails a required param's check", async () => {
|
|
// JS arity: the thunk fills undefined; a number param can't hold it.
|
|
const r = await compileAndRun(
|
|
"dynfn-missing-arg",
|
|
`function twice(x: number): number { return x * 2; }
|
|
const u: unknown = twice;
|
|
const zero = u as () => unknown;
|
|
zero();
|
|
console.log("unreachable");
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain("Uncaught TypeError: expected number at $[0], got undefined");
|
|
});
|
|
|
|
test("an adapter validates the RESULT into the target's return type", async () => {
|
|
const r = await compileAndRun(
|
|
"dynfn-result-mismatch",
|
|
`function greet(): string { return "hi"; }
|
|
const u: unknown = greet;
|
|
const asNum = u as () => number;
|
|
console.log("unreachable", asNum());
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain("Uncaught TypeError: expected number at $, got string");
|
|
});
|
|
|
|
test("boundary TypeErrors are catchable and calls keep working after", async () => {
|
|
const r = await compileAndRun(
|
|
"dynfn-recover",
|
|
`function twice(x: number): number { return x * 2; }
|
|
const u: unknown = twice;
|
|
const bad = u as (x: string) => unknown;
|
|
let caught = 0;
|
|
for (let i = 0; i < 50; i = i + 1) {
|
|
try {
|
|
bad("no");
|
|
} catch {
|
|
caught = caught + 1;
|
|
}
|
|
}
|
|
const good = u as (x: number) => number;
|
|
console.log(caught, good(21));
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toBe("50 42\n");
|
|
});
|
|
|
|
test("adapter/box churn stays clean (RC stress across the boundary)", async () => {
|
|
// Meaningful mostly under SCRIPTC_SAN=1: every round boxes a fresh
|
|
// closure, adapts it, calls through, and drops everything.
|
|
const r = await compileAndRun(
|
|
"dynfn-rc",
|
|
`function makeAdder(n: number): (x: number) => number {
|
|
return (x: number) => x + n;
|
|
}
|
|
let total = 0;
|
|
for (let i = 0; i < 100; i = i + 1) {
|
|
const u: unknown = makeAdder(i);
|
|
const f = u as (x: number) => unknown;
|
|
total = total + (f(1) as number);
|
|
}
|
|
console.log(total);
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(0);
|
|
// sum of (1 + i) for i in 0..99
|
|
expect(r.stdout).toBe("5050\n");
|
|
});
|
|
|
|
test("a lying `any` into a typed slot throws catchably where Node proceeds silently", async () => {
|
|
// The static-any lane's exit stance (the checked cast's rule applied
|
|
// to implicit any→typed flows): Node assigns the mismatched value and
|
|
// carries on; scriptc throws the catchable TypeError. Loud, never a
|
|
// silent wrong answer — the divergence is deliberate and documented.
|
|
const r = await compileAndRun(
|
|
"any-lying-exit",
|
|
`const a: any = "not a number";
|
|
try {
|
|
const n: number = a;
|
|
console.log("unreachable", n);
|
|
} catch (e) {
|
|
console.log((e as Error).name);
|
|
}
|
|
console.log("recovered");
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toBe("TypeError\nrecovered\n");
|
|
});
|
|
|
|
test("a lying cast into a union that cannot represent the value throws the stranded trap catchably", async () => {
|
|
// The stranded-source trap (coerceToExpected): a checker-approved
|
|
// value no union arm can hold — a unit smuggled through `null!` / `as
|
|
// any`, or a scalar cast at a record union — compiles to the
|
|
// catchable TypeError where Node lets the impossible value ride
|
|
// (divergence 38's stance; the Node-exact families live in corpus
|
|
// 2253).
|
|
const r = await compileAndRun(
|
|
"union-stranded-trap",
|
|
`type U = { foo: number } | { bar: string };
|
|
try {
|
|
const u: U = null!;
|
|
console.log("unreachable", typeof u);
|
|
} catch (e) {
|
|
console.log((e as Error).name, (e as Error).message.includes("not representable in the target union"));
|
|
}
|
|
try {
|
|
const w: U = 4 as any as U;
|
|
console.log("unreachable", typeof w);
|
|
} catch (e) {
|
|
console.log("scalar:", (e as Error).name);
|
|
}
|
|
console.log("recovered");
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toBe("TypeError true\nscalar: TypeError\nrecovered\n");
|
|
});
|
|
|
|
test("a unit smuggled into a PLAIN non-nullable slot throws the stranded trap catchably", async () => {
|
|
// The stranded-UNIT trap without a union in sight: `null!` / `null as
|
|
// any as T` into a plain typed slot (string, array, class, function).
|
|
// Node lets the impossible value ride until it is used; the flow
|
|
// throws here instead (divergence 38's stance extended — SEMANTICS.md
|
|
// 335).
|
|
const r = await compileAndRun(
|
|
"plain-stranded-unit-trap",
|
|
`try {
|
|
const s: string = null!;
|
|
console.log("unreachable", s.length);
|
|
} catch (e) {
|
|
console.log((e as Error).name, (e as Error).message.includes("not representable in a 'string' slot"));
|
|
}
|
|
try {
|
|
const a: number[] = undefined!;
|
|
console.log("unreachable", a.length);
|
|
} catch (e) {
|
|
console.log("array:", (e as Error).name);
|
|
}
|
|
try {
|
|
const f: () => number = null!;
|
|
console.log("unreachable", f());
|
|
} catch (e) {
|
|
console.log("func:", (e as Error).name);
|
|
}
|
|
console.log("recovered");
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toBe("TypeError true\narray: TypeError\nfunc: TypeError\nrecovered\n");
|
|
});
|
|
|
|
test("a checker-approved function mismatch strands per piece: mismatched params compile at the flow, void results trap after the call", async () => {
|
|
// funcCoerceAdapter's stranded dispositions. Param strand: an array
|
|
// literal of arrows collapses to one element signature — the
|
|
// mismatched arm compiles at the FLOW and could only throw if
|
|
// invoked (the collapsed union types the call's parameter 'never', so
|
|
// honest code cannot invoke it — narrowingUnionToUnion's shape).
|
|
// Result strand: a 'never' thrower behind a typed-result slot — the
|
|
// call runs, the throw wins before the stranded-result trap could
|
|
// fire, so the flow is exact.
|
|
const r = await compileAndRun(
|
|
"func-stranded-adapter",
|
|
`const cases = [
|
|
(v: string) => { console.log("s", v.length); },
|
|
(v: number) => { console.log("n", v + 1); },
|
|
];
|
|
console.log("assigned", cases.length);
|
|
function fail(msg: string): never { throw new Error(msg); }
|
|
const h: (s: string) => number[] = fail;
|
|
try {
|
|
h("boom");
|
|
} catch (e) {
|
|
console.log("never:", (e as Error).message);
|
|
}
|
|
console.log("recovered");
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toBe("assigned 2\nnever: boom\nrecovered\n");
|
|
});
|
|
|
|
test("a NAMED record into an `any` slot is a deep copy — dyn writes never alias the original", async () => {
|
|
// dynFrom's aliasing stance (documented for 'unknown' slots) applies
|
|
// to any-typed storage identically: JS would alias, the dyn copies.
|
|
const r = await compileAndRun(
|
|
"any-record-copy",
|
|
`const base = { a: 1 };
|
|
const boxed: any = base;
|
|
boxed.a = 2;
|
|
console.log(base.a, boxed.a);
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toBe("1 2\n");
|
|
});
|
|
|
|
test("an uninitialized `any` binding is the dyn undefined, not a trap", async () => {
|
|
// The undefined-init rule: dyn slots are never NULL. (Node-agreeing
|
|
// reads are corpus-tested — 2040; this pins the non-trap guarantee on
|
|
// the module-global path with a captured reader.)
|
|
const r = await compileAndRun(
|
|
"any-uninit-global",
|
|
`let cell: any;
|
|
function read(): any {
|
|
return cell;
|
|
}
|
|
console.log(typeof read(), read() === undefined);
|
|
cell = 3;
|
|
console.log(typeof read(), read() === undefined);
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toBe("undefined true\nnumber false\n");
|
|
});
|
|
});
|
|
|
|
describe(`native-handle boundary fences (scriptc-only${sanitize ? ", sanitized" : ""})`, () => {
|
|
// Handles cross the checked-dynamic boundary as SCR_DYN_HANDLE boxes;
|
|
// the SUCCESS surface is corpus-tested differentially (1795-1798).
|
|
// These pin the honesty ladder — texts Node never prints, because
|
|
// under Node the members exist (the loud fence IS the divergence).
|
|
|
|
const serverPreamble = `'use strict';
|
|
const http = require('http');
|
|
function wrap(fn) {
|
|
return function() {
|
|
return fn.apply(this, arguments);
|
|
};
|
|
}
|
|
const server = http.createServer();
|
|
`;
|
|
const listenAndHit = `
|
|
server.listen(0, '127.0.0.1', wrap(function() {
|
|
http.get({ host: '127.0.0.1', port: server.address().port, path: '/' }, wrap(function(res) {}));
|
|
}));
|
|
`;
|
|
|
|
test("a real-but-unmodeled member on a dyn handle throws the loud ladder", async () => {
|
|
// writeContinue stays unmodeled (cork/uncork/flushHeaders graduated
|
|
// to real dispatches — the ladder's exemplar moves with the surface).
|
|
const r = await compileAndRun(
|
|
"handle-unmodeled-member",
|
|
serverPreamble +
|
|
`server.on('request', wrap(function(req, res) {
|
|
res.writeContinue();
|
|
}));
|
|
` + listenAndHit,
|
|
"cjs",
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain(
|
|
"'ServerResponse.prototype.writeContinue' on a dynamic value is not supported yet",
|
|
);
|
|
});
|
|
|
|
test("an expando write on a dyn handle throws the loud ladder (identity would break)", async () => {
|
|
const r = await compileAndRun(
|
|
"handle-expando-write",
|
|
serverPreamble +
|
|
`server.on('request', wrap(function(req, res) {
|
|
req.custom = 1;
|
|
}));
|
|
` + listenAndHit,
|
|
"cjs",
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain("setting 'custom' on a dynamic IncomingMessage is not supported yet");
|
|
});
|
|
|
|
test("an unmodeled event registration on a dyn handle throws the loud ladder", async () => {
|
|
const r = await compileAndRun(
|
|
"handle-unmodeled-event",
|
|
serverPreamble +
|
|
`server.on('request', wrap(function(req, res) {
|
|
req.on('nonsense', wrap(function() {}));
|
|
}));
|
|
` + listenAndHit,
|
|
"cjs",
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain(
|
|
"listening for 'nonsense' on a dynamic IncomingMessage is not supported yet",
|
|
);
|
|
});
|
|
|
|
test("a handle-targeted dynCheck failure names the class", async () => {
|
|
const r = await compileAndRun(
|
|
"handle-wrong-target",
|
|
`import type { IncomingMessage } from "node:http";
|
|
const u: unknown = "not a request";
|
|
const req = u as IncomingMessage;
|
|
console.log("unreachable", req.url);
|
|
`,
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain("Uncaught TypeError: expected IncomingMessage at $, got string");
|
|
});
|
|
|
|
test("a non-function listener on a dyn handle throws Node's ERR_INVALID_ARG_TYPE", async () => {
|
|
const r = await compileAndRun(
|
|
"handle-bad-listener",
|
|
serverPreamble +
|
|
`server.on('request', wrap(function(req, res) {
|
|
try {
|
|
req.on('data', 5);
|
|
} catch (e) {
|
|
console.log('caught: ' + String(e));
|
|
}
|
|
res.end('done');
|
|
server.close();
|
|
}));
|
|
` + listenAndHit,
|
|
"cjs",
|
|
);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toContain(
|
|
'caught: TypeError [ERR_INVALID_ARG_TYPE]: The "listener" argument must be of type function. Received type number (5)',
|
|
);
|
|
});
|
|
|
|
/* ── the jsval→dyn crossing's honesty ladder (SCR_DYN_JSVAL) ────────
|
|
* The armed rows (typeof/truthiness/String()/===, the narrowing tests,
|
|
* the identity round trip; the routed-ops lane's keyed read/write,
|
|
* calls, method dispatch, Object statics, JSON.stringify) are
|
|
* corpus-tested differentially (2578, 2579, 2582-2585, npm
|
|
* jsval-into-dyn). These pin the LOUD fences of every dyn walk still
|
|
* un-armed over an island-held value — the retired fence box answered
|
|
* typeof "function" and .length 0 SILENTLY here; a wrong answer is
|
|
* never acceptable, a named refusal is. All --dynamic. */
|
|
|
|
// A JS-lane `any` producer: JSDoc-`any` returns are ISLAND values under
|
|
// --dynamic (a bare record literal would infer a typed record and take
|
|
// the deep-copy path instead; a local initialized FROM an island value
|
|
// also stays island-world by the ratified runtime-world dispatch — the
|
|
// crossing needs a real dyn slot: a param, a literal member, a return).
|
|
const wrapPreamble = `const eng: any = { a: 1, list: [1, 2, 3] };
|
|
const u: unknown = eng;
|
|
`;
|
|
|
|
test("a keyed read on an island-held unknown routes to the engine (the retired .length fence)", async () => {
|
|
const r = await compileAndRun(
|
|
"jsval-key-read",
|
|
`/** @returns {any} */
|
|
function mint() { return { a: 1, list: [1, 2, 3] }; }
|
|
const eng = mint();
|
|
/** @param {object} bag */
|
|
function probe(bag) { console.log(bag.list.length, bag.a, bag.missing === undefined); }
|
|
probe(eng);
|
|
`,
|
|
"cjs",
|
|
true,
|
|
);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toBe("3 1 true\n");
|
|
});
|
|
|
|
test("the collapsed (string | object)[] slot compiles and the element read routes", async () => {
|
|
// Appendix-A repro 4's exit, one lane further along: the union-arm
|
|
// SC2009 fence is RETIRED — `(string | object)[]` maps to the
|
|
// checked-dynamic representation wholesale (every arm dyn-subsumable),
|
|
// the island plugins array WRAPS at the slot (dynFromJsval), and the
|
|
// element read rides the routed keyed-read arm: the engine answers,
|
|
// the scalar normalizes, and typeof prints Node's answer.
|
|
const r = await compileAndRun(
|
|
"jsval-collapsed-union-slot",
|
|
`const eng: any = ["p1", { languages: ["js"] }];
|
|
function firstPlugin(ps: (string | object)[]): void {
|
|
console.log(typeof ps[0]);
|
|
}
|
|
firstPlugin(eng);
|
|
`,
|
|
"ts",
|
|
true,
|
|
);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toBe("string\n");
|
|
});
|
|
|
|
test("a method call on an island-held unknown runs the engine's own prototype", async () => {
|
|
// scr_dyn_invoke's JSVAL arm routes to scr_jsval_call_method — the
|
|
// ENGINE's Array.prototype.slice runs (JS-exact), and the result
|
|
// wraps back for further routed reads.
|
|
const r = await compileAndRun(
|
|
"jsval-method",
|
|
`/** @returns {any} */
|
|
function mint() { return [{ languages: ["js"] }]; }
|
|
const eng = mint();
|
|
/** @param {object} plugins */
|
|
function walk(plugins) { return plugins.slice(); }
|
|
console.log(\`\${walk(eng).length}\`);
|
|
`,
|
|
"cjs",
|
|
true,
|
|
);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toBe("1\n");
|
|
});
|
|
|
|
test("JSON.stringify of an island-held unknown is the engine's own stringify", async () => {
|
|
const r = await compileAndRun(
|
|
"jsval-stringify",
|
|
wrapPreamble + `console.log(JSON.stringify(u));
|
|
`,
|
|
"ts",
|
|
true,
|
|
);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toBe('{"a":1,"list":[1,2,3]}\n');
|
|
});
|
|
|
|
test("structuredClone of an island-held unknown fences loudly", async () => {
|
|
const r = await compileAndRun(
|
|
"jsval-clone",
|
|
wrapPreamble + `const c = structuredClone(u);
|
|
console.log("unreachable", typeof c);
|
|
`,
|
|
"ts",
|
|
true,
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain(
|
|
"structuredClone on an island value held in 'unknown' is not supported yet",
|
|
);
|
|
});
|
|
|
|
test("Object.keys over an island-held unknown walks the engine object", async () => {
|
|
const r = await compileAndRun(
|
|
"jsval-keys",
|
|
`/** @returns {any} */
|
|
function mint() { return { a: 1, b: 2 }; }
|
|
const eng = mint();
|
|
/** @param {object} bag */
|
|
function keys(bag) { return Object.keys(bag); }
|
|
console.log(keys(eng).length, keys(eng).join(","));
|
|
`,
|
|
"cjs",
|
|
true,
|
|
);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toBe("2 a,b\n");
|
|
});
|
|
|
|
test("deepStrictEqual over island-held values: identity passes, structure fences", async () => {
|
|
const r = await compileAndRun(
|
|
"jsval-deepeq",
|
|
`import assert from "node:assert";
|
|
const eng: any = { a: 1 };
|
|
const other: any = { a: 1 };
|
|
const u1: unknown = eng;
|
|
const u2: unknown = eng;
|
|
assert.deepStrictEqual(u1, u2); // the same engine value: honest true
|
|
console.log("identity ok");
|
|
const u3: unknown = other;
|
|
assert.deepStrictEqual(u1, u3); // distinct values: the structural walk fences
|
|
console.log("unreachable");
|
|
`,
|
|
"ts",
|
|
true,
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stdout).toContain("identity ok");
|
|
expect(r.stderr).toContain(
|
|
"deepStrictEqual on an island value held in 'unknown' is not supported yet",
|
|
);
|
|
});
|
|
|
|
test("util.inspect (console.log) of an island-held unknown fences with the engine typeof", async () => {
|
|
const r = await compileAndRun(
|
|
"jsval-inspect",
|
|
wrapPreamble + `console.log(u);
|
|
`,
|
|
"ts",
|
|
true,
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain(
|
|
"util.inspect of a composite 'any' value (typeof 'object') is not supported yet",
|
|
);
|
|
});
|
|
|
|
test("destructuring and for-of over an island-held unknown drain the engine's own iterator", async () => {
|
|
// The iteration arm (lane dom-jsval-long-tail): dyn.iterPack's JSVAL
|
|
// arm drains the ENGINE's iterator protocol — the wrapped engine
|
|
// array destructures and iterates with Node's answers, and a
|
|
// non-iterable engine value throws V8's for-of spelling instead of
|
|
// the retired island fence.
|
|
const r = await compileAndRun(
|
|
"jsval-iterate",
|
|
`/** @returns {any} */
|
|
function mint() { return { list: [10, 20], num: 7 }; }
|
|
const eng = mint();
|
|
/** @param {object} bag */
|
|
function walk(bag) {
|
|
const [first] = bag.list;
|
|
console.log(\`first \${first}\`);
|
|
let sum = 0;
|
|
for (const x of bag.list) sum += x;
|
|
console.log(\`sum \${sum}\`);
|
|
try {
|
|
for (const x of bag.num) console.log(x);
|
|
} catch (err) {
|
|
console.log(\`caught: \${err.message}\`);
|
|
}
|
|
}
|
|
walk(eng);
|
|
`,
|
|
"cjs",
|
|
true,
|
|
);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toBe("first 10\nsum 30\ncaught: bag.num is not iterable\n");
|
|
});
|
|
|
|
test("a keyed write through an island-held unknown lands on the real engine object", async () => {
|
|
// Aliasing preserved: the island-side reader sees the write made
|
|
// through the wrapped alias (the retired setting-fence row).
|
|
const r = await compileAndRun(
|
|
"jsval-key-write",
|
|
`/** @returns {any} */
|
|
function mint() { return { a: 1 }; }
|
|
const eng = mint();
|
|
/** @param {object} bag */
|
|
function poke(bag) { bag.a = 2; bag.fresh = "x"; }
|
|
poke(eng);
|
|
console.log(\`\${eng.a} \${eng.fresh}\`);
|
|
`,
|
|
"cjs",
|
|
true,
|
|
);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toBe("2 x\n");
|
|
});
|
|
|
|
test("'in' over an island-held unknown fences (never a silent false)", async () => {
|
|
const r = await compileAndRun(
|
|
"jsval-in",
|
|
`/** @returns {any} */
|
|
function mint() { return { a: 1 }; }
|
|
const eng = mint();
|
|
/** @param {object} bag */
|
|
function has(bag) { return "a" in bag; }
|
|
console.log(has(eng));
|
|
`,
|
|
"cjs",
|
|
true,
|
|
);
|
|
expect(r.exitCode).toBe(1);
|
|
expect(r.stderr).toContain("'in' on an island value held in 'unknown' is not supported yet");
|
|
});
|
|
|
|
test("a wrapped island value RC-balances through catch-and-continue fences", async () => {
|
|
// The fence throws are catchable; the wrapped cell must release
|
|
// cleanly through the unwind (the SAN lane's RC audit is the check).
|
|
// The crossing rides a real dyn PARAM slot — a local initialized from
|
|
// an island value stays island-world by the runtime-world dispatch.
|
|
const r = await compileAndRun(
|
|
"jsval-fence-unwind",
|
|
`const eng: any = { a: 1 };
|
|
let caught = 0;
|
|
function probe(u: unknown): void {
|
|
try {
|
|
structuredClone(u);
|
|
} catch (e) {
|
|
if (e instanceof Error) caught++;
|
|
}
|
|
}
|
|
for (let i = 0; i < 100; i++) probe(eng);
|
|
console.log("done", caught);
|
|
`,
|
|
"ts",
|
|
true,
|
|
);
|
|
expect(r.exitCode).toBe(0);
|
|
expect(r.stdout).toBe("done 100\n");
|
|
});
|
|
});
|