Files
scriptc/packages/compiler/test/class-method-mutations.test.ts
T
Chris Tate b91882d684 Use asserted key types in the method replacement census
An element write whose key is asserted to a number, such as
`slots[at as number] = x` or `slots[at!] = x`, was judged by the type of
the key before the assertion. A `number | undefined` key counted as a key
that can name any string, so calls of every method in the program went
through the checked property lookup. The census now reads the type of the
key with its assertions, so these writes only mark methods with numeric
names.

Fixes #1148
Co-authored-by: Alan Leal <33952+alantriesagain@users.noreply.github.com>
2026-10-17 16:15:37 -05:00

90 lines
3.1 KiB
TypeScript

import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile, deserializeModule } from "../src/index.js";
/** Names of the functions whose bodies call `callee`. */
async function callersOf(source: string, callee: string): Promise<string[]> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-method-mutations-"));
try {
const entry = join(dir, "main.ts");
const outPath = join(dir, "main.ir.json");
await writeFile(entry, source);
const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const mod = deserializeModule(await readFile(outPath, "utf8"));
return mod.functions
.filter((fn) => JSON.stringify(fn.body).includes(`"callee":"${callee}"`))
.map((fn) => fn.name);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
const reporter = `
type ErrorCallback = (message: string) => void;
class Scanner {
private onError: ErrorCallback | undefined = undefined;
setOnError(onError: ErrorCallback | undefined): void { this.onError = onError; }
fail(message: string): void { if (this.onError !== undefined) this.onError(message); }
}
class Reporter {
count = 0;
onError(message: string): void { this.count = this.count + message.length; }
}
const reporter = new Reporter();
const scanner = new Scanner();
scanner.setOnError((message) => reporter.onError(message));
scanner.fail("bad");
console.log(reporter.count);
`;
test("a callback field sharing a method's name leaves the method's calls direct", async () => {
expect(await callersOf(reporter, "%dyn.class.properties")).toEqual([]);
});
test("an interface-typed write that can reach a method keeps checked dispatch", async () => {
const relabeled = `${reporter}
interface Errors { onError: (message: string) => void; }
function silence(target: Errors): void { target.onError = () => {}; }
silence(reporter);
reporter.onError("again");
console.log(reporter.count);
`;
expect(await callersOf(relabeled, "%dyn.class.properties")).not.toEqual([]);
});
const counter = `
class Counter {
n = 0;
bump(): void { this.n++; }
}
function clear(slots: (Counter | null)[], positions: Map<string, number>, key: string): void {
const at = positions.get(key);
if (at !== undefined || slots.length > 0) KEY;
}
const counter = new Counter();
clear([counter], new Map([["a", 0]]), "a");
counter.bump();
console.log(counter.n);
`;
test("an asserted number key leaves method calls direct", async () => {
for (const write of [
"slots[at as number] = null",
"slots[at!] = null",
"slots[<number>at] = null",
])
expect(await callersOf(counter.replace("KEY", write), "%dyn.class.properties")).toEqual([]);
});
test("an asserted string key keeps checked dispatch", async () => {
const source = counter.replace(
"KEY",
"(slots as unknown as Record<string, unknown>)[String(at) as string] = null",
);
expect(await callersOf(source, "%dyn.class.properties")).not.toEqual([]);
});