Files
scriptc/tests/harness/static-objects-order.test.ts
T
2026-10-25 11:02:41 -05:00

128 lines
5.8 KiB
TypeScript

/* Static-object specialization (shape-inference/static-objects.ts): the
* rewrites keep what the program evaluates and when its modules load. With
* the shape overlay on, the npm case static-objects-order (through its
* JavaScript consumer, js-main.cjs) keeps receivers with effects as
* written, keeps reads that may run before what they read exists, binds a
* module where the program loads it and only for reads that run after the
* binding, never adds a hoisted class to an exports literal that runs
* before the class's declaration, does not fold a literal's `__proto__`,
* and prints what Node prints. A module in a require cycle through another
* module is not read through its exports from the cycle. The rewritten
* files are read from the overlay's debug output
* (SCRIPTC_DEBUG_SHAPE_OVERLAY). */
import { execFile } from "node:child_process";
import { mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { expect, test, vi } from "vitest";
import { compile } from "@scriptc/compiler";
const execFileAsync = promisify(execFile);
const repoRoot = join(import.meta.dirname, "../..");
const sanitize = process.env["SCRIPTC_SAN"] === "1";
/** Compiles `entry` with the overlay on; the result and the debug output. */
async function compileWithOverlay(entry: string, name: string) {
const outDir = join(repoRoot, "node_modules/.cache/scriptc-tests", name);
mkdirSync(outDir, { recursive: true });
// The debug output comes from the frontend, which a cached build skips.
const saved = process.env["SCRIPTC_DEBUG_SHAPE_OVERLAY"];
const savedCache = process.env["SCRIPTC_NO_CACHE"];
process.env["SCRIPTC_DEBUG_SHAPE_OVERLAY"] = "1";
process.env["SCRIPTC_NO_CACHE"] = "1";
let debug = "";
const write = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
debug += String(chunk);
return true;
});
try {
const result = await compile(entry, {
outPath: join(outDir, "main"),
outDir,
sanitize,
shapeOverlay: true,
});
return { result, debug };
} finally {
write.mockRestore();
if (saved === undefined) delete process.env["SCRIPTC_DEBUG_SHAPE_OVERLAY"];
else process.env["SCRIPTC_DEBUG_SHAPE_OVERLAY"] = saved;
if (savedCache === undefined) delete process.env["SCRIPTC_NO_CACHE"];
else process.env["SCRIPTC_NO_CACHE"] = savedCache;
}
}
/** The text the rewrites give `file` in the debug output, if any. */
function rewrittenIfAny(debug: string, file: string): string | undefined {
const parts = debug.split(/^\[(?:static-objects|shape-overlay)\] /m);
return parts.find((p) => p.startsWith(`${file}\n`))?.slice(file.length + 1);
}
/** The text the rewrites give `file` in the debug output. */
function rewritten(debug: string, file: string): string {
const text = rewrittenIfAny(debug, file);
expect(text, `no rewrite of ${file}`).toBeDefined();
return text!;
}
test(
"receivers with effects, and exports literals before a class, stay as written",
{ timeout: 600_000 },
async () => {
const entry = join(repoRoot, "tests/fixtures/npm/cases/static-objects-order/js-main.cjs");
const { result, debug } = await compileWithOverlay(entry, "static-objects-order");
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const pkg = join(dirname(entry), "node_modules/static-objects-order");
expect(debug).toContain(
"kept constant read (reads++, ns.Kind).A: the receiver may have effects",
);
expect(debug).toContain(
"kept reference (reads++, ns.util).twice: the receiver may have effects",
);
expect(debug).toContain(
"kept table call (reads++, ns.ops)[op]: the table expression may have effects",
);
// vec.js assigns its exports literal before the constructor function.
// receivers.js reads ns.Late.X and calls ns.later() before they exist.
expect(debug).toContain(
"kept constant read ns.Late.X: it may run before the object is created",
);
expect(debug).toContain("kept reference ns.later: it may run before the member is stored");
// lazy-user.js's first mention of lazy.js is a conditional require: the
// binding follows the statement that loads the module each time it runs.
expect(rewritten(debug, join(pkg, "lazy-user.js"))).toMatch(
/var m = require\("\.\/lazy"\);\nconst __ns_mod_\w+ = require\("\.\/lazy"\);/,
);
// early-user.js calls a function in the statement that loads lazy.js:
// a binding after that statement is not initialized when it runs.
expect(rewrittenIfAny(debug, join(pkg, "early-user.js")) ?? "").not.toContain("__ns_mod_");
const vec = rewritten(debug, join(pkg, "vec.js"));
expect(vec).toContain("class __ns_geo_Vec");
expect(vec).toContain("module.exports = { geo: geo };");
const pending = execFileAsync(result.binaryPath, [], { cwd: dirname(entry), encoding: "utf8" });
pending.child.stdin?.end();
const { stdout } = await pending;
const node = await execFileAsync(process.execPath, [entry], {
cwd: dirname(entry),
encoding: "utf8",
});
expect(stdout).toBe(node.stdout);
},
);
test(
"a module in a require cycle is not read through its exports",
{ timeout: 600_000 },
async () => {
// cycle-pkg's cycle-d.js requires cycle-m.js, which requires cycle-r.js, which reads
// cycle-d's namespace while cycle-d is still loading. scriptc refuses the
// cycle itself (SC1016); the rewrite must not change what cycle-r reads.
const entry = join(repoRoot, "tests/fixtures/static-objects-cycle/main.cjs");
const { debug } = await compileWithOverlay(entry, "static-objects-cycle");
const text = rewritten(debug, join(dirname(entry), "node_modules/cycle-pkg/cycle-r.js"));
expect(text).toContain("typeof d.dd.f");
expect(text).not.toContain("__ns_mod_");
},
);