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`), + ); +});