diff --git a/packages/compiler/src/frontend/lowering/class-callbacks.ts b/packages/compiler/src/frontend/lowering/class-callbacks.ts index b51158022..0bda0068e 100644 --- a/packages/compiler/src/frontend/lowering/class-callbacks.ts +++ b/packages/compiler/src/frontend/lowering/class-callbacks.ts @@ -164,6 +164,7 @@ export function classCallbackCall( fallback: IrExpr, loc: SrcLoc, calleeName = name, + original?: { originalId: () => string | undefined; directCall: () => IrExpr }, ): IrExpr { const bag = lowerer.declareHiddenLocal("%callbackBag", DYN); const present = lowerer.declareHiddenLocal("%callbackPresent", BOOL); @@ -235,6 +236,44 @@ export function classCallbackCall( answer = fallback.type.kind === "void" ? result : lowerer.coerceToExpected(result, fallback.type); } + // Read after observedSlot built the prototype data and its originals. + const originalId = observed ? original?.originalId() : undefined; + if (original && originalId !== undefined) { + // The slot still holds the method's original value: calling it is + // calling the method, so the call stays native (no boxed arguments, + // dynamic call or checked result). A replacement takes the dynamic call. + const unchanged: IrExpr = { + kind: "libCall", + fn: "dyn.sameValue", + args: [varRef(callback.id, DYN, loc), varRef(originalId, DYN, loc)], + type: BOOL, + loc, + }; + answer = + fallback.type.kind === "void" + ? { + kind: "ternary", + cond: unchanged, + then: { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: original.directCall(), loc }], + result: dynUndefinedExpr(loc), + type: DYN, + loc, + }, + else_: call, + type: DYN, + loc, + } + : { + kind: "ternary", + cond: unchanged, + then: original.directCall(), + else_: answer, + type: answer.type, + loc, + }; + } return { kind: "seqExpr", stmts: [ diff --git a/packages/compiler/src/frontend/lowering/class-prototypes.ts b/packages/compiler/src/frontend/lowering/class-prototypes.ts index 2a05cf977..3ecdd84aa 100644 --- a/packages/compiler/src/frontend/lowering/class-prototypes.ts +++ b/packages/compiler/src/frontend/lowering/class-prototypes.ts @@ -207,12 +207,21 @@ export function classPrototypeData( // promise-returning signature) gets no data slot. const boxedValue = lowerer.coerceToExpected(compiled, DYN); if (boxedValue.type.kind !== "dyn") continue; + const originalId = `%g.${info.def.prototypeDataHelper}.original.${method}`; + lowerer.globalsList.push({ + id: originalId, + name: `${info.def.jsName ?? info.def.name}.prototype.${method}.original`, + type: DYN, + mutable: true, + }); + info.prototypeOriginals ??= new Map(); + info.prototypeOriginals.set(method, originalId); const descriptor: IrExpr = { kind: "dynObjLit", fields: [ { key: { kind: "strLit", value: "value", type: STRING, loc }, - value: boxedValue, + value: varRef(originalId, DYN, loc), }, { key: { kind: "strLit", value: "writable", type: STRING, loc }, @@ -227,6 +236,8 @@ export function classPrototypeData( loc, }; const init = helper.body[0]!; + if (init.kind === "if") + init.then.push({ kind: "assign", localId: originalId, value: boxedValue, loc }); if (init.kind === "if") init.then.push({ kind: "exprStmt", diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 14d94ad24..493d979f5 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -10195,6 +10195,20 @@ export function lowerObjectMethodCall( type: found.sig.ret, loc, }; + // The declaring class's original value, built with the observed slot. + // An override below (a virtual call) keeps the dynamic call: a slot + // reassigned to an ancestor's method must not reach the override. So do + // methods declared in unchecked JavaScript, whose results are inferred. + const declarerFile = found.declarer.decl ? ts.getSourceFileOfNode(found.declarer.decl) : null; + const original = + virtual || + !declarerFile || + (isJsSourceFile(declarerFile) && !lowerer.checkedJsFile(declarerFile)) + ? undefined + : { + originalId: (): string | undefined => found.declarer.prototypeOriginals?.get(method), + directCall: (): IrExpr => fallback, + }; const result = classCallbackCall( lowerer, value, @@ -10203,6 +10217,7 @@ export function lowerObjectMethodCall( fallback, loc, ts.getTextOfNode(access), + original, ); return { kind: "seqExpr", diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 6dd0124bd..29afbb4fe 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -478,6 +478,10 @@ export interface ClassInfo { prototypeMethodValues?: Map; prototypeReflectionReady?: true; prototypeObservedMethodsReady?: true; + /** The globals holding each observed method's original prototype value + * (set when the prototype data is built), by method name. A typed call + * whose slot still holds that value calls the method natively. */ + prototypeOriginals?: Map; prototypeReflectionRefusal?: string; localClass?: { context: FnCtx | null; diff --git a/packages/compiler/test/observed-method-guard.test.ts b/packages/compiler/test/observed-method-guard.test.ts new file mode 100644 index 000000000..5964c482b --- /dev/null +++ b/packages/compiler/test/observed-method-guard.test.ts @@ -0,0 +1,77 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { compile, deserializeModule, validateModule } from "../src/index.js"; + +const dirs: string[] = []; + +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +type IrRecord = Record; + +function recordsOf(value: unknown, out: IrRecord[] = []): IrRecord[] { + if (value === null || typeof value !== "object") return out; + if (Array.isArray(value)) { + for (const item of value) recordsOf(item, out); + return out; + } + const record = value as IrRecord; + out.push(record); + for (const child of Object.values(record)) recordsOf(child, out); + return out; +} + +// An untyped computed write puts every method name in the census, so typed +// calls read the observable slot. While the slot holds the declared method, +// the call is the native method call, not a dynamic call of the slot. +test("typed calls of an observed method call it natively while its slot is unchanged", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-observed-guard-")); + dirs.push(dir); + const entry = join(dir, "main.ts"); + const outDir = join(dir, ".scriptc"); + const outPath = join(outDir, "main.ir.json"); + await writeFile( + join(dir, "untyped.js"), + "export function put(target, key, value) { target[key] = value; }\n", + ); + await writeFile( + entry, + [ + 'import { put } from "./untyped.js";', + "class Repo {", + " items: number[] = [];", + " total(): number { let s = 0; for (const n of this.items) s += n; return s; }", + "}", + "const repo = new Repo();", + "repo.items.push(1, 2);", + "put({}, String(Math.random()), 1);", + "export function sum(r: Repo): number { return r.total(); }", + "console.log(sum(repo));", + "", + ].join("\n"), + ); + const result = await compile(entry, { outDir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const module = deserializeModule(await readFile(outPath, "utf8")); + expect(validateModule(module)).toEqual([]); + const sum = module.functions.find((fn) => fn.name.endsWith("sum")); + expect(sum).toBeDefined(); + const records = recordsOf(sum); + const guards = records.filter( + (record) => + record.kind === "ternary" && + (record.cond as IrRecord | undefined)?.kind === "libCall" && + (record.cond as IrRecord).fn === "dyn.sameValue", + ); + expect(guards).toHaveLength(1); + const then = guards[0]!.then as IrRecord; + expect(then.kind).toBe("call"); + expect(String(then.callee)).toMatch(/Repo\.total$/); + expect(module.globals?.some((global) => global.name === "Repo.prototype.total.original")).toBe( + true, + ); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7365-class-methods-untyped-computed-writes/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7365-class-methods-untyped-computed-writes/main.ts.json new file mode 100644 index 000000000..58cdacb62 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7365-class-methods-untyped-computed-writes/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/7365-class-methods-untyped-computed-writes/untyped.js", + "/tests/corpus/7365-class-methods-untyped-computed-writes/main.ts" + ], + "diags": [] +} diff --git a/tests/corpus/7365-class-methods-untyped-computed-writes/main.ts b/tests/corpus/7365-class-methods-untyped-computed-writes/main.ts new file mode 100644 index 000000000..f7449f1af --- /dev/null +++ b/tests/corpus/7365-class-methods-untyped-computed-writes/main.ts @@ -0,0 +1,59 @@ +// Typed classes next to untyped computed writes. Calls through a typed +// receiver keep the method while its prototype slot holds the declared +// method, and observe a replacement made by untyped code (on the prototype +// or as an own property of one instance) exactly as Node does. +import { patch, tally, unpatch } from "./untyped.js"; + +class Repository { + private items = new Map(); + add(name: string, cents: number): number { + this.items.set(name, cents); + return this.items.size; + } + list(): string[] { + return [...this.items.keys()]; + } + total(): number { + let sum = 0; + for (const cents of this.items.values()) sum += cents; + return sum; + } +} + +const words: string[] = ["list", "add", "list", "total"]; +console.log(JSON.stringify(tally(words))); + +const repo = new Repository(); +repo.add("keyboard", 12900); +repo.add("mouse", 4900); +let sum = 0; +for (let i = 0; i < 1000; i++) sum += repo.total(); +console.log(repo.list().join(","), sum); + +// untyped code replaces the method on the prototype +const proto: unknown = Object.getPrototypeOf(repo); +patch(proto, "total", function (this: Repository): number { + return -1; +}); +console.log(repo.total(), new Repository().total()); +unpatch(proto, "total"); +try { + repo.total(); +} catch (error) { + console.log("after delete:", error instanceof TypeError); +} + +// an own replacement on one instance shadows the prototype for that instance only +const other = new Repository(); +other.add("cable", 900); +patch(other, "list", () => ["shadowed"]); +console.log(other.list().join(","), repo.list().join(",")); +unpatch(other, "list"); +console.log(other.list().join(",")); + +// restoring the original value through the prototype brings back the method +const saved: unknown = (proto as Record)["list"]; +patch(proto, "list", () => ["patched"]); +console.log(repo.list().join(",")); +patch(proto, "list", saved); +console.log(repo.list().join(",")); diff --git a/tests/corpus/7365-class-methods-untyped-computed-writes/untyped.js b/tests/corpus/7365-class-methods-untyped-computed-writes/untyped.js new file mode 100644 index 000000000..3c0d20189 --- /dev/null +++ b/tests/corpus/7365-class-methods-untyped-computed-writes/untyped.js @@ -0,0 +1,15 @@ +// Untyped helpers with computed writes whose keys TypeScript cannot know. +// They put every class method name in play for the method census. +export function tally(words) { + const counts = {}; + for (const word of words) counts[word] = (counts[word] || 0) + 1; + return counts; +} + +export function patch(target, name, impl) { + target[name] = impl; +} + +export function unpatch(target, name) { + delete target[name]; +} diff --git a/tests/harness/corpus-costs/7365-class-methods-untyped-computed-writes/main.ts.json b/tests/harness/corpus-costs/7365-class-methods-untyped-computed-writes/main.ts.json new file mode 100644 index 000000000..0347bbd22 --- /dev/null +++ b/tests/harness/corpus-costs/7365-class-methods-untyped-computed-writes/main.ts.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 3400 }