Keep shape inference within its budget on large packages

The analysis exceeded its 20M-step budget on the Hono fixture (32.5 s)
and on moment 2.30 (60 s, two files), so it reported no facts for them.

- Widening: a value node holding more than 32 atoms (merged code that
  every value flows through, such as moment's utilities) becomes ⊤. Its
  values escape, so the facts stay sound, and later atoms escape instead
  of multiplying constraints. SCRIPTC_SHAPE_WIDEN sets the limit.
- A member a string, number or boolean prototype lacks reads undefined
  instead of creating a builtin atom per name (Hono's request and route
  objects meet strings in shared helpers).
- Members of builtin instances (`RegExp.instance`) no longer chain into
  static builtin paths.
- Symbol resolution is memoized per identifier.

Hono: 1.16M steps in 2.4 s with complete facts (55 constant objects).
moment: 115k steps in 0.4 s. zod 3 and the namespace benchmark are
unchanged (same steps and overlay annotations).

SCRIPTC_DEBUG_SHAPES_PROFILE prints where the solver's steps go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-25 07:34:04 -07:00
co-authored by Claude Opus 5.5
parent 0c0077da21
commit fc3f0de981
3 changed files with 201 additions and 4 deletions
@@ -24,7 +24,13 @@
import * as ts from "../ts7/adapter.js";
import { isJsSourceFile } from "../program.js";
import { npmStaticPackageOfPath } from "../npm-static.js";
import { AtomTable, MODELED_GLOBALS, STRING_METHOD_RESULTS, type Atom } from "./domain.js";
import {
AtomTable,
MODELED_GLOBALS,
OBJECT_PROTOTYPE_MEMBERS,
STRING_METHOD_RESULTS,
type Atom,
} from "./domain.js";
type Node = ts.Node;
@@ -36,6 +42,9 @@ export interface ValueNode {
readonly onAtom: ((atom: Atom) => void)[];
/** Every atom that arrives escapes. */
escapeSink: string | null;
/** Widened (WIDEN_AT): the node holds ⊤, and every further atom that
* arrives escapes instead of flowing on. */
wide?: true;
}
export interface FunctionInfo {
@@ -185,6 +194,79 @@ const ARRAY_METHODS: ReadonlySet<string> = new Set([
]);
const MAX_STEPS = 20_000_000;
/** The members String.prototype, Number.prototype and Boolean.prototype
* define (Node 24); Object.prototype's come from OBJECT_PROTOTYPE_MEMBERS. */
const PRIMITIVE_MEMBERS: Readonly<Record<"string" | "number" | "boolean", ReadonlySet<string>>> = {
string: new Set([
"at",
"charAt",
"charCodeAt",
"codePointAt",
"concat",
"endsWith",
"includes",
"indexOf",
"isWellFormed",
"lastIndexOf",
"localeCompare",
"match",
"matchAll",
"normalize",
"padEnd",
"padStart",
"repeat",
"replace",
"replaceAll",
"search",
"slice",
"split",
"startsWith",
"substr",
"substring",
"toLocaleLowerCase",
"toLocaleUpperCase",
"toLowerCase",
"toString",
"toUpperCase",
"toWellFormed",
"trim",
"trimEnd",
"trimStart",
"trimLeft",
"trimRight",
"valueOf",
"anchor",
"big",
"blink",
"bold",
"fixed",
"fontcolor",
"fontsize",
"italics",
"link",
"small",
"strike",
"sub",
"sup",
"length",
"constructor",
]),
number: new Set([
"toExponential",
"toFixed",
"toLocaleString",
"toPrecision",
"toString",
"valueOf",
"constructor",
]),
boolean: new Set(["toString", "valueOf", "constructor"]),
};
/** The most atoms a value node tracks before it widens to ⊤
* (SCRIPTC_SHAPE_WIDEN overrides). */
const WIDEN_AT = Number(process.env["SCRIPTC_SHAPE_WIDEN"] ?? 32);
function list<T extends Node>(arr: { readonly nodes: readonly T[] } | undefined): readonly T[] {
return arr?.nodes ?? [];
}
@@ -335,6 +417,49 @@ class Analyzer {
this.top = this.atoms.intern("top", () => ({ kind: "top", label: "⊤" }));
}
/** SCRIPTC_DEBUG_SHAPES_PROFILE: where the solver's steps go. */
private profileReport(): void {
const byNodeLabel = new Map<string, number>();
let atomsTotal = 0;
let maxAtoms = 0;
let onAtoms = 0;
let succ = 0;
for (const n of this.nodes) {
atomsTotal += n.atoms.size;
maxAtoms = Math.max(maxAtoms, n.atoms.size);
onAtoms += n.onAtom.length;
succ += n.succ.size;
}
const kinds = new Map<string, number>();
for (const a of this.atoms.atoms) kinds.set(a.kind, (kinds.get(a.kind) ?? 0) + 1);
const ctxPerFn = new Map<Node, number>();
for (const info of this.functions.values())
ctxPerFn.set(info.node, (ctxPerFn.get(info.node) ?? 0) + 1);
const topFns = [...ctxPerFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15);
const lines = [
`nodes ${this.nodes.length} atoms ${this.atoms.atoms.length} (${[...kinds].map(([k, v]) => `${k} ${v}`).join(", ")})`,
`solve ${this.solveMs.toFixed(0)} ms (nested walks included) checker ${this.checkerMs.toFixed(0)} ms in ${this.checkerCalls} calls`,
`node-atom pairs ${atomsTotal} max ${maxAtoms} edges ${succ} listeners ${onAtoms} functions ${this.functions.size} steps ${this.steps}`,
...topFns.map(([n, c]) => ` ${c} contexts: ${locLabel(n)}`),
...[...this.popsByAtom.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 15)
.map(([a, c]) => ` ${c} pops: atom ${a.kind} ${a.label}`),
...[...this.popsByNode.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(
([id, c]) =>
` ${c} pops: node ${id} ${this.nodeLabel.get(id) ?? ""} atoms ${this.nodes[id]!.atoms.size} succ ${this.nodes[id]!.succ.size} listeners ${this.nodes[id]!.onAtom.length}`,
),
];
void byNodeLabel;
process.stderr.write(lines.map((l) => `[shapes-profile] ${l}\n`).join(""));
}
private readonly profiling = process.env["SCRIPTC_DEBUG_SHAPES_PROFILE"] !== undefined;
private readonly popsByAtom = new Map<Atom, number>();
private readonly popsByNode = new Map<number, number>();
run(): ShapeAnalysis {
for (const sf of this.program.getSourceFiles()) {
if (sf.isDeclarationFile) continue;
@@ -347,6 +472,7 @@ class Analyzer {
for (const sf of this.files) this.checker.prefetchSourceFile(sf);
for (const sf of this.files) this.moduleBody(sf);
this.finish();
if (this.profiling) this.profileReport();
return {
atoms: this.atoms,
nodes: this.nodes,
@@ -393,7 +519,22 @@ class Analyzer {
add(node: number, atom: Atom): void {
const n = this.nodes[node]!;
if (n.atoms.has(atom)) return;
if (n.wide === true) {
this.escape(atom, "a value set too large to track");
return;
}
n.atoms.add(atom);
// Widening: a value set this large is merged code (a utility every
// value flows through). It becomes ⊤: its values escape, so the facts
// stay sound, and the solver stops multiplying constraints per atom.
if (n.atoms.size > WIDEN_AT) {
n.wide = true;
for (const a of n.atoms) this.escape(a, "a value set too large to track");
if (!n.atoms.has(this.top)) {
n.atoms.add(this.top);
this.worklist.push([node, this.top]);
}
}
if (this.traceTop && atom === this.top && !this.topCause.has(node))
this.topCause.set(
node,
@@ -542,7 +683,17 @@ class Analyzer {
return this.atoms.intern(key, () => ({ kind: "builtin", label: path, path, receiver }));
}
private solveMs = 0;
solve(): void {
const started = this.profiling ? performance.now() : 0;
try {
this.solveLoop();
} finally {
if (this.profiling) this.solveMs += performance.now() - started;
}
}
private solveLoop(): void {
while (this.worklist.length > 0) {
if (++this.steps > MAX_STEPS) {
this.budgetExceeded = true;
@@ -550,6 +701,10 @@ class Analyzer {
}
const [id, atom] = this.worklist.pop()!;
const n = this.nodes[id]!;
if (this.profiling) {
this.popsByAtom.set(atom, (this.popsByAtom.get(atom) ?? 0) + 1);
this.popsByNode.set(id, (this.popsByNode.get(id) ?? 0) + 1);
}
this.cause = id;
for (const s of n.succ) this.add(s, atom);
this.cause = null;
@@ -765,7 +920,22 @@ class Analyzer {
// ---- symbols -----------------------------------------------------------------
private checkerMs = 0;
private checkerCalls = 0;
private readonly resolved = new Map<Node, Node | undefined>();
resolve(id: Node): Node | undefined {
if (this.resolved.has(id)) return this.resolved.get(id);
const started = this.profiling ? performance.now() : 0;
const out = this.resolveUncached(id);
if (this.profiling) {
this.checkerMs += performance.now() - started;
this.checkerCalls++;
}
this.resolved.set(id, out);
return out;
}
private resolveUncached(id: Node): Node | undefined {
let sym = this.checker.getSymbolAtLocation(id);
if (sym === undefined) return undefined;
if (sym.flags & ts.SymbolFlags.Alias) {
@@ -2198,8 +2368,15 @@ class Analyzer {
return;
case "prim":
if (name === "length" && a.label === "string") this.add(dst, this.prim("number"));
else if (a.label === "string" || a.label === "number" || a.label === "boolean")
this.add(dst, this.builtin(`${a.label}.${name}`, a));
else if (a.label === "string" && /^\d+$/.test(name)) {
this.add(dst, this.prim("string"));
this.add(dst, this.prim("undefined"));
} else if (a.label === "string" || a.label === "number" || a.label === "boolean") {
// A name the primitive's prototype chain lacks reads undefined.
if (PRIMITIVE_MEMBERS[a.label].has(name) || OBJECT_PROTOTYPE_MEMBERS.has(name))
this.add(dst, this.builtin(`${a.label}.${name}`, a));
else this.add(dst, this.prim("undefined"));
}
return;
case "instance": {
const decl = a.node!;
@@ -2358,6 +2535,7 @@ class Analyzer {
} else if (
a.receiver === undefined &&
MODELED_GLOBALS.has(path.split(".")[0]!) &&
!path.endsWith(".instance") &&
path.split(".").length < 4
)
this.add(dst, this.builtin(`${path}.${name}`));
@@ -102,6 +102,10 @@ export function jsonCallbacks(k) {
export function revivedResult(text) { const r = { fromReviver: 1 }; return leak(JSON.parse(text, function () { return r; })); }
export function constructedProxy() { return Reflect.construct(Proxy, [{ constructedProxy: 1 }, {}]); }
export function globalProxy() { return new globalThis.Proxy({ globalProxy: 1 }, {}); }
function same(value) { return value; }
export function manyShapes() {
return [same({ w0: 0 }), same({ w1: 1 }), same({ w2: 2 }), same({ w3: 3 }), same({ w4: 4 }), same({ w5: 5 }), same({ w6: 6 }), same({ w7: 7 }), same({ w8: 8 }), same({ w9: 9 }), same({ w10: 10 }), same({ w11: 11 }), same({ w12: 12 }), same({ w13: 13 }), same({ w14: 14 }), same({ w15: 15 }), same({ w16: 16 }), same({ w17: 17 }), same({ w18: 18 }), same({ w19: 19 }), same({ w20: 20 }), same({ w21: 21 }), same({ w22: 22 }), same({ w23: 23 }), same({ w24: 24 }), same({ w25: 25 }), same({ w26: 26 }), same({ w27: 27 }), same({ w28: 28 }), same({ w29: 29 }), same({ w30: 30 }), same({ w31: 31 }), same({ w32: 32 }), same({ w33: 33 }), same({ w34: 34 }), same({ w35: 35 }), same({ w36: 36 }), same({ w37: 37 }), same({ w38: 38 }), same({ w39: 39 })].length;
}
`;
const packageTypes = `
@@ -125,6 +129,7 @@ export declare function jsonCallbacks(k: string): unknown;
export declare function revivedResult(text: string): unknown;
export declare function constructedProxy(): unknown;
export declare function globalProxy(): unknown;
export declare function manyShapes(): number;
`;
const mainSource = `
@@ -134,6 +139,7 @@ import {
computedField,
constructedProxy,
globalProxy,
manyShapes,
jsonCallbacks,
leak,
obj,
@@ -165,6 +171,7 @@ const more = [
revivedResult("{}"),
constructedProxy(),
globalProxy(),
manyShapes(),
];
console.log(more.length);
`;
@@ -284,6 +291,8 @@ test("S1 report follows values through array properties, unknown keys, callbacks
// a proxy constructed through Reflect.construct and globalThis
expect(site(lines, /^site \{constructedProxy\}@/)).toMatch(/: G — proxy at/);
expect(site(lines, /^site \{globalProxy\}@/)).toMatch(/: G — proxy at/);
// a value set past the widening limit: its values escape
expect(site(lines, /^site \{w0\}@/)).toMatch(/: P-escaping — escapes: a value set too large/);
// the existing verdicts hold
expect(site(lines, /^site \{kept\}@main\.ts/)).toMatch(/: P-escaping — escapes: stored into ⊤/);
});
+11 -1
View File
@@ -112,6 +112,16 @@ const KNOWN: Record<string, Known> = {
"workspace-unclean": { build: "SC2013" },
};
/** Where the overlay changes how a case KNOWN refuses: the operation it
* fails at on an argv list that fails in both builds. */
const OVERLAY_KNOWN: Record<string, Known> = {
// `--help` reaches an unsupported operation in both builds; with the
// overlay it fails at a checked conversion of an option value first
// (results/design-shape-inference/NOTES.md, open item).
"commander-calc": { run: "expected number at $, got undefined", argvs: [["--help"]] },
"commander-calc-async": { run: "expected number at $, got undefined", argvs: [["--help"]] },
};
/** Per-case timeouts past the default 120 s: the undici cases compile the
* built-in undici implementation's module graph, the zod 3 cases (zod3,
* zod3-adoption, zod3-boundary, zod3-infer) compile zod 3's 3,700-line
@@ -234,7 +244,7 @@ export function defineNpmSuite(options: { label: string; shapeOverlay?: boolean
describe(`${label} (${cases.length} programs${sanitize ? ", sanitized" : ""}${shardSuffix()})`, () => {
for (const c of cases) {
const known = KNOWN[c.name];
const known = (shapeOverlay === true ? OVERLAY_KNOWN[c.name] : undefined) ?? KNOWN[c.name];
if (known !== undefined && "divergence" in known) {
test.fails(`${c.name} (diverges: ${known.divergence})`, async () => {
const built = await build(c.entry, shapeOverlay);