From 487f536a2908ae4bb4c755bd1b57768f502c674c Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 21:28:46 -0500 Subject: [PATCH] Keep int32 fields of classes whose prototypes are named for inspection dyn.nameClassPrototype sets the class name on a class's runtime prototype object and stores no property. The int32 field analysis did not list it as read-only, so every program that gave a class a runtime prototype counted a dynamic store that can name any property, and the number fields of every class reaching dynamic code were stored as doubles. --- packages/compiler/src/ir/int32-slots.test.ts | 4 +- packages/compiler/src/ir/int32-slots.ts | 1 + .../test/int32-dynamic-exposure.test.ts | 84 +++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/int32-dynamic-exposure.test.ts diff --git a/packages/compiler/src/ir/int32-slots.test.ts b/packages/compiler/src/ir/int32-slots.test.ts index 446b3512d..be933ab38 100644 --- a/packages/compiler/src/ir/int32-slots.test.ts +++ b/packages/compiler/src/ir/int32-slots.test.ts @@ -262,7 +262,8 @@ test("dynamic stores disqualify the field names they can reach", () => { expect([readOnly.isField("C", "a"), readOnly.isField("C", "b")]).toEqual([true, true]); // Reflection that only reads the view (Object.keys/values/entries, for-in, // own-key lists, typeof, toString and inspection) stores nothing, nor does - // creating an object from a prototype or reading a prototype. + // creating an object from a prototype, reading a prototype or naming a + // class prototype for inspection. for (const reader of [ "dyn.objKeys", "dyn.objKeyStrings", @@ -282,6 +283,7 @@ test("dynamic stores disqualify the field names they can reach", () => { "insp.dynS", "dyn.objCreate", "dyn.getPrototype", + "dyn.nameClassPrototype", // Registering or removing a process event listener stores nothing either. "process.onSignal", "process.offSignal", diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index 781fa86bd..6ff148c55 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -376,6 +376,7 @@ const DYN_READ_ONLY = new Set([ "dyn.collectionIteratorIndex", "dyn.mapSeedEntries", "dyn.mapSeedEntry", + "dyn.nameClassPrototype", "dyn.numberConstructor", "dyn.objCreate", "dyn.objEntries", diff --git a/packages/compiler/test/int32-dynamic-exposure.test.ts b/packages/compiler/test/int32-dynamic-exposure.test.ts new file mode 100644 index 000000000..09330e057 --- /dev/null +++ b/packages/compiler/test/int32-dynamic-exposure.test.ts @@ -0,0 +1,84 @@ +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"; +import { analyzeInt32Slots, dynamicFieldStores } from "../src/ir/int32-slots.js"; +import type { IrModule } from "../src/ir/ir.js"; + +// Instances of these classes reach checked-dynamic code (an unknown[] array, +// instanceof, inspection, JSON and string conversion), but the program never +// stores a property through a dynamic value. The helpers the compiler emits +// for those reads (prototype objects, inspection names) store nothing, so the +// classes keep their int32 fields. +const source = ` +import { inspect } from "node:util"; + +class Node { + kind: number; + flags = 0; + constructor(kind: number) { + this.kind = kind; + } +} +class Identifier extends Node { + text = "id"; +} +class Type { + constructor( + public flags: number, + public id: number, + ) {} +} + +const values: unknown[] = [new Node(1), new Identifier(2), new Type(4, 7), "s", 3]; +let total = 0; +for (const value of values) { + if (value instanceof Identifier) total += value.kind | value.flags; + else if (value instanceof Node) total += value.kind; + else if (value instanceof Type) total += value.flags & 6; +} +console.log(total, values, inspect(new Type(1, 2))); +console.log(JSON.stringify(values), String(values[0]), typeof values[2]); +`; + +async function lowered(): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-int32-dynamic-")); + try { + const entry = join(dir, "main.ts"); + await writeFile(entry, source); + const outPath = join(dir, "main.ir.json"); + 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")); + return deserializeModule(await readFile(outPath, "utf8")); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test("classes that only reach dynamic reads keep their int32 fields", async () => { + const mod = await lowered(); + // Name the functions whose dynamic helpers count as stores that can name + // any property: such a helper either stores into the value or is missing + // from the analysis's read-only list (DYN_READ_ONLY in int32-slots.ts). + const storing = mod.functions + .filter((fn) => dynamicFieldStores({ ...mod, functions: [fn] }).all) + .map((fn) => fn.name); + expect(storing).toEqual([]); + expect(dynamicFieldStores(mod).names.size).toBe(0); + + const slots = analyzeInt32Slots(mod); + const className = (jsName: string): string => + mod.classes!.find((c) => (c.jsName ?? c.name) === jsName || c.name.endsWith(`.${jsName}`))! + .name; + expect([ + slots.isField(className("Node"), "kind"), + slots.isField(className("Node"), "flags"), + slots.isField(className("Identifier"), "kind"), + slots.isField(className("Type"), "flags"), + slots.isField(className("Type"), "id"), + ]).toEqual([true, true, true, true, true]); + // The program reaches the helper that names class prototypes for inspection. + expect(mod.functions.some((fn) => fn.name.startsWith("%prototype.data."))).toBe(true); +});