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.
This commit is contained in:
Chris Tate
2026-10-24 16:13:05 -05:00
parent 69e6c11290
commit 317162af78
2 changed files with 51 additions and 1 deletions
@@ -252,6 +252,10 @@ const LOOP_CALLBACK_METHODS: ReadonlySet<string> = 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<Node>();
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));
@@ -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`),
);
});