From 484fc2cb13ad4bf75d3997e01c1dfe04c1b70828 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 12:30:10 -0700 Subject: [PATCH 1/7] Fold members stored after their namespace was created; skip the receiver read in JavaScript functions that never use this - static-objects: a primitive member stored once by an unconditional top-level statement of the object's own file (app.math.EPSILON = 1e-9) reads as its literal where the read provably runs after the store: inside functions when the statements before the store run no code, and in later top-level statements of that file. SCRIPTC_SHAPE_FOLD_STORES=0 opts out. - JavaScript functions read the ambient receiver at entry only when this occurs in their parameters or body outside nested non-arrow functions. - npm case static-stores pins both, with the overlay on and off. nsbench (n=20000, overlay on): 64.4 -> 36 ms. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-functions.ts | 28 ++++- .../shape-inference/static-objects.ts | 106 +++++++++++++++++- .../npm/cases/static-stores/main.ts.json | 6 + .../npm/cases/static-stores/js-main.cjs | 8 ++ .../fixtures/npm/cases/static-stores/main.ts | 9 ++ .../node_modules/static-stores/index.d.ts | 10 ++ .../node_modules/static-stores/index.js | 47 ++++++++ .../node_modules/static-stores/package.json | 1 + .../npm/cases/static-stores/package.json | 1 + tests/harness/static-stores.test.ts | 46 ++++++++ 10 files changed, 257 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/static-stores/main.ts.json create mode 100644 tests/fixtures/npm/cases/static-stores/js-main.cjs create mode 100644 tests/fixtures/npm/cases/static-stores/main.ts create mode 100644 tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.d.ts create mode 100644 tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.js create mode 100644 tests/fixtures/npm/cases/static-stores/node_modules/static-stores/package.json create mode 100644 tests/fixtures/npm/cases/static-stores/package.json create mode 100644 tests/harness/static-stores.test.ts diff --git a/packages/compiler/src/frontend/lowering/lower-functions.ts b/packages/compiler/src/frontend/lowering/lower-functions.ts index c1cca9c64d..7b5e9aa46b 100644 --- a/packages/compiler/src/frontend/lowering/lower-functions.ts +++ b/packages/compiler/src/frontend/lowering/lower-functions.ts @@ -61,6 +61,29 @@ function declareCallReceiver( }; } +/** Whether a JavaScript function's own `this` can be read: a `this` in its + * parameters or body, outside nested non-arrow functions (which bind their + * own). Conservative inside classes and object literal methods. A function + * that never reads it skips the ambient receiver read at entry. */ +function readsOwnThis(node: ts.FunctionLikeDeclaration): boolean { + let found = false; + const visit = (n: ts.Node): void => { + if (found) return; + if (n.kind === ts.SyntaxKind.ThisKeyword) { + found = true; + return; + } + if (ts.isFunctionDeclaration(n) || ts.isFunctionExpression(n)) return; + ts.forEachChild(n, (c) => { + visit(c); + return undefined; + }); + }; + for (const p of node.parameters.nodes) visit(p); + if (node.body !== undefined) visit(node.body); + return found; +} + /** True when the identifier resolves (through import aliases) to a * top-level function declaration of ANY program file (not merely a * same-named local shadowing one). Functions declared directly in a @@ -335,7 +358,7 @@ export function lowerLambda( try { const receiver = (checkedReceiver || - isJsSourceFile(ts.getSourceFileOfNode(node)) || + (isJsSourceFile(ts.getSourceFileOfNode(node)) && readsOwnThis(node)) || node.parameters.nodes.some(isThisParameter)) && !ts.isArrowFunction(node) ? declareCallReceiver(lowerer, node, isGenerator) @@ -535,7 +558,8 @@ export function lowerFunction(lowerer: Lowerer, decl: ts.FunctionDeclaration): I lowerer.fnStack.push(ctx); try { const receiver = - decl.parameters.nodes.some(isThisParameter) || isJsSourceFile(ts.getSourceFileOfNode(decl)) + decl.parameters.nodes.some(isThisParameter) || + (isJsSourceFile(ts.getSourceFileOfNode(decl)) && readsOwnThis(decl)) ? declareCallReceiver(lowerer, decl, sig.generator !== undefined) : null; const { params, prologue } = lowerer.declareParams(decl.parameters.nodes, sig.params); diff --git a/packages/compiler/src/frontend/shape-inference/static-objects.ts b/packages/compiler/src/frontend/shape-inference/static-objects.ts index 0a7c5abad5..a702e4f246 100644 --- a/packages/compiler/src/frontend/shape-inference/static-objects.ts +++ b/packages/compiler/src/frontend/shape-inference/static-objects.ts @@ -167,6 +167,45 @@ function ownLiteralMember(literal: Node, key: string): string | null { return found; } +/** Whether a top-level statement runs no code but its own: no call, `new`, + * tagged template, class (static blocks and field initializers run), getter + * or setter, `await`, `yield` or `delete`. Function bodies do not run. */ +function runsNoCode(stmt: ts.Statement): boolean { + if (ts.isFunctionDeclaration(stmt) || ts.isEmptyStatement(stmt)) return true; + if (!ts.isVariableStatement(stmt) && !ts.isExpressionStatement(stmt)) return false; + let quiet = true; + const visit = (n: Node): void => { + if (!quiet) return; + if (ts.isFunctionExpression(n) || ts.isArrowFunction(n)) return; + if ( + ts.isCallExpression(n) || + ts.isNewExpression(n) || + ts.isTaggedTemplateExpression(n) || + ts.isClassExpression(n) || + ts.isGetAccessorDeclaration(n) || + ts.isSetAccessorDeclaration(n) || + ts.isAwaitExpression(n) || + ts.isYieldExpression(n) || + ts.isDeleteExpression(n) || + ts.isMethodDeclaration(n) + ) { + quiet = false; + return; + } + ts.forEachChild(n, (c) => { + visit(c); + return undefined; + }); + }; + visit(stmt); + return quiet; +} + +function enclosingFunctionLike(n: Node): Node | null { + for (let p = n.parent; p !== undefined; p = p.parent) if (ts.isFunctionLike(p)) return p; + return null; +} + function primitiveLiteralText(e: ts.Expression): string | null { if ( ts.isNumericLiteral(e) || @@ -758,19 +797,80 @@ export function planStaticObjectRewrites( // the object does and nothing writes it, so every read sees it. (A // member stored after the object was created could be read before the // store; those stay reads.) + // + // A member stored once by an unconditional top-level statement of the + // object's own file (`app.math.EPSILON = 1e-9;`) folds too, at reads that + // provably run after the store: reads inside functions, when no code can + // run before the store (the statements before it call nothing, so no + // function runs and no other module starts), and top-level reads after + // it in the same file. + const writesOf = new Map>(); + for (const op of analysis.ops) { + if (op.kind !== "write" || op.name === null) continue; + for (const a of analysis.nodes[op.recv]!.atoms) { + let byKey = writesOf.get(a); + if (byKey === undefined) writesOf.set(a, (byKey = new Map())); + let l = byKey.get(op.name); + if (l === undefined) byKey.set(op.name, (l = [])); + if (!l.includes(op.node)) l.push(op.node); + } + } + const quietPrefix = new Map(); + const storedLiteral = ( + atom: Atom, + site: Node, + key: string, + ): { literal: string; stmt: ts.ExpressionStatement; statements: readonly Node[] } | null => { + const writes = writesOf.get(atom)?.get(key) ?? []; + if (writes.length !== 1) return null; + const target = writes[0]!; + const assign = target.parent; + if ( + assign === undefined || + !ts.isBinaryExpression(assign) || + assign.left !== target || + assign.operatorToken.kind !== ts.SyntaxKind.EqualsToken + ) + return null; + const stmt = assign.parent; + if (stmt === undefined || !ts.isExpressionStatement(stmt) || !ts.isSourceFile(stmt.parent)) + return null; + if (stmt.getSourceFile() !== site.getSourceFile()) return null; + const literal = primitiveLiteralText(assign.right); + if (literal === null) return null; + const statements = stmt.parent.statements.nodes; + let quiet = quietPrefix.get(stmt); + if (quiet === undefined) { + quiet = statements.slice(0, statements.indexOf(stmt)).every(runsNoCode); + quietPrefix.set(stmt, quiet); + } + return quiet ? { literal, stmt, statements } : null; + }; let foldedReads = 0; for (const { op, atoms } of mergedOps) { if (op.kind !== "read" || op.name === null) continue; if (atoms.size !== 1) continue; - const o = constantAtoms.get([...atoms][0]!); + const atom = [...atoms][0]!; + const o = constantAtoms.get(atom); if (o === undefined || o.site.kind !== "literal") continue; const field = o.fields.find((f) => f.key === op.name); if (field === undefined || field.stores !== 1) continue; - const literal = ownLiteralMember(o.site.node, op.name); - if (literal === null) continue; const node = op.node; if (!ts.isPropertyAccessExpression(node) && !ts.isElementAccessExpression(node)) continue; if (!isPlainRead(node) || topStatement(node) === null) continue; + let literal = ownLiteralMember(o.site.node, op.name); + if (literal === null && process.env["SCRIPTC_SHAPE_FOLD_STORES"] !== "0") { + const stored = storedLiteral(atom, o.site.node, op.name); + if (stored === null) continue; + const inFunction = enclosingFunctionLike(node) !== null; + const top = topStatement(node)!; + const after = + top.getSourceFile() === stored.stmt.getSourceFile() && + stored.statements.indexOf(top) > stored.statements.indexOf(stored.stmt); + if (!inFunction && !after) continue; + literal = stored.literal; + } + if (literal === null) continue; // An element key needs no parentheses (and a parenthesized literal key // would read as a computed one). const key = diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/static-stores/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/static-stores/main.ts.json new file mode 100644 index 0000000000..10384a91ba --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/static-stores/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/static-stores/main.ts" + ], + "diags": [] +} diff --git a/tests/fixtures/npm/cases/static-stores/js-main.cjs b/tests/fixtures/npm/cases/static-stores/js-main.cjs new file mode 100644 index 0000000000..c844f5b74a --- /dev/null +++ b/tests/fixtures/npm/cases/static-stores/js-main.cjs @@ -0,0 +1,8 @@ +// The JavaScript consumer of static-stores (main.ts is the TypeScript one). +const { lib, other } = require("static-stores"); + +let near = 0; +for (let i = 0; i < 20; i++) if (lib.near((i - 10) / 10)) near++; +console.log(near, lib.name(), lib.early, lib.after); +console.log(lib.counter.inc(), lib.counter.inc(), lib.twice(21), lib.tagOf.call({ tag: "t" })); +console.log(other.seen, other.check()); diff --git a/tests/fixtures/npm/cases/static-stores/main.ts b/tests/fixtures/npm/cases/static-stores/main.ts new file mode 100644 index 0000000000..ad0e3eefce --- /dev/null +++ b/tests/fixtures/npm/cases/static-stores/main.ts @@ -0,0 +1,9 @@ +// Primitive members stored after their namespace was created, and functions +// with and without a receiver (js-main.cjs is the JavaScript consumer). +import { lib, other } from "static-stores"; + +let near = 0; +for (let i = 0; i < 20; i++) if (lib.near((i - 10) / 10)) near++; +console.log(near, lib.name(), lib.early, lib.after); +console.log(lib.counter.inc(), lib.counter.inc(), lib.twice(21), lib.tagOf.call({ tag: "t" })); +console.log(other.seen, other.check()); diff --git a/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.d.ts b/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.d.ts new file mode 100644 index 0000000000..4a98d27dde --- /dev/null +++ b/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.d.ts @@ -0,0 +1,10 @@ +export declare const lib: { + early: number | undefined; + after: number; + near(x: number): boolean; + name(): string; + counter: { n: number; inc(): number }; + twice(a: number): number; + tagOf(this: { tag: string }): string; +}; +export declare const other: { seen: number; check(): boolean }; diff --git a/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.js b/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.js new file mode 100644 index 0000000000..4291167877 --- /dev/null +++ b/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.js @@ -0,0 +1,47 @@ +// Primitive members stored after their namespace object was created. Reads +// that provably run after the store read the stored literal; a read that can +// run before it (top-level code before the store, or any read once code has +// run before the store) stays a read. +var lib = lib || {}; +lib.cfg = lib.cfg || {}; +lib.cfg.EPS = 0.5; +lib.cfg.NAME = "v1"; +lib.early = lib.cfg.LATE; +lib.cfg.LATE = 7; +lib.after = lib.cfg.LATE + 1; +lib.near = function (x) { + return Math.abs(x) <= lib.cfg.EPS; +}; +lib.name = function () { + return lib.cfg.NAME + ":" + lib.cfg.LATE; +}; +// Functions that read their own receiver, through an arrow, and not at all. +lib.counter = { + n: 0, + inc: function () { + this.n++; + return this.n; + }, +}; +lib.twice = function (a) { + return a * 2; +}; +lib.tagOf = function () { + const read = () => this.tag; + return read(); +}; + +// A store after a call: code ran before it, so no read folds. +var other = {}; +other.log = []; +other.note = function (s) { + other.log.push(s); + return other.log.length; +}; +other.seen = other.note("start"); +other.flag = true; +other.check = function () { + return other.flag; +}; + +module.exports = { lib, other }; diff --git a/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/package.json b/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/package.json new file mode 100644 index 0000000000..2d202dde04 --- /dev/null +++ b/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/package.json @@ -0,0 +1 @@ +{ "name": "static-stores", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/fixtures/npm/cases/static-stores/package.json b/tests/fixtures/npm/cases/static-stores/package.json new file mode 100644 index 0000000000..38edc40905 --- /dev/null +++ b/tests/fixtures/npm/cases/static-stores/package.json @@ -0,0 +1 @@ +{ "name": "static-stores-case", "private": true, "type": "module" } diff --git a/tests/harness/static-stores.test.ts b/tests/harness/static-stores.test.ts new file mode 100644 index 0000000000..0509053718 --- /dev/null +++ b/tests/harness/static-stores.test.ts @@ -0,0 +1,46 @@ +/* Static-object specialization (shape-inference/static-objects.ts): with the + * shape overlay on, the npm case static-stores (through its JavaScript + * consumer, js-main.cjs) reads primitive members stored after their + * namespace object was created as literals where the read provably runs + * after the store, keeps the reads that can run before it, and prints what + * Node prints. Its functions with and without a receiver read `this` as + * Node does. The npm suites run the TypeScript consumer with and without + * the 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 } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, "../.."); +const entry = join(repoRoot, "tests/fixtures/npm/cases/static-stores/js-main.cjs"); +const outDir = join(repoRoot, "node_modules/.cache/scriptc-tests/static-stores"); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; + +test( + "members stored after their namespace was created read as literals after the store", + { timeout: 600_000 }, + async () => { + mkdirSync(outDir, { recursive: true }); + const result = await compile(entry, { + outPath: join(outDir, "main"), + outDir, + sanitize, + shapeOverlay: true, + }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + const report = (result.warnings ?? []).find((w) => w.code === "SC6103"); + expect(report?.message).toMatch(/, 5 constant reads\)/); + 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); + }, +); From e31e2ebe759a106d62f9cb735920d89d7ec1cd72 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 13:10:25 -0700 Subject: [PATCH 2/7] Dispatch a constant function table without a fallback when every caller passes one of its keys When the key is a parameter of a function that never escapes, every call passes a string literal key of the table, and the body neither assigns the parameter nor uses arguments, the dispatch needs no arm for other keys. In nsbench the fallback boxed the Vec probe into a dynamic call, so every Vec carried the property bag and lived on the heap; without it the workload inlines into one loop. SCRIPTC_SHAPE_EXHAUSTIVE_TABLES=0 opts out. nsbench (n=20000, overlay on): 36 -> 8.2 ms (Node about 21 ms). Co-Authored-By: Claude Opus 5.5 --- .../shape-inference/static-objects.ts | 82 ++++++++++++++++++- .../npm/cases/static-stores/js-main.cjs | 2 + .../fixtures/npm/cases/static-stores/main.ts | 2 + .../node_modules/static-stores/index.d.ts | 2 + .../node_modules/static-stores/index.js | 17 ++++ tests/harness/static-stores.test.ts | 2 +- 6 files changed, 102 insertions(+), 5 deletions(-) diff --git a/packages/compiler/src/frontend/shape-inference/static-objects.ts b/packages/compiler/src/frontend/shape-inference/static-objects.ts index a702e4f246..ab7c8e5fc5 100644 --- a/packages/compiler/src/frontend/shape-inference/static-objects.ts +++ b/packages/compiler/src/frontend/shape-inference/static-objects.ts @@ -913,7 +913,63 @@ export function planStaticObjectRewrites( // key reads the table as written. The members do not use `this`, the // table has no accessors, and comparing a string runs no code, so only // the representation of the call changes. - const tableCalls: { sf: ts.SourceFile; call: ts.CallExpression; arms: [string, string][] }[] = []; + const tableCalls: { + sf: ts.SourceFile; + call: ts.CallExpression; + arms: [string, string][]; + exhaustive: boolean; + }[] = []; + // Whether a key parameter only ever holds one of the table's keys: its + // function never escapes, every call of it passes a string literal key + // of the table, and its body neither assigns the parameter nor uses + // `arguments` (which aliases parameters in sloppy code). The dispatch + // then needs no arm for other keys. + const literalKeysOnly = (keyDecl: Node, keys: ReadonlySet): boolean => { + if (process.env["SCRIPTC_SHAPE_EXHAUSTIVE_TABLES"] === "0") return false; + if (!ts.isParameter(keyDecl) || !ts.isIdentifier(keyDecl.name)) return false; + const fn = keyDecl.parent; + if (fn === undefined || !ts.isFunctionLike(fn) || escapedFns.has(fn)) return false; + for (const a of analysis.atoms.atoms) + if (a.kind === "function" && a.node === fn && analysis.escaped.has(a)) return false; + const index = (fn as ts.FunctionLikeDeclaration).parameters.nodes.indexOf(keyDecl); + const name = keyDecl.name.text; + let touched = false; + const visit = (n: Node): void => { + if (touched) return; + if (ts.isIdentifier(n) && n.text === "arguments") touched = true; + else if ( + ts.isBinaryExpression(n) && + n.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + n.operatorToken.kind <= ts.SyntaxKind.LastAssignment && + ts.isIdentifier(n.left) && + n.left.text === name + ) + touched = true; + else if ( + (ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) && + ts.isIdentifier(n.operand) && + n.operand.text === name + ) + touched = true; + ts.forEachChild(n, (c) => { + visit(c); + return undefined; + }); + }; + const body = (fn as { body?: Node }).body; + if (body === undefined) return false; + visit(body); + if (touched) return false; + const edges = analysis.calls.filter((c) => c.callee.node === fn); + if (edges.length === 0) return false; + return edges.every((e) => { + if (!ts.isCallExpression(e.site)) return false; + const args = e.site.arguments.nodes; + if (args.slice(0, index + 1).some((a) => ts.isSpreadElement(a))) return false; + const arg = args[index]; + return arg !== undefined && ts.isStringLiteral(arg) && keys.has(arg.text); + }); + }; for (const { op, atoms } of mergedOps) { if (op.kind !== "computedRead") continue; const node = op.node; @@ -954,16 +1010,34 @@ export function planStaticObjectRewrites( kept.push(`${where}: a member is not a hoisted function of this file`); continue; } - tableCalls.push({ sf: call.getSourceFile(), call, arms }); + tableCalls.push({ + sf: call.getSourceFile(), + call, + arms, + exhaustive: literalKeysOnly(keyDecl, new Set(arms.map(([k]) => k))), + }); } - for (const { sf, call, arms } of tableCalls) { + for (const { sf, call, arms, exhaustive } of tableCalls) { const node = call.expression as ts.ElementAccessExpression; const key = node.argumentExpression.getText(); const args = call.arguments.nodes.map((a) => sliceWithCalls(sf, a)).join(", "); const table = sliceWithCalls(sf, node.expression); - const text = arms + const tested = exhaustive ? arms.slice(0, -1) : arms; + const text = tested .map(([k, name]) => `${key} === ${JSON.stringify(k)} ? ${name}(${args}) : `) .join(""); + if (exhaustive) { + callEdits.push({ + sf, + edit: { + start: ts.tokenStartOfNode(call), + end: call.end, + text: `(${text}${arms[arms.length - 1]![1]}(${args}))`, + }, + }); + tableDispatches++; + continue; + } callEdits.push({ sf, edit: { diff --git a/tests/fixtures/npm/cases/static-stores/js-main.cjs b/tests/fixtures/npm/cases/static-stores/js-main.cjs index c844f5b74a..9925086f23 100644 --- a/tests/fixtures/npm/cases/static-stores/js-main.cjs +++ b/tests/fixtures/npm/cases/static-stores/js-main.cjs @@ -5,4 +5,6 @@ let near = 0; for (let i = 0; i < 20; i++) if (lib.near((i - 10) / 10)) near++; console.log(near, lib.name(), lib.early, lib.after); console.log(lib.counter.inc(), lib.counter.inc(), lib.twice(21), lib.tagOf.call({ tag: "t" })); +const key = process.argv.length > 99 ? "dbl" : "inc"; +console.log(lib.apply("inc", 1), lib.apply("dbl", 4), lib.applyAny("dbl", 5), lib.applyAny(key, 3)); console.log(other.seen, other.check()); diff --git a/tests/fixtures/npm/cases/static-stores/main.ts b/tests/fixtures/npm/cases/static-stores/main.ts index ad0e3eefce..ee05b1adf5 100644 --- a/tests/fixtures/npm/cases/static-stores/main.ts +++ b/tests/fixtures/npm/cases/static-stores/main.ts @@ -6,4 +6,6 @@ let near = 0; for (let i = 0; i < 20; i++) if (lib.near((i - 10) / 10)) near++; console.log(near, lib.name(), lib.early, lib.after); console.log(lib.counter.inc(), lib.counter.inc(), lib.twice(21), lib.tagOf.call({ tag: "t" })); +const key = process.argv.length > 99 ? "dbl" : "inc"; +console.log(lib.apply("inc", 1), lib.apply("dbl", 4), lib.applyAny("dbl", 5), lib.applyAny(key, 3)); console.log(other.seen, other.check()); diff --git a/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.d.ts b/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.d.ts index 4a98d27dde..88a4d6aa87 100644 --- a/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.d.ts +++ b/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.d.ts @@ -6,5 +6,7 @@ export declare const lib: { counter: { n: number; inc(): number }; twice(a: number): number; tagOf(this: { tag: string }): string; + apply(op: "inc" | "dbl", v: number): number; + applyAny(op: string, v: number): number | string; }; export declare const other: { seen: number; check(): boolean }; diff --git a/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.js b/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.js index 4291167877..8e65f2fc7a 100644 --- a/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.js +++ b/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.js @@ -30,6 +30,23 @@ lib.tagOf = function () { const read = () => this.tag; return read(); }; +// A constant function table: `apply` is only ever called with literal keys +// of the table, so its dispatch needs no arm for other keys; `applyAny` is +// called with a computed key and keeps reading the table for other keys. +lib.ops = { + inc: function (v) { + return v + 1; + }, + dbl: function (v) { + return v * 2; + }, +}; +lib.apply = function (op, v) { + return lib.ops[op](v); +}; +lib.applyAny = function (op, v) { + return lib.ops[op](v); +}; // A store after a call: code ran before it, so no read folds. var other = {}; diff --git a/tests/harness/static-stores.test.ts b/tests/harness/static-stores.test.ts index 0509053718..3032373922 100644 --- a/tests/harness/static-stores.test.ts +++ b/tests/harness/static-stores.test.ts @@ -33,7 +33,7 @@ test( if (!result.ok) throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); const report = (result.warnings ?? []).find((w) => w.code === "SC6103"); - expect(report?.message).toMatch(/, 5 constant reads\)/); + expect(report?.message).toMatch(/2 function table calls, 5 constant reads\)/); const pending = execFileAsync(result.binaryPath, [], { cwd: dirname(entry), encoding: "utf8" }); pending.child.stdin?.end(); const { stdout } = await pending; From 6f1efbf42b32f005a18815cdda6e52af11b9697e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 25 Oct 2026 15:56:53 -0500 Subject: [PATCH 3/7] Remove the committed node_modules symlink --- .gitignore | 2 ++ packages/compiler/node_modules | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) delete mode 120000 packages/compiler/node_modules diff --git a/.gitignore b/.gitignore index 81b92ddd53..88dfdc35b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ node_modules/ +# A symlinked node_modules is a file to git, which node_modules/ does not match. +/packages/*/node_modules .pnpm-store/ # test-fixture packages are TEST DATA, committed on purpose !tests/diagnostics/npm-imports/node_modules/ diff --git a/packages/compiler/node_modules b/packages/compiler/node_modules deleted file mode 120000 index 6b01b0eda6..0000000000 --- a/packages/compiler/node_modules +++ /dev/null @@ -1 +0,0 @@ -../../../shape-reg/packages/compiler/node_modules \ No newline at end of file From bcbae8a497d2d68f7b18b8930e89392e0510f7ec Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 25 Oct 2026 16:36:06 -0500 Subject: [PATCH 4/7] Address review feedback --- .../shape-inference/static-objects.ts | 206 ++++++++++++++---- .../test/static-object-guards.test.ts | 148 +++++++++++++ 2 files changed, 309 insertions(+), 45 deletions(-) create mode 100644 packages/compiler/test/static-object-guards.test.ts diff --git a/packages/compiler/src/frontend/shape-inference/static-objects.ts b/packages/compiler/src/frontend/shape-inference/static-objects.ts index ecaf664aa0..640140e61e 100644 --- a/packages/compiler/src/frontend/shape-inference/static-objects.ts +++ b/packages/compiler/src/frontend/shape-inference/static-objects.ts @@ -238,38 +238,142 @@ function ownLiteralMember(literal: Node, key: string): string | null { return found; } -/** Whether a top-level statement runs no code but its own: no call, `new`, - * tagged template, class (static blocks and field initializers run), getter - * or setter, `await`, `yield` or `delete`. Function bodies do not run. */ -function runsNoCode(stmt: ts.Statement): boolean { +/** Whether a top-level statement runs no code but its own. Its expressions + * are literals, identifiers, functions (not run), object and array literals + * without spreads or computed keys, `=` stores, `var` bindings without + * patterns, `&&`, `||`, `??`, `?:`, `,`, `!`, `typeof`, `void`, `===` and + * `!==`, none of which calls a function, and member reads and stores that + * `plainMember` accepts (no getter or setter runs). Every other form can + * run code: a call, `new` or tagged template, a class (static blocks and + * field initializers run), coercion (arithmetic, `+`, templates with + * substitutions, relational and loose comparisons, `in`, `instanceof`, + * computed keys), iteration (spreads, destructuring), `delete`, `await` and + * `yield`. */ +function runsNoCode(stmt: ts.Statement, plainMember: (n: Node) => boolean): boolean { if (ts.isFunctionDeclaration(stmt) || ts.isEmptyStatement(stmt)) return true; - if (!ts.isVariableStatement(stmt) && !ts.isExpressionStatement(stmt)) return false; - let quiet = true; - const visit = (n: Node): void => { - if (!quiet) return; - if (ts.isFunctionExpression(n) || ts.isArrowFunction(n)) return; - if ( - ts.isCallExpression(n) || - ts.isNewExpression(n) || - ts.isTaggedTemplateExpression(n) || - ts.isClassExpression(n) || - ts.isGetAccessorDeclaration(n) || - ts.isSetAccessorDeclaration(n) || - ts.isAwaitExpression(n) || - ts.isYieldExpression(n) || - ts.isDeleteExpression(n) || - ts.isMethodDeclaration(n) - ) { - quiet = false; - return; - } - ts.forEachChild(n, (c) => { - visit(c); - return undefined; + if (ts.isExpressionStatement(stmt)) return quietExpression(stmt.expression, plainMember); + if (!ts.isVariableStatement(stmt)) return false; + return stmt.declarationList.declarations.nodes.every( + (d) => + ts.isIdentifier(d.name) && + (d.initializer === undefined || quietExpression(d.initializer, plainMember)), + ); +} + +function quietExpression(e: ts.Expression, plainMember: (n: Node) => boolean): boolean { + const quiet = (x: ts.Expression): boolean => quietExpression(x, plainMember); + if (ts.isParenthesizedExpression(e)) return quiet(e.expression); + if ( + ts.isIdentifier(e) || + ts.isNumericLiteral(e) || + ts.isBigIntLiteral(e) || + ts.isStringLiteral(e) || + ts.isNoSubstitutionTemplateLiteral(e) || + ts.isRegularExpressionLiteral(e) || + ts.isFunctionExpression(e) || + ts.isArrowFunction(e) || + e.kind === ts.SyntaxKind.TrueKeyword || + e.kind === ts.SyntaxKind.FalseKeyword || + e.kind === ts.SyntaxKind.NullKeyword || + e.kind === ts.SyntaxKind.ThisKeyword + ) + return true; + if (ts.isObjectLiteralExpression(e)) + return e.properties.nodes.every((m) => { + if (ts.isShorthandPropertyAssignment(m)) return m.objectAssignmentInitializer === undefined; + if (!ts.isPropertyAssignment(m)) return false; + const name = m.name; + if (!ts.isIdentifier(name) && !ts.isStringLiteral(name) && !ts.isNumericLiteral(name)) + return false; + return quiet(m.initializer); }); - }; - visit(stmt); - return quiet; + if (ts.isArrayLiteralExpression(e)) + return e.elements.nodes.every( + (x) => ts.isOmittedExpression(x) || (!ts.isSpreadElement(x) && quiet(x)), + ); + if (ts.isTypeOfExpression(e) || ts.isVoidExpression(e)) return quiet(e.expression); + if (ts.isPrefixUnaryExpression(e)) + return e.operator === ts.SyntaxKind.ExclamationToken && quiet(e.operand); + if (ts.isConditionalExpression(e)) + return quiet(e.condition) && quiet(e.whenTrue) && quiet(e.whenFalse); + if (ts.isPropertyAccessExpression(e) || ts.isElementAccessExpression(e)) + return quietMember(e, plainMember); + if (!ts.isBinaryExpression(e)) return false; + switch (e.operatorToken.kind) { + case ts.SyntaxKind.EqualsToken: + if (ts.isIdentifier(e.left)) return quiet(e.right); + return ( + (ts.isPropertyAccessExpression(e.left) || ts.isElementAccessExpression(e.left)) && + quietMember(e.left, plainMember) && + quiet(e.right) + ); + case ts.SyntaxKind.AmpersandAmpersandToken: + case ts.SyntaxKind.BarBarToken: + case ts.SyntaxKind.QuestionQuestionToken: + case ts.SyntaxKind.CommaToken: + case ts.SyntaxKind.EqualsEqualsEqualsToken: + case ts.SyntaxKind.ExclamationEqualsEqualsToken: + return quiet(e.left) && quiet(e.right); + default: + return false; + } +} + +/** A member read or store target with a literal key on a receiver that + * runs no code, which `plainMember` accepts. */ +function quietMember( + e: ts.PropertyAccessExpression | ts.ElementAccessExpression, + plainMember: (n: Node) => boolean, +): boolean { + if (e.questionDotToken !== undefined) return false; + if ( + ts.isElementAccessExpression(e) && + !ts.isStringLiteral(e.argumentExpression) && + !ts.isNumericLiteral(e.argumentExpression) + ) + return false; + return plainMember(e) && quietExpression(e.expression, plainMember); +} + +/** Whether an identifier is written where it appears: an assignment or + * update target, a destructuring assignment or loop target (through array + * and object patterns, defaults and rests), or a `var`, `let`, `const` or + * parameter binding of the name (a nested binding shadows it, which only + * makes the answer conservative). */ +function writesBinding(id: ts.Identifier): boolean { + let n: Node = id; + for (let p = n.parent; p !== undefined; n = p, p = p.parent) { + if (ts.isPrefixUnaryExpression(p) || ts.isPostfixUnaryExpression(p)) + return ( + p.operator === ts.SyntaxKind.PlusPlusToken || p.operator === ts.SyntaxKind.MinusMinusToken + ); + if (ts.isBinaryExpression(p)) { + const op = p.operatorToken.kind; + if (op >= ts.SyntaxKind.FirstAssignment && op <= ts.SyntaxKind.LastAssignment) + return p.left === n; + return false; + } + if (ts.isForOfStatement(p) || ts.isForInStatement(p)) return p.initializer === n; + if (ts.isVariableDeclaration(p) || ts.isParameter(p) || ts.isBindingElement(p)) + return p.name === n; + if ( + ts.isParenthesizedExpression(p) || + ts.isArrayLiteralExpression(p) || + ts.isObjectLiteralExpression(p) || + ts.isSpreadElement(p) || + ts.isSpreadAssignment(p) || + ts.isShorthandPropertyAssignment(p) || + ts.isArrayBindingPattern(p) || + ts.isObjectBindingPattern(p) + ) + continue; + if (ts.isPropertyAssignment(p)) { + if (p.initializer !== n) return false; + continue; + } + return false; + } + return false; } function enclosingFunctionLike(n: Node): Node | null { @@ -1345,6 +1449,24 @@ export function planStaticObjectRewrites( if (!l.includes(op.node)) l.push(op.node); } } + // Member reads and stores whose receiver can only be constant objects or + // primitives: neither has accessors of its own, so no getter or setter + // runs (a primitive receiver of a store throws, or reads its prototype). + const memberReceivers = new Map>(); + for (const op of analysis.ops) { + if ((op.kind !== "read" && op.kind !== "write") || op.name === null) continue; + let set = memberReceivers.get(op.node); + if (set === undefined) memberReceivers.set(op.node, (set = new Set())); + for (const a of analysis.nodes[op.recv]!.atoms) set.add(a); + } + const plainMember = (n: Node): boolean => { + const atoms = memberReceivers.get(n); + return ( + atoms !== undefined && + atoms.size > 0 && + [...atoms].every((a) => a.kind === "prim" || constantAtoms.has(a)) + ); + }; const quietPrefix = new Map(); const storedLiteral = ( atom: Atom, @@ -1371,7 +1493,9 @@ export function planStaticObjectRewrites( const statements = stmt.parent.statements.nodes; let quiet = quietPrefix.get(stmt); if (quiet === undefined) { - quiet = statements.slice(0, statements.indexOf(stmt)).every(runsNoCode); + quiet = statements + .slice(0, statements.indexOf(stmt)) + .every((s) => runsNoCode(s, plainMember)); quietPrefix.set(stmt, quiet); } return quiet ? { literal, stmt, statements } : null; @@ -1472,29 +1596,21 @@ export function planStaticObjectRewrites( if (a.kind === "function" && a.node === fn && analysis.escaped.has(a)) return false; const index = (fn as ts.FunctionLikeDeclaration).parameters.nodes.indexOf(keyDecl); const name = keyDecl.name.text; + // Any write of the name in the function, its other parameters included, + // or a direct eval or `arguments` (which aliases parameters in sloppy + // code) can give the key another value. let touched = false; const visit = (n: Node): void => { if (touched) return; - if (ts.isIdentifier(n) && n.text === "arguments") touched = true; - else if ( - ts.isBinaryExpression(n) && - n.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && - n.operatorToken.kind <= ts.SyntaxKind.LastAssignment && - ts.isIdentifier(n.left) && - n.left.text === name - ) - touched = true; - else if ( - (ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) && - ts.isIdentifier(n.operand) && - n.operand.text === name - ) + if (ts.isIdentifier(n) && (n.text === "arguments" || n.text === "eval")) touched = true; + else if (ts.isIdentifier(n) && n.text === name && n !== keyDecl.name && writesBinding(n)) touched = true; ts.forEachChild(n, (c) => { visit(c); return undefined; }); }; + for (const p of (fn as ts.FunctionLikeDeclaration).parameters.nodes) visit(p); const body = (fn as { body?: Node }).body; if (body === undefined) return false; visit(body); diff --git a/packages/compiler/test/static-object-guards.test.ts b/packages/compiler/test/static-object-guards.test.ts new file mode 100644 index 0000000000..2aba8dfd67 --- /dev/null +++ b/packages/compiler/test/static-object-guards.test.ts @@ -0,0 +1,148 @@ +/* Static-object specialization (shape-inference/static-objects.ts) keeps two + * reads dynamic when the facts it would fold on can change: + * - a member stored after a statement that can run code (a coercion such as + * `+` can call toString or valueOf) keeps its read in functions, which + * that code could call before the store (the statements checked are all + * the file's top-level statements before the store); + * - a table call whose key parameter the function rebinds (destructuring, + * a loop target, another parameter's default or a `var` redeclaration) + * keeps reading the table for other keys, though every caller passes one + * of the table's keys. + * The checks read the rewritten package text SCRIPTC_DEBUG_SHAPE_OVERLAY + * prints. */ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { compile } from "../src/index.js"; + +const packageSource = `var quiet = quiet || {}; +quiet.cfg = { U: 1 }; +quiet.read = function () { + return quiet.cfg.V; +}; +quiet.label = quiet.cfg.U; +quiet.cfg.V = 6; + +var late = late || {}; +late.cfg = { U: 1 }; +late.read = function () { + return late.cfg.V; +}; +late.label = "v" + late.cfg.U; +late.cfg.V = 5; + +var t = t || {}; +t.ops = { + a: function (v) { + return v + 1; + }, + b: function (v) { + return v * 2; + }, +}; +t.plain = function (op, v) { + return t.ops[op](v); +}; +t.pattern = function (op, v) { + [op] = ["c"]; + return t.ops[op](v); +}; +t.loop = function (op, v) { + for (op of ["c"]); + return t.ops[op](v); +}; +t.initializer = function (op, v, w = (op = "c")) { + return t.ops[op](v); +}; +t.redeclared = function (op, v) { + var op = "c"; + return t.ops[op](v); +}; +module.exports = { late, quiet, t }; +`; + +const consumer = `const { late, quiet, t } = require("guard-fixture"); +console.log(late.read(), late.label, quiet.read(), quiet.label); +const tryCall = (call) => { + try { + return call(); + } catch (e) { + return e.name; + } +}; +console.log(t.plain("a", 1), t.plain("b", 2)); +console.log(tryCall(() => t.pattern("a", 1)), tryCall(() => t.loop("b", 2))); +console.log(tryCall(() => t.initializer("a", 1)), tryCall(() => t.redeclared("b", 2))); +`; + +let dir = ""; +const saved: Record = {}; +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "scriptc-static-guards-")); + const pkg = join(dir, "node_modules/guard-fixture"); + await mkdir(pkg, { recursive: true }); + await writeFile( + join(pkg, "package.json"), + JSON.stringify({ name: "guard-fixture", version: "1.0.0", main: "index.js" }), + ); + await writeFile(join(pkg, "index.js"), packageSource); + await writeFile( + join(dir, "package.json"), + JSON.stringify({ dependencies: { "guard-fixture": "1.0.0" } }), + ); + await writeFile(join(dir, "main.cjs"), consumer); + for (const k of ["SCRIPTC_SHAPE_OVERLAY", "SCRIPTC_DEBUG_SHAPE_OVERLAY", "SCRIPTC_NO_CACHE"]) + saved[k] = process.env[k]; +}); +afterEach(async () => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + vi.restoreAllMocks(); + await rm(dir, { recursive: true, force: true }); +}); + +/** The package text after the structural rewrites. */ +async function rewrittenPackage(): Promise { + process.env["SCRIPTC_SHAPE_OVERLAY"] = "1"; + process.env["SCRIPTC_DEBUG_SHAPE_OVERLAY"] = "1"; + process.env["SCRIPTC_NO_CACHE"] = "1"; + let text = ""; + vi.spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => { + text += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + return true; + }); + const result = await compile(join(dir, "main.cjs"), { + outDir: dir, + outPath: join(dir, "main.ir.json"), + outputKind: "ir", + npmStatic: ["guard-fixture"], + }); + vi.restoreAllMocks(); + if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n")); + const start = text.indexOf("[static-objects] "); + const end = text.indexOf("[shape-overlay] ", start); + return start < 0 ? "" : text.slice(start, end < 0 ? undefined : end); +} + +/** The body of the function the rewritten text assigns to or hoists as `name`. */ +function body(text: string, name: string): string { + const at = text.search(new RegExp(`function [\\w$]*${name}\\b|${name} = function`)); + return at < 0 ? "" : text.slice(at, text.indexOf("\n}", at)); +} + +test("a store after code that can run keeps the read; a quiet prefix folds it", async () => { + const text = await rewrittenPackage(); + expect(body(text, "quiet_read")).toMatch(/return \(.*\b6\);/); + expect(body(text, "late_read")).toContain("return late.cfg.V"); +}); + +test("a table call whose key parameter is rebound keeps reading the table", async () => { + const text = await rewrittenPackage(); + // Every caller passes "a" or "b": the plain call needs no arm for other keys. + expect(body(text, "t_plain")).not.toContain("t.ops[op](v)"); + for (const name of ["t_pattern", "t_loop", "t_initializer", "t_redeclared"]) + expect(body(text, name), name).toContain("t.ops[op](v)"); +}); From 98e67128c1079b8b59d66feed284b0435f62a63f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 14:00:45 -0700 Subject: [PATCH 5/7] static-stores: this.tag on a literal made at the call stays a read The object literal is created in the consumer's call, so the base's created-before check keeps the read. Co-Authored-By: Claude Opus 5.5 --- tests/harness/static-stores.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/harness/static-stores.test.ts b/tests/harness/static-stores.test.ts index 3032373922..7296580fd4 100644 --- a/tests/harness/static-stores.test.ts +++ b/tests/harness/static-stores.test.ts @@ -33,7 +33,7 @@ test( if (!result.ok) throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); const report = (result.warnings ?? []).find((w) => w.code === "SC6103"); - expect(report?.message).toMatch(/2 function table calls, 5 constant reads\)/); + expect(report?.message).toMatch(/2 function table calls, 4 constant reads\)/); const pending = execFileAsync(result.binaryPath, [], { cwd: dirname(entry), encoding: "utf8" }); pending.child.stdin?.end(); const { stdout } = await pending; From 09de11920c77d28f1faaf166fec5a9133a47b2e8 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 15:15:13 -0700 Subject: [PATCH 6/7] Keep the receiver read in every JavaScript function for now Skipping it in functions without `this` missed methods that read the receiver through super (8302-class-super-method-values, prototype-method-overrides) and changes the default build's dynamic-tier counts. It changes the default JavaScript lowering, so it gets its own PR. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-functions.ts | 28 ++----------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-functions.ts b/packages/compiler/src/frontend/lowering/lower-functions.ts index 444134203c..bd9b5f55d1 100644 --- a/packages/compiler/src/frontend/lowering/lower-functions.ts +++ b/packages/compiler/src/frontend/lowering/lower-functions.ts @@ -62,29 +62,6 @@ function declareCallReceiver( }; } -/** Whether a JavaScript function's own `this` can be read: a `this` in its - * parameters or body, outside nested non-arrow functions (which bind their - * own). Conservative inside classes and object literal methods. A function - * that never reads it skips the ambient receiver read at entry. */ -function readsOwnThis(node: ts.FunctionLikeDeclaration): boolean { - let found = false; - const visit = (n: ts.Node): void => { - if (found) return; - if (n.kind === ts.SyntaxKind.ThisKeyword) { - found = true; - return; - } - if (ts.isFunctionDeclaration(n) || ts.isFunctionExpression(n)) return; - ts.forEachChild(n, (c) => { - visit(c); - return undefined; - }); - }; - for (const p of node.parameters.nodes) visit(p); - if (node.body !== undefined) visit(node.body); - return found; -} - /** True when the identifier resolves (through import aliases) to a * top-level function declaration of ANY program file (not merely a * same-named local shadowing one). Functions declared directly in a @@ -359,7 +336,7 @@ export function lowerLambda( try { const receiver = (checkedReceiver || - (isJsSourceFile(ts.getSourceFileOfNode(node)) && readsOwnThis(node)) || + isJsSourceFile(ts.getSourceFileOfNode(node)) || node.parameters.nodes.some(isThisParameter)) && !ts.isArrowFunction(node) ? declareCallReceiver(lowerer, node, isGenerator) @@ -564,8 +541,7 @@ export function lowerFunction(lowerer: Lowerer, decl: ts.FunctionDeclaration): I const argumentCount: IrStmt[] = []; declareArgumentCount(lowerer, decl, argumentCount); const receiver = - decl.parameters.nodes.some(isThisParameter) || - (isJsSourceFile(ts.getSourceFileOfNode(decl)) && readsOwnThis(decl)) + decl.parameters.nodes.some(isThisParameter) || isJsSourceFile(ts.getSourceFileOfNode(decl)) ? declareCallReceiver(lowerer, decl, sig.generator !== undefined) : null; const { params, prologue } = lowerer.declareParams(decl.parameters.nodes, sig.params); From d294af682a56664e7ed61276f69eb8eef014b1b5 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 25 Oct 2026 17:53:23 -0500 Subject: [PATCH 7/7] Fix the static-objects order test --- .../src/frontend/shape-inference/static-objects.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/frontend/shape-inference/static-objects.ts b/packages/compiler/src/frontend/shape-inference/static-objects.ts index d20f53cfce..e36b67d412 100644 --- a/packages/compiler/src/frontend/shape-inference/static-objects.ts +++ b/packages/compiler/src/frontend/shape-inference/static-objects.ts @@ -1394,10 +1394,6 @@ export function planStaticObjectRewrites( if (parent === undefined || ts.isDeleteExpression(parent)) continue; if (!ts.isPropertyAccessExpression(node)) continue; if (topStatement(node) === null) continue; - // The receiver's evaluation is dropped, and the binding exists from the - // store on (a function) or from its declaration on (a class). - if (!pureReceiver(node.expression, node)) continue; - if (!runsAfter(node, storeOf.get(field.values[0]!)!)) continue; // The reference replaces the whole member expression: its receiver // must be one that evaluating runs no code. if (!effectFree(node.expression)) { @@ -1408,6 +1404,10 @@ export function planStaticObjectRewrites( kept.push(`reference ${node.getText()}: it may run before the member is stored`); continue; } + // The receiver's evaluation is dropped, and the binding exists from the + // store on (a function) or from its declaration on (a class). + if (!pureReceiver(node.expression, node)) continue; + if (!runsAfter(node, storeOf.get(field.values[0]!)!)) continue; if (node.getSourceFile() !== field.values[0]!.node!.getSourceFile()) { const text = crossFileReference(node, field.values[0]!.node!.getSourceFile(), name); if (text === null) continue;