Report constant object graphs in untyped JavaScript (static objects, stage 1)

SCRIPTC_DEBUG_SHAPES=const reports the constant object graphs shape
inference finds (DESIGN-constant-objects.md, stage 1): objects created by
code that runs at most once (module top level, IIFEs, functions reached
only through one call site from such code) whose fields are written only
there, that never meet a reflective or computed write, and that never
escape. Namespaces (`var ns = ns || {}; ns.util = {}`), revealing
modules, `module.exports` tables, and ES5 constructors' prototype objects
qualify. Each near miss names its rule: allocated repeatedly, created by an
init factory (one object per call site), escapes, reflective or computed
write, literal form, written after init, or lazy cache. The report counts
the member reads and calls a later stage can fold.

The analysis now models ES5 constructors (a function's prototype object,
instances reading through it) and links CommonJS require() and export
bindings to the analyzed module's exports, which it previously read as
nothing. Code generation is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-24 13:53:14 -07:00
co-authored by Claude Opus 5.5
parent 50ccfbcbe2
commit d837036f87
4 changed files with 642 additions and 7 deletions
@@ -100,6 +100,8 @@ export interface CallEdge {
readonly caller: FunctionInfo | null;
readonly callee: FunctionInfo;
readonly inLoop: boolean;
/** The call (or `new`, getter read, builtin callback) site. */
readonly site: Node;
}
export interface ShapeAnalysis {
@@ -123,6 +125,10 @@ export interface ShapeAnalysis {
readonly steps: number;
readonly topCause: ReadonlyMap<number, number | string>;
readonly nodeLabel: ReadonlyMap<number, string>;
/** Functions unknown code may call (their atoms escaped). */
readonly escapedFunctions: ReadonlySet<FunctionInfo>;
/** ES5 constructors: each function's default prototype object. */
readonly protoOf: ReadonlyMap<Atom, Atom>;
}
/** Array.prototype members: other names on an array are own properties. */
@@ -298,6 +304,7 @@ class Analyzer {
caller: ctx?.caller ?? null,
callee,
inLoop: (ctx?.inLoop ?? false) || this.forceLoop,
site,
});
}
@@ -340,6 +347,8 @@ class Analyzer {
steps: this.steps,
topCause: this.topCause,
nodeLabel: this.nodeLabel,
escapedFunctions: this.escapedFunctionCalls,
protoOf: this.protoOf,
};
}
@@ -413,6 +422,25 @@ class Analyzer {
return a;
}
/** ES5 constructor functions: the function's default prototype object,
* and which function each `new F()` instance atom belongs to. */
readonly protoOf = new Map<Atom, Atom>();
readonly fnOfInstance = new Map<Atom, Atom>();
protoAtom(fn: Atom): Atom {
let p = this.protoOf.get(fn);
if (p === undefined) {
p = this.atoms.intern(`proto:${this.nodeKey(fn.node!)}:${fn.ctx ?? ""}`, () => ({
kind: "object",
label: `${fn.label}.prototype`,
node: fn.node,
}));
this.protoOf.set(fn, p);
this.add(this.field(p, "constructor"), fn);
if (this.escaped.has(fn)) this.escape(p, `via ${fn.label}`);
}
return p;
}
builtin(path: string, receiver?: Atom): Atom {
const key = receiver === undefined ? `builtin:${path}` : `builtin:${path}@${receiver.id}`;
return this.atoms.intern(key, () => ({ kind: "builtin", label: path, path, receiver }));
@@ -477,6 +505,11 @@ class Analyzer {
if (this.escaped.has(atom)) return;
this.escaped.set(atom, reason);
const via = `via ${atom.label}`;
// Unknown code reaches a constructor's prototype through the function
// and through its instances.
const ctor = atom.kind === "function" ? atom : this.fnOfInstance.get(atom);
if (ctor !== undefined && ctor.node !== undefined && (atom !== ctor || this.protoOf.has(ctor)))
this.escape(this.protoAtom(ctor), via);
const fm = this.fields.get(atom);
if (fm !== undefined) {
for (const [name, id] of fm) {
@@ -1424,6 +1457,15 @@ class Analyzer {
)
return this.constNode(this.top);
if (ts.isSourceFile(decl) || ts.isModuleDeclaration(decl)) return this.namespaceNode(decl);
// A CommonJS export the checker resolved an import or require binding
// to (`module.exports = { name }`, `exports.name = v`): the member of the
// module's exports.
const exported = cjsExportName(decl);
if (exported !== null && decl.getSourceFile() !== id.getSourceFile()) {
const dst = this.newNode();
this.loadConstraint(this.namespaceNode(decl.getSourceFile()), exported, dst, id);
return dst;
}
return this.narrowed(id, decl, this.binding(decl));
}
@@ -1531,6 +1573,9 @@ class Analyzer {
}
private readonly namespaces = new Map<Node, number>();
/** Module namespaces: a CommonJS module's `module.exports` value is the
* namespace's `exports` field, and member reads see its members too. */
private readonly namespaceAtoms = new Set<Atom>();
/** `import * as ns`: an object whose fields are the module's exports. */
namespaceNode(mod: Node): number {
const existing = this.namespaces.get(mod);
@@ -1542,6 +1587,7 @@ class Analyzer {
label: `namespace ${shortFile((mod as ts.SourceFile).fileName ?? "")}`,
node: mod,
}));
this.namespaceAtoms.add(atom);
this.add(n, atom);
const sym = this.checker.getSymbolAtLocation(mod);
if (sym !== undefined) {
@@ -1983,8 +2029,11 @@ class Analyzer {
case "function":
if (name === "call" || name === "apply" || name === "bind")
this.add(dst, this.builtin(`function.${name}`, a));
else if (name === "prototype") this.add(dst, this.top);
else if (name === "name") this.add(dst, this.prim("string"));
else if (name === "prototype") {
// The default prototype object, or what the program assigns.
this.add(dst, this.protoAtom(a));
this.edge(this.field(a, "prototype"), dst);
} else if (name === "name") this.add(dst, this.prim("string"));
else if (name === "length") this.add(dst, this.prim("number"));
else this.edge(this.field(a, name), dst);
return;
@@ -1994,12 +2043,28 @@ class Analyzer {
else if (ARRAY_METHODS.has(name)) this.add(dst, this.builtin(`array.${name}`, a));
else this.edge(this.field(a, name), dst);
return;
case "object":
case "object": {
this.edge(this.field(a, name), dst);
this.edge(this.anyField(a), dst);
if (this.namespaceAtoms.has(a) && name !== "exports") {
this.loadConstraint(this.field(a, "exports"), name, dst, site);
}
// `new F()` instances read through F's prototype objects.
const fn = this.fnOfInstance.get(a);
if (fn !== undefined) {
const protos = this.newNode();
this.add(protos, this.protoAtom(fn));
this.edge(this.field(fn, "prototype"), protos);
this.on(protos, (proto) => {
if (proto.kind === "object") this.edge(this.field(proto, name), dst);
else if (proto.kind === "top") this.add(dst, this.top);
});
return;
}
if (name === "constructor" || name === "hasOwnProperty" || name === "toString")
this.add(dst, this.builtin(`object.${name}`, a));
return;
}
case "dictionary":
this.edge(this.anyField(a), dst);
this.edge(this.field(a, name), dst);
@@ -2190,6 +2255,27 @@ class Analyzer {
for (const a of args) this.sink(a, `super(...) at ${locLabel(e)}`);
return dst;
}
// CommonJS `require("<spec>")` of an analyzed module: its exports (the
// module's namespace, which holds `module.exports` as its `exports`
// field and the checker's view of its exports).
if (ts.isIdentifier(callee) && callee.text === "require" && e.arguments.nodes.length === 1) {
const spec = e.arguments.nodes[0]!;
if (ts.isStringLiteral(spec)) {
let target: Node | undefined;
try {
const sym = this.checker.getSymbolAtLocation(spec);
target = sym?.valueDeclaration ?? sym?.declarations[0];
} catch {
target = undefined;
}
if (target !== undefined && ts.isSourceFile(target) && this.files.includes(target)) {
const ns = this.namespaceNode(target);
this.edge(ns, dst);
this.on(ns, (a) => this.edge(this.field(a, "exports"), dst));
return dst;
}
}
}
if (ts.isPropertyAccessExpression(callee) || ts.isElementAccessExpression(callee)) {
const recvExpr = unparen(callee.expression);
const { args, spread } = this.args(e);
@@ -2286,6 +2372,7 @@ class Analyzer {
label: `new ${a.label}`,
node: a.node,
}));
this.fnOfInstance.set(inst, a);
this.add(dst, inst);
const info = this.infoOfAtom(a);
if (info !== undefined) {
@@ -2862,3 +2949,21 @@ class Analyzer {
}
}
}
/** The export name of a CommonJS export declaration the checker resolves
* bindings to: a member of the `module.exports` literal, or the target of
* `exports.name =` / `module.exports.name =`. */
function cjsExportName(decl: Node): string | null {
if (ts.isShorthandPropertyAssignment(decl) || ts.isPropertyAssignment(decl)) {
const n = decl.name;
return ts.isIdentifier(n) || ts.isStringLiteral(n) ? n.text : null;
}
if (ts.isPropertyAccessExpression(decl) && ts.isIdentifier(decl.name)) return decl.name.text;
if (
ts.isBinaryExpression(decl) &&
ts.isPropertyAccessExpression(decl.left) &&
ts.isIdentifier(decl.left.name)
)
return decl.left.name.text;
return null;
}
@@ -0,0 +1,421 @@
/* Static-object specialization, stage 1 (DESIGN-constant-objects.md):
* constant object graphs.
*
* An object literal is a SINGLETON when the code that creates it runs at
* most once: at module top level, or in a function every call path to which
* starts at module evaluation through exactly one call site outside loops
* (IIFEs, revealing modules, init helpers). A singleton's field is CONSTANT
* when every store to it runs in such once-only code, no reflective or
* computed write reaches the object, and the object never escapes to code
* the analysis cannot see. A constant object has only constant fields and a
* fixed key set; constant objects whose constant fields point at each other
* form a constant graph (`ns`, `ns.util`, `ns.util.trim`).
*
* This stage computes the graphs and reports them, with the rule that
* demoted every near miss, and the reads and calls a later stage could fold.
* Nothing reaches code generation. */
import * as ts from "../ts7/adapter.js";
import type { Atom } from "./domain.js";
import type { FunctionInfo, Op, ShapeAnalysis } from "./analyze.js";
import { npmStaticPackageOfPath } from "../npm-static.js";
type Node = ts.Node;
export interface ConstantField {
readonly key: string;
/** Distinct atoms the field may hold. */
readonly values: readonly Atom[];
/** Stores (the literal's own member included). */
readonly stores: number;
}
/** An object the analysis tracks as one allocation: an object literal, a
* function object (a namespace or an ES5 constructor), or a constructor's
* default prototype object. */
export interface Candidate {
readonly atom: Atom;
/** The syntax that creates it (the literal or the function). */
readonly node: Node;
readonly kind: "literal" | "function" | "prototype";
/** Keys the creating syntax itself defines. */
readonly keys: readonly string[];
/** Whether the creating syntax is a plain data literal. */
readonly plain: boolean;
readonly inPackage: boolean;
}
export interface ConstantObject {
readonly site: Candidate;
readonly fields: readonly ConstantField[];
readonly frozen: boolean;
}
export interface NearMiss {
readonly site: Candidate;
/** Null when the whole object was demoted. */
readonly key: string | null;
readonly rule:
| "init-factory"
| "allocated-repeatedly"
| "escapes"
| "reflective-write"
| "computed-write"
| "literal-form"
| "written-after-init"
| "lazy-cache";
readonly detail: string;
}
export interface ConstantGraphs {
readonly objects: readonly ConstantObject[];
readonly nearMisses: readonly NearMiss[];
/** Reads `e.k` whose receiver is exactly one constant object and whose
* field is constant, and the subset that are callees. */
readonly foldableReads: number;
readonly foldableCalls: number;
/** Reads of a constant field through a receiver that may also be other
* values. */
readonly mixedReads: number;
readonly once: (fn: Node) => boolean;
}
function isLoop(n: Node): boolean {
return (
ts.isForStatement(n) ||
ts.isForInStatement(n) ||
ts.isForOfStatement(n) ||
ts.isWhileStatement(n) ||
ts.isDoStatement(n)
);
}
/** The function (or source file) whose body contains `n`. */
function ownerFunction(n: Node): Node {
for (let p = n.parent; p !== undefined; p = p.parent) {
if (ts.isSourceFile(p)) return p;
if (ts.isFunctionLike(p) && (p as { body?: Node }).body !== undefined) return p;
}
return n.getSourceFile();
}
/** Whether `n` sits in a loop of its own function's body. */
function inLoop(n: Node, owner: Node): boolean {
for (let p = n.parent; p !== undefined && p !== owner; p = p.parent) if (isLoop(p)) return true;
return false;
}
function loc(n: Node): string {
const sf = n.getSourceFile();
const file = sf.fileName.split("/node_modules/").pop() ?? sf.fileName;
return `${file.split("/").slice(-2).join("/")}:${sf.getLineAndCharacterOfPosition(ts.tokenStartOfNode(n)).line + 1}`;
}
/** `x.k || (x.k = v)`, `x.k ??= v`, `x.k ||= v`, `if (!x.k) x.k = v`. */
function isCacheStore(write: Node): boolean {
let assign: Node | undefined = write.parent;
while (assign !== undefined && ts.isParenthesizedExpression(assign)) assign = assign.parent;
if (assign === undefined || !ts.isBinaryExpression(assign)) return false;
const op = assign.operatorToken.kind;
if (op === ts.SyntaxKind.BarBarEqualsToken || op === ts.SyntaxKind.QuestionQuestionEqualsToken)
return true;
if (op !== ts.SyntaxKind.EqualsToken) return false;
const text = write.getText();
let up: Node | undefined = assign.parent;
while (up !== undefined && ts.isParenthesizedExpression(up)) up = up.parent;
if (
up !== undefined &&
ts.isBinaryExpression(up) &&
(up.operatorToken.kind === ts.SyntaxKind.BarBarToken ||
up.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) &&
up.left.getText() === text
)
return true;
for (let p: Node | undefined = assign.parent; p !== undefined; p = p.parent) {
if (ts.isIfStatement(p)) {
const cond = p.expression.getText().replace(/\s+/g, "");
return cond === `!${text}` || cond === `${text}===undefined` || cond === `${text}==null`;
}
if (ts.isFunctionLike(p) || ts.isSourceFile(p)) break;
}
return false;
}
const OBJECT_DEMOTING: ReadonlySet<Op["kind"]> = new Set([
"computedWrite",
"delete",
"defineProperty",
"setPrototypeOf",
"assign",
"proxy",
]);
export function computeConstantGraphs(analysis: ShapeAnalysis): ConstantGraphs {
const infosByNode = new Map<Node, FunctionInfo[]>();
for (const info of analysis.functions.values()) {
let l = infosByNode.get(info.node);
if (l === undefined) infosByNode.set(info.node, (l = []));
l.push(info);
}
const edgesTo = new Map<Node, (typeof analysis.calls)[number][]>();
for (const e of analysis.calls) {
let l = edgesTo.get(e.callee.node);
if (l === undefined) edgesTo.set(e.callee.node, (l = []));
l.push(e);
}
const escapedNodes = new Set<Node>([...analysis.escapedFunctions].map((f) => f.node));
const onceMemo = new Map<Node, boolean>();
const once = (fn: Node): boolean => {
if (ts.isSourceFile(fn)) return true;
const known = onceMemo.get(fn);
if (known !== undefined) return known;
onceMemo.set(fn, false); // a cycle is not once-only
let result = false;
const edges = edgesTo.get(fn) ?? [];
if (
infosByNode.has(fn) &&
!escapedNodes.has(fn) &&
edges.length > 0 &&
new Set(edges.map((e) => e.site)).size === 1
) {
result = edges.every(
(e) =>
!e.inLoop && e.caller !== null && !inLoop(e.site, e.caller.node) && once(e.caller.node),
);
}
onceMemo.set(fn, result);
return result;
};
// Whether every call of a function comes from once-only code outside
// loops, through any number of call sites: an init factory, whose
// allocations are one object per call site (schema builders called at
// module initialization).
const initOnlyMemo = new Map<Node, boolean>();
const initOnly = (fn: Node): boolean => {
if (ts.isSourceFile(fn)) return true;
const known = initOnlyMemo.get(fn);
if (known !== undefined) return known;
initOnlyMemo.set(fn, false);
const edges = edgesTo.get(fn) ?? [];
const result =
infosByNode.has(fn) &&
!escapedNodes.has(fn) &&
edges.length > 0 &&
edges.every(
(e) =>
!e.inLoop &&
e.caller !== null &&
!inLoop(e.site, e.caller.node) &&
(once(e.caller.node) || initOnly(e.caller.node)),
);
initOnlyMemo.set(fn, result);
return result;
};
const opsByAtom = new Map<Atom, Op[]>();
for (const op of analysis.ops) {
for (const a of analysis.nodes[op.recv]!.atoms) {
let l = opsByAtom.get(a);
if (l === undefined) opsByAtom.set(a, (l = []));
l.push(op);
}
}
const objects: ConstantObject[] = [];
const nearMisses: NearMiss[] = [];
const constantKeys = new Map<Atom, Set<string>>();
const candidates: Candidate[] = [];
for (const site of analysis.sites) {
if (site.kind !== "object") continue;
candidates.push({
atom: site.atom,
node: site.node,
kind: "literal",
keys: site.keys ?? [],
plain: !(
site.hasAccessor ||
site.hasComputedKey ||
site.spreads.length > 0 ||
site.keys === null
),
inPackage: site.inPackage,
});
}
const inPackage = (n: Node): boolean =>
npmStaticPackageOfPath(n.getSourceFile().fileName) !== null;
for (const atom of analysis.atoms.atoms) {
if (atom.kind !== "function" || atom.node === undefined) continue;
const hasFields = (analysis.fields.get(atom)?.size ?? 0) > 0;
const proto = analysis.protoOf.get(atom);
if (!hasFields && proto === undefined) continue;
candidates.push({
atom,
node: atom.node,
kind: "function",
keys: [],
plain: true,
inPackage: inPackage(atom.node),
});
if (proto !== undefined)
candidates.push({
atom: proto,
node: atom.node,
kind: "prototype",
keys: ["constructor"],
plain: true,
inPackage: inPackage(atom.node),
});
}
for (const site of candidates) {
// A function object is created where its expression or declaration
// runs; its prototype with it.
const owner = ownerFunction(site.node);
if (!once(owner) || inLoop(site.node, owner)) {
const factory = !inLoop(site.node, owner) && initOnly(owner);
if (site.inPackage || factory)
nearMisses.push({
site,
key: null,
rule: factory ? "init-factory" : "allocated-repeatedly",
detail: factory
? `one object per call of ${loc(owner)} (${new Set((edgesTo.get(owner) ?? []).map((e) => e.site)).size} call sites)`
: `created in ${owner.kind === ts.SyntaxKind.SourceFile ? "a module loop" : loc(owner)}`,
});
continue;
}
const escaped = analysis.escaped.get(site.atom);
if (escaped !== undefined) {
nearMisses.push({ site, key: null, rule: "escapes", detail: escaped });
continue;
}
if (!site.plain) {
nearMisses.push({
site,
key: null,
rule: "literal-form",
detail: "accessor, computed key or spread in the literal",
});
continue;
}
const ops = opsByAtom.get(site.atom) ?? [];
const demoting = ops.find((op) => OBJECT_DEMOTING.has(op.kind));
if (demoting !== undefined) {
nearMisses.push({
site,
key: null,
rule: demoting.kind === "computedWrite" ? "computed-write" : "reflective-write",
detail: `${demoting.kind} at ${loc(demoting.node)}`,
});
continue;
}
const frozen = ops.some((op) => op.kind === "freeze");
const stores = new Map<string, number>();
for (const k of site.keys) stores.set(k, 1);
// A function's own data fields come only from stores.
const demotedKeys = new Map<string, NearMiss>();
for (const op of ops) {
// A `length` store on an object (not an array) is an ordinary write.
if ((op.kind !== "write" && op.kind !== "lengthWrite") || op.name === null) continue;
stores.set(op.name, (stores.get(op.name) ?? 0) + 1);
const fn = ownerFunction(op.node);
if (once(fn) && !inLoop(op.node, fn)) continue;
if (demotedKeys.has(op.name)) continue;
demotedKeys.set(op.name, {
site,
key: op.name,
rule: isCacheStore(op.node) ? "lazy-cache" : "written-after-init",
detail: loc(op.node),
});
}
for (const miss of demotedKeys.values()) nearMisses.push(miss);
const fieldMap = analysis.fields.get(site.atom) ?? new Map<string, number>();
const fields: ConstantField[] = [];
const keys = new Set<string>();
for (const [key, count] of stores) {
if (demotedKeys.has(key)) continue;
const id = fieldMap.get(key);
const values = id === undefined ? [] : [...analysis.nodes[id]!.atoms];
fields.push({ key, values, stores: count });
keys.add(key);
}
constantKeys.set(site.atom, keys);
if (demotedKeys.size === 0) objects.push({ site, fields, frozen });
}
let foldableReads = 0,
foldableCalls = 0,
mixedReads = 0;
for (const op of analysis.ops) {
if (op.kind !== "read" || op.name === null) continue;
const atoms = analysis.nodes[op.recv]!.atoms;
const constant = [...atoms].filter((a) => constantKeys.get(a)?.has(op.name!) === true);
if (constant.length === 0) continue;
if (atoms.size === 1) {
foldableReads++;
const p = op.node.parent;
if (p !== undefined && ts.isCallExpression(p) && p.expression === op.node) foldableCalls++;
} else mixedReads++;
}
return { objects, nearMisses, foldableReads, foldableCalls, mixedReads, once };
}
/** Report lines (SCRIPTC_DEBUG_SHAPES=const): per package, the constant
* graphs with their roots, the near misses by rule, and the foldable reads. */
export function constantGraphReport(analysis: ShapeAnalysis, graphs: ConstantGraphs): string[] {
const lines: string[] = [];
const pkgOf = (site: Candidate): string =>
npmStaticPackageOfPath(site.node.getSourceFile().fileName) ?? "<user>";
const byPkg = new Map<string, { objects: ConstantObject[]; misses: NearMiss[] }>();
const entry = (pkg: string) => {
let e = byPkg.get(pkg);
if (e === undefined) byPkg.set(pkg, (e = { objects: [], misses: [] }));
return e;
};
for (const o of graphs.objects) entry(pkgOf(o.site)).objects.push(o);
for (const m of graphs.nearMisses) entry(pkgOf(m.site)).misses.push(m);
const constantAtoms = new Set(graphs.objects.map((o) => o.site.atom));
const referenced = new Set<Atom>();
for (const o of graphs.objects)
for (const f of o.fields)
if (f.values.length === 1 && constantAtoms.has(f.values[0]!)) referenced.add(f.values[0]!);
const describe = (o: ConstantObject, depth: number, seen: Set<Atom>): string => {
const parts = o.fields.map((f) => {
if (f.values.length !== 1)
return `${f.key}: ${f.values.length === 0 ? "?" : f.values.map((a) => a.label).join("|")}`;
const v = f.values[0]!;
const child = graphs.objects.find((x) => x.site.atom === v);
if (child !== undefined && depth < 3 && !seen.has(v)) {
seen.add(v);
return `${f.key}: ${describe(child, depth + 1, seen)}`;
}
return `${f.key}: ${v.kind === "function" && child === undefined ? "fn" : v.label}`;
});
const shown = parts.length > 8 ? [...parts.slice(0, 8), `… ${parts.length - 8} more`] : parts;
return `{${shown.join(", ")}}${o.frozen ? " (frozen)" : ""}`;
};
lines.push(
`analysis ${analysis.steps} steps${analysis.budgetExceeded ? " (BUDGET EXCEEDED: facts incomplete)" : ""}, ${analysis.files.length} files`,
);
lines.push(
`constant objects ${graphs.objects.length}, near misses ${graphs.nearMisses.length}; foldable reads ${graphs.foldableReads} (calls ${graphs.foldableCalls}), mixed-receiver reads ${graphs.mixedReads}`,
);
for (const [pkg, e] of [...byPkg].sort((a, b) => a[0].localeCompare(b[0]))) {
const fields = e.objects.reduce((n, o) => n + o.fields.length, 0);
const rules = new Map<string, number>();
for (const m of e.misses) rules.set(m.rule, (rules.get(m.rule) ?? 0) + 1);
lines.push(
`${pkg}: ${e.objects.length} constant objects (${fields} fields); near misses ${[...rules].map(([r, n]) => `${r} ${n}`).join(", ") || "none"}`,
);
for (const o of e.objects) {
if (referenced.has(o.site.atom)) continue;
lines.push(` graph ${loc(o.site.node)} ${describe(o, 0, new Set([o.site.atom]))}`);
}
for (const m of e.misses.filter((x) => x.rule !== "allocated-repeatedly").slice(0, 40)) {
lines.push(
` near miss ${loc(m.site.node)}${m.key === null ? "" : ` .${m.key}`}: ${m.rule} (${m.detail})`,
);
}
}
return lines;
}
@@ -3,6 +3,7 @@
import type * as ts from "../ts7/adapter.js";
import { analyzeShapes } from "./analyze.js";
import { buildShapeReport } from "./report.js";
import { computeConstantGraphs, constantGraphReport } from "./constant-graphs.js";
export { analyzeShapes } from "./analyze.js";
export { buildShapeReport } from "./report.js";
@@ -13,9 +14,11 @@ export { buildShapeReport } from "./report.js";
export function reportShapes(program: ts.Program, filter: string): void {
const start = performance.now();
const analysis = analyzeShapes(program, program.getTypeChecker());
const report = buildShapeReport(analysis, filter);
// `const`: the constant object graphs (DESIGN-constant-objects.md, stage 1).
const lines =
filter === "const"
? constantGraphReport(analysis, computeConstantGraphs(analysis))
: buildShapeReport(analysis, filter).lines;
const ms = (performance.now() - start).toFixed(0);
process.stderr.write(
`[shapes] ${ms} ms\n${report.lines.map((l) => `[shapes] ${l}`).join("\n")}\n`,
);
process.stderr.write(`[shapes] ${ms} ms\n${lines.map((l) => `[shapes] ${l}`).join("\n")}\n`);
}
@@ -0,0 +1,106 @@
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";
// A library in the pre-module idiom: guarded namespace declarations, a
// revealing module, an ES5 constructor with prototype methods, and the
// cases that keep an object from being constant.
const packageSource = `
var lib = lib || {};
lib.math = lib.math || {};
lib.math.TAU = 6.283185307179586;
lib.math.half = function (x) { return x / 2; };
lib.text = (function () {
function upper(s) { return s.toUpperCase(); }
return { upper: upper };
})();
lib.Point = function (x, y) { this.x = x; this.y = y; };
lib.Point.prototype.norm = function () { return Math.sqrt(this.x * this.x + this.y * this.y); };
lib.counter = { hits: 0 };
lib.bump = function () { lib.counter.hits++; };
lib.cache = { table: null };
lib.lookup = function () { return lib.cache.table || (lib.cache.table = [1, 2, 3]); };
lib.scratch = { a: 1, b: 2 };
delete lib.scratch.b;
lib.shared = { value: 1 };
globalThis.sharedFromLib = lib.shared;
function option(name) { return { name: name }; }
lib.options = { first: option("a"), second: option("b") };
module.exports = { lib };
`;
const mainSource = `
const { lib } = require("ns-fixture");
let total = 0;
for (let i = 0; i < 3; i++) {
total += lib.math.half(i) + new lib.Point(i, i).norm() + lib.lookup().length;
lib.bump();
}
console.log(total, lib.text.upper("x"), lib.counter.hits);
`;
let dir: string;
let saved: string | undefined;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "scriptc-constant-graphs-"));
const pkg = join(dir, "node_modules", "ns-fixture");
await mkdir(pkg, { recursive: true });
await writeFile(
join(pkg, "package.json"),
JSON.stringify({ name: "ns-fixture", version: "1.0.0", main: "index.js", types: "index.d.ts" }),
);
await writeFile(join(pkg, "index.js"), packageSource);
await writeFile(join(pkg, "index.d.ts"), "declare const lib: any;\nexport { lib };\n");
await writeFile(
join(dir, "package.json"),
JSON.stringify({ dependencies: { "ns-fixture": "1.0.0" } }),
);
await writeFile(join(dir, "main.cjs"), mainSource);
saved = process.env["SCRIPTC_DEBUG_SHAPES"];
});
afterEach(async () => {
if (saved === undefined) delete process.env["SCRIPTC_DEBUG_SHAPES"];
else process.env["SCRIPTC_DEBUG_SHAPES"] = saved;
vi.restoreAllMocks();
await rm(dir, { recursive: true, force: true });
});
async function report(): Promise<string[]> {
process.env["SCRIPTC_DEBUG_SHAPES"] = "const";
const lines: string[] = [];
vi.spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array) => {
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
for (const l of text.split("\n")) if (l.startsWith("[shapes] ")) lines.push(l.slice(9));
return true;
});
await compile(join(dir, "main.cjs"), {
outDir: dir,
outPath: join(dir, "main.ir.json"),
outputKind: "ir",
npmStatic: ["ns-fixture"],
});
return lines;
}
test("constant graphs: namespaces, revealing modules and prototypes, with near misses", async () => {
const lines = await report();
const text = lines.join("\n");
// The namespace graph: guarded declarations, the revealing module's
// table, and the constructor function.
expect(text).toMatch(
/graph ns-fixture\/index\.js:\d+ \{lib: \{math: \{TAU: number, half: fn\}, text: \{upper: fn\}/,
);
// The constructor's prototype object, built by member stores at init.
expect(text).toMatch(/graph ns-fixture\/index\.js:\d+ \{constructor: \{\}, norm: fn\}/);
// A field written by a function called from a loop.
expect(text).toMatch(/near miss ns-fixture\/index\.js:12 \.hits: written-after-init/);
// A lazily filled cache.
expect(text).toMatch(/near miss ns-fixture\/index\.js:14 \.table: lazy-cache/);
// A deleted key, and an object stored into the global object.
expect(text).toMatch(/near miss ns-fixture\/index\.js:16: reflective-write \(delete/);
expect(text).toMatch(/near miss ns-fixture\/index\.js:18: escapes/);
// 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\)/);
});