From 317162af78f367342ff8a06b46779f9c4e2b5e9d Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 24 Oct 2026 16:13:05 -0500 Subject: [PATCH] Count repeated calls through constructors, apply and per-value callbacks The constant-graph pass treats a function whose call edges all run once at initialization as init-only. Some calls recorded no edge or a once-only one: - `new F()` of an ES5 constructor and `f.apply(thisArg, args)` bound the arguments without a call edge, so a function also called directly once was init-only even when a loop constructed or applied it. - Callbacks of Array.from, toSorted, replace and replaceAll run once per element, comparison or match, but those methods were missing from the repeating callback set. - JSON.parse revivers, JSON.stringify replacers and toJSON methods run once per value; their edges at the JSON call site now count as repeated, including toJSON calls resolved after the call's handling. Objects these functions write or allocate were reported as constant, and their reads as foldable. constant-graphs.test.ts covers each case. --- .../src/frontend/shape-inference/analyze.ts | 15 +++++++- .../compiler/test/constant-graphs.test.ts | 37 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/frontend/shape-inference/analyze.ts b/packages/compiler/src/frontend/shape-inference/analyze.ts index 98ec87099..8b1101da2 100644 --- a/packages/compiler/src/frontend/shape-inference/analyze.ts +++ b/packages/compiler/src/frontend/shape-inference/analyze.ts @@ -252,6 +252,10 @@ const LOOP_CALLBACK_METHODS: ReadonlySet = new Set([ "reduceRight", "flatMap", "sort", + "toSorted", + "from", + "replace", + "replaceAll", ]); export function analyzeShapes(program: ts.Program, checker: ts.TypeChecker): ShapeAnalysis { @@ -298,12 +302,17 @@ class Analyzer { this.siteCtx.set(site, { caller: this.currentFn, inLoop: this.loopDepth > 0 }); } + /** Builtin calls that invoke their callbacks once per value + * (JSON.parse revivers, JSON.stringify replacers and toJSON methods), + * which may run after the call's own handling. */ + private readonly repeatingSites = new Set(); + edgeFor(site: Node, callee: FunctionInfo): void { const ctx = this.siteCtx.get(site); this.calls.push({ caller: ctx?.caller ?? null, callee, - inLoop: (ctx?.inLoop ?? false) || this.forceLoop, + inLoop: (ctx?.inLoop ?? false) || this.forceLoop || this.repeatingSites.has(site), site, }); } @@ -2547,6 +2556,7 @@ class Analyzer { this.add(info.thisNode, inst); this.bindArgs(info, args, spread); this.edge(info.ret, dst); + this.edgeFor(e, info); } return; } @@ -2894,6 +2904,7 @@ class Analyzer { for (const p of info.params) if (args[1] !== undefined) this.iterateInto(args[1], p, site); this.edge(info.ret, dst); + this.edgeFor(site, info); } return; } @@ -3034,6 +3045,7 @@ class Analyzer { // result. const revived = this.newNode(); this.add(revived, this.top); + this.repeatingSites.add(site); if (args[1] !== undefined) this.on(args[1], (f) => { if (f.kind === "function") @@ -3057,6 +3069,7 @@ class Analyzer { // Their results are serialized in turn. const serialized = this.newNode(); this.childrenInto(serialized); + this.repeatingSites.add(site); this.edge(arg(0), serialized); const key = this.constNode(this.prim("string")); this.on(serialized, (v) => this.callToJSON(v, key, serialized, site)); diff --git a/packages/compiler/test/constant-graphs.test.ts b/packages/compiler/test/constant-graphs.test.ts index 258c83b08..a89276ce7 100644 --- a/packages/compiler/test/constant-graphs.test.ts +++ b/packages/compiler/test/constant-graphs.test.ts @@ -104,3 +104,40 @@ test("constant graphs: namespaces, revealing modules and prototypes, with near m // A factory called from two init sites: one object per call site. expect(text).toMatch(/near miss ns-fixture\/index\.js:20: init-factory \(.*2 call sites\)/); }); + +// Functions that one call site runs repeatedly: an ES5 constructor and an +// apply call in a loop, and builtin callbacks that run once per element, +// match or parsed value. +const repeatedSource = ` +var lib = {}; +lib.newState = { v: 0 }; +function bumpNew() { lib.newState.v++; } +bumpNew(); +lib.applyState = { v: 0 }; +function bumpApply() { lib.applyState.v++; } +bumpApply(); +lib.repeat = function () { for (var i = 0; i < 3; i++) { new bumpNew(); bumpApply.apply(null, []); } }; +lib.seen = { match: null, pair: null, mapped: null, key: null }; +lib.replaced = "a-b".replace(/\\\\w/g, function (m) { lib.seen.match = { m: m }; return m; }); +lib.sorted = [3, 1, 2].toSorted(function (a, b) { lib.seen.pair = { a: a }; return a - b; }); +lib.mapped = Array.from([1, 2], function (x) { lib.seen.mapped = { x: x }; return x; }); +lib.revived = JSON.parse('{"a":{"b":1}}', function (k, v) { lib.seen.key = { k: k }; return v; }); +module.exports = { lib }; +`; + +test("constant graphs: constructors, apply calls and callbacks that one site runs repeatedly", async () => { + await writeFile(join(dir, "node_modules", "ns-fixture", "index.js"), repeatedSource); + await writeFile( + join(dir, "main.cjs"), + 'const { lib } = require("ns-fixture");\nlib.repeat();\nconsole.log(lib.newState.v, lib.applyState.v, lib.replaced, lib.sorted, lib.mapped, lib.revived);\n', + ); + const text = (await report()).join("\n"); + // `new bumpNew()` and `bumpApply.apply(...)` in a loop write after init. + expect(text).toMatch(/near miss ns-fixture\/index\.js:3 \.v: written-after-init/); + expect(text).toMatch(/near miss ns-fixture\/index\.js:6 \.v: written-after-init/); + // Each callback runs once per match, comparison, element or parsed value. + for (const field of ["match", "pair", "mapped", "key"]) + expect(text).toMatch( + new RegExp(`near miss ns-fixture/index\\.js:10 \\.${field}: written-after-init`), + ); +});