Files
scriptc/tests/harness/static-objects-order.test.ts
T
Chris Tate 352842f020 Scope early errors to function bodies and class strictness, and match the upstream compareArray reads
- A function body or class static block treats its top-level function
  declarations as var-scoped: duplicates are legal in strict code, and a
  let/const/class of the same name is SC0006.
- Strictness is the code around a node: a function declaration's own
  "use strict" no longer makes its declaring context strict.
- Every part of a class is strict; object literal methods and accessors
  are strict only by their own prologue or their surroundings, and an
  arrow function's block body can carry "use strict".
- The bare compareArray reads b.length, then a.length, and a.length again
  before every element pair, converting it as the upstream `i < a.length`
  does, so a string length compares its elements.
- Tests that relied on the old handling of undeclared names now use
  constructs that are still refused or still compile: the installed-entry
  diagnostics fixture reports a syntax error (a read of an undeclared name
  is now Node's run-time ReferenceError), and static-objects-order's
  loops.js stores from its for-in head into `undefined` instead of an
  undeclared name (a store into an undeclared name refuses its package with
  SC1091).
2026-10-29 08:37:25 -05:00

141 lines
6.6 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__`,
* keeps constructors whose methods store from for-in and for-of heads where
* strict mode throws, 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_");
// coerce.js converts an object with a toString method between its
// constructor and the method: the conversion runs before the method exists.
expect(debug).toContain("code runs between the constructor and its methods");
expect(rewrittenIfAny(debug, join(pkg, "coerce.js")) ?? "").not.toContain(
"class __ns_shapes_Box",
);
// loops.js's methods store from for-in and for-of heads into the
// read-only global `undefined` and into a property of a string, which
// throws in a class body.
const loops = rewrittenIfAny(debug, join(pkg, "loops.js")) ?? "";
expect(loops).not.toContain("class __ns_shapes_Bag");
expect(loops).not.toContain("class __ns_shapes_Cell");
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_");
},
);