fix(javascript): bound static getter reference expansion

Replace recursive getter histories with a query-owned worklist and memo. Bound
precise expansion at 512 states and widen growing receiver cycles; retain
escaped roots and constructed children rather than silently dropping suffixes.
Carry the resource limit through semantic coverage, worker transport and graph
Evidence while preserving direct writes, imports and unrelated literal facts.

The small source probe reduced 65/326 paths to one widened reference for five
and six getters. The original large Claude artifact was not rerun. Validate
receiver/alias/mutation controls and compiled CLI/MCP consumers.

Refs #3839, #3848

Co-authored-by: Yi Han <yhan606@aucklanduni.ac.nz>
This commit is contained in:
morluto
2026-10-16 06:06:00 +08:00
parent 461eb16128
commit 880b3a510b
17 changed files with 1308 additions and 524 deletions
@@ -22,6 +22,7 @@ import {
} from "../../domain/javascript/javascriptSemanticAnalysis.js";
import { parseJavaScriptSource } from "../../domain/javascript/javascriptSourceParser.js";
import { semanticCoverageResourceLimits } from "../../domain/javascript/javascriptSemanticCoverage.js";
import { getterExpansionSource } from "../../../tests/fixtures/javascriptGetterExpansion.js";
const SHA256 = "a".repeat(64);
@@ -177,13 +178,15 @@ it("preserves primitive candidate budget unknowns in semantic graph coverage", (
status: "partial",
resourceLimits: ["primitive-candidates"],
});
expect(graph.unknowns).toContainEqual(
expect(
graph.unknowns.filter(({ reason }) => reason === "resource-limit"),
).toEqual([
expect.objectContaining({
family: "data-flow",
reason: "resource-limit",
detail: expect.stringMatching(/primitive candidate budget exceeded/i),
}),
);
]);
});
it("preserves derived string-byte limit reasons in semantic graph unknowns", () => {
@@ -242,12 +245,14 @@ it("preserves deep expression resource reasons through graph projection", () =>
expect(semanticCoverageResourceLimits(ir.coverage)).toEqual([
"expression-depth",
]);
expect(graph.unknowns).toContainEqual(
expect(
graph.unknowns.filter(({ reason }) => reason === "resource-limit"),
).toEqual([
expect.objectContaining({
reason: "resource-limit",
detail: expect.stringMatching(/expression depth budget exceeded/i),
}),
);
]);
});
it("keeps ambiguous interprocedural flow out of the default traversal", () => {
@@ -473,6 +478,26 @@ const semanticNodeFor = (roleKey: string) => {
);
};
it("preserves getter expansion limits in semantic graph coverage", () => {
const source = getterExpansionSource();
const graph = graphFor(source, analyzeJavaScriptSemantics(source));
const module = graph.nodes.find(({ kind }) => kind === "module");
if (module === undefined) throw new Error("Expected module node");
expect(
graph.unknowns.filter(({ reason }) => reason === "resource-limit"),
).toEqual([
expect.objectContaining({
node_id: module.node_id,
family: "data-flow",
reason: "resource-limit",
detail: expect.stringMatching(
/getter expansion was conservatively bounded/i,
),
}),
]);
expect(graph.coverage.status).toBe("unknown");
});
it.each([
"const value = consume(Promise.resolve(1));",
"async function run() { await consume(Promise.resolve(1)); }",
@@ -34,8 +34,16 @@ import {
import { projectSemanticResources } from "./JavaScriptSemanticGraphResourceProjection.js";
import { projectSemanticObjects } from "./JavaScriptSemanticGraphObjectProjection.js";
import { projectSemanticValues } from "./JavaScriptSemanticGraphValueProjection.js";
import { inferredSemanticEvidenceAt } from "./JavaScriptSemanticGraphEvidence.js";
import { observedSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js";
import {
inferredSemanticEvidenceAt,
observedSemanticEvidence,
unknownSemanticEvidence,
} from "./JavaScriptSemanticGraphEvidence.js";
import {
semanticCoverageResourceLimits,
semanticResourceLimitCoverage,
} from "../../domain/javascript/javascriptSemanticCoverage.js";
import { semanticResourceLimitReason } from "../../domain/javascript/javascriptSemanticResourceLimits.js";
import {
partialApplicationCoverage,
type ApplicationCoverage,
@@ -465,6 +473,7 @@ function* projectFileSteps(
});
if (moduleNode === null) return [];
state.roots.add(moduleNode.node_id);
projectFileResourceLimits({ file, ir, state, moduleNode });
yield* projectElectronSemanticCallPaths(file, paths, state);
// Node creation keeps source order, which the per-file node budget relies
// on; yields between nodes do not change it.
@@ -553,6 +562,36 @@ function* projectFileSteps(
);
}
const projectFileResourceLimits = (
context: Pick<FileContext, "file" | "ir" | "state" | "moduleNode">,
): void => {
// Getter limits describe query effects without tagged values; other limits
// already project from binding or return-site values and must not be duplicated.
for (const limit of semanticCoverageResourceLimits(context.ir.coverage)) {
if (limit !== "getter-expansion") continue;
const unknown = createJavaScriptSemanticGraphUnknown(
{
node_id: context.moduleNode.node_id,
family: "data-flow",
relation_kinds: ["defines"],
reason: "resource-limit",
detail: semanticResourceLimitReason(limit),
candidate_node_ids: [],
evidence: {
...unknownSemanticEvidence(context.file, null),
coverage: partialApplicationCoverage(
semanticResourceLimitCoverage([limit]),
null,
),
limitations: [semanticResourceLimitReason(limit)],
},
},
context.state.evidenceContexts,
);
context.state.unknowns.set(unknown.unknown_id, unknown);
}
};
function* createReturnSiteNodes(
file: JavaScriptArtifactFile,
ir: JavaScriptSemanticIr,
@@ -0,0 +1,94 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import { onlyBinding } from "./javascriptSemanticAnalysis.fixture.js";
const permutationSource = (count: number, isStatic: boolean) => {
const getterPrefix = isStatic ? "static " : "";
const access = isStatic ? "Request.field0" : "new Request().field0";
const getters = Array.from(
{ length: count },
(_, index) =>
`${getterPrefix}get field${index}() { return this[key].field${index}; }`,
).join("\n");
return `
import imported from "dep";
const shared = { value: 1 };
class Request {
${getters}
${getterPrefix}get state() { return shared; }
}
consume(${access});
const result = shared.value;
const unrelated = 42;
`;
};
const expectRetainedFacts = (
ir: ReturnType<typeof analyzeJavaScriptSemantics>,
) => {
expect(onlyBinding(ir, "unrelated").value).toEqual({
status: "literal",
value: 42,
});
expect(onlyBinding(ir, "imported").provenance.status).toBe("module");
expect(ir.moduleLinks).toContainEqual(
expect.objectContaining({ kind: "import", specifier: "dep" }),
);
};
it.each([false, true])(
"bounds unknown-key %s getter permutations and preserves unrelated facts",
(isStatic) => {
const ir = analyzeJavaScriptSemantics(permutationSource(10, isStatic));
expect(ir.coverage).toMatchObject({
status: "partial",
omittedCount: null,
resourceLimits: ["getter-expansion"],
});
expect(onlyBinding(ir, "result").value.status).toBe("unknown");
expect(onlyBinding(ir, "result").provenance).toMatchObject({
status: "local",
origins: [],
});
expect(ir.limitations.join(" ")).toMatch(
/Getter expansion was conservatively bounded/,
);
expectRetainedFacts(ir);
},
);
it("keeps direct field writes through an escaped growing getter result", () => {
const ir = analyzeJavaScriptSemantics(`
import imported from "dep";
const shared = { x: 1 };
class Request {
slot = shared;
get grow() { return this.grow.more; }
}
new Request()[key].x = 2;
const result = shared.x;
const unrelated = 42;
`);
expect(onlyBinding(ir, "result").value.status).toBe("unknown");
expect(ir.coverage.resourceLimits).toEqual(["getter-expansion"]);
expectRetainedFacts(ir);
});
it("escapes constructed fields when a getter query widens its suffix", () => {
const ir = analyzeJavaScriptSemantics(`
import imported from "dep";
const shared = { value: 1 };
class Inner { alias = shared; }
class Outer {
get result() { return new Inner(); }
get grow() { return this.grow.more; }
}
new Outer()[key].alias.value = 2;
const result = shared.value;
const unrelated = 42;
`);
expect(onlyBinding(ir, "result").value.status).toBe("unknown");
expect(ir.coverage.resourceLimits).toEqual(["getter-expansion"]);
expectRetainedFacts(ir);
});
@@ -66,6 +66,7 @@ import {
} from "./javascriptElectronMemberWrites.js";
import { range } from "./javascriptStaticAnalysisHelpers.js";
import { semanticCoverage } from "./javascriptSemanticCoverage.js";
import { semanticResourceLimitReason } from "./javascriptSemanticResourceLimits.js";
import {
parseJavaScriptSource,
type ParsedJavaScriptSource,
@@ -200,9 +201,17 @@ export function* analyzeParsedJavaScriptSemanticsSteps(
...derived,
coverage: semanticCoverage(
parserPartial,
[...state.valueResourceLimits].sort(),
[
...new Set([
...state.valueResourceLimits,
...state.getterResourceLimits,
]),
].sort(),
),
limitations: [
...[...state.getterResourceLimits]
.sort()
.map(semanticResourceLimitReason),
...(parserPartial
? [
"The parser recovered from syntax errors; affected bindings are partial.",
@@ -698,6 +707,7 @@ const createState = (
program,
staticImportResolutionMode,
valueResourceLimits: new Set(),
getterResourceLimits: new Set(),
parentsByNode: new WeakMap(),
scopes: [root],
scopesById: new Map([[root.scopeId, root]]),
@@ -2,6 +2,7 @@ import type { JavaScriptSemanticResourceLimit } from "./javascriptSemanticValueT
import type { ApplicationCoverage } from "./javascriptApplicationEvidenceSchemas.js";
import {
SEMANTIC_EXPRESSION_DEPTH_LIMIT,
SEMANTIC_GETTER_EXPANSION_LIMIT,
SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT,
SEMANTIC_PRIMITIVE_JSON_BYTES_LIMIT,
} from "./javascriptSemanticResourceLimits.js";
@@ -62,6 +63,10 @@ const semanticResourceBounds: Record<
value: SEMANTIC_EXPRESSION_DEPTH_LIMIT,
unit: "depth",
},
"getter-expansion": {
value: SEMANTIC_GETTER_EXPANSION_LIMIT,
unit: "items",
},
};
/** Map semantic evaluator bounds to their caller-visible application coverage. */
@@ -0,0 +1,185 @@
import type * as t from "@babel/types";
import type { JavaScriptSemanticObjectReference as Reference } from "./javascriptSemanticObjectReferences.js";
import type { JavaScriptSemanticPropertyPath as PropertyPath } from "./javascriptSemanticPropertyPaths.js";
import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js";
import { SEMANTIC_GETTER_EXPANSION_LIMIT } from "./javascriptSemanticResourceLimits.js";
export interface GetterInvocation {
readonly getter: t.ObjectMethod | t.ClassMethod;
readonly owner: t.ObjectExpression | t.ClassDeclaration | t.ClassExpression;
readonly receiver?: GetterInvocation["owner"];
}
interface GetterAncestor {
readonly getter: t.Node;
readonly receiver: t.Node;
readonly length: number;
readonly parent?: GetterAncestor;
}
export interface GetterQueryJob {
readonly invocation: GetterInvocation;
readonly path: PropertyPath;
readonly escapes: boolean;
readonly ancestry: GetterAncestor;
}
/** References at different receiver allocations must not erase ownership. */
export class SemanticObjectReferenceSet {
readonly #nodes = new Map<t.Node, Map<string, Reference>>();
readonly #owners = new WeakMap<t.Node, number>();
#ownerCount = 0;
add(reference: Reference): void {
const owner = reference.ownerInitializer;
if (owner !== undefined && !this.#owners.has(owner))
this.#owners.set(owner, ++this.#ownerCount);
// Escaping a primitive cannot cover a write to its slot; retain both effects.
const key = JSON.stringify([
reference.path,
owner === undefined ? null : this.#owners.get(owner),
reference.escapes === true,
reference.widenedGetterResult === true,
]);
const paths =
this.#nodes.get(reference.node) ?? new Map<string, Reference>();
paths.set(key, reference);
this.#nodes.set(reference.node, paths);
}
values(): readonly Reference[] {
return [...this.#nodes.values()].flatMap((paths) => [...paths.values()]);
}
}
/** One syntax query owns its memo and worklist; no receiver evaluation is cached across mutations. */
export class SemanticGetterQuery {
#memo = new WeakMap<t.Node, WeakMap<t.Node, Set<string>>>();
#references = new SemanticObjectReferenceSet();
#pending: GetterQueryJob[] = [];
#current: GetterQueryJob | undefined;
#limited = false;
#visits = 0;
#fallback = false;
constructor(readonly state: JavaScriptSemanticAnalysisState) {}
retain(reference: Reference, escapes = false): void {
// Direct fields keep their write suffix; only getter jobs widen omitted paths.
this.#references.add({
...reference,
...(escapes ? { escapes: true } : {}),
...(this.#fallback && this.#current !== undefined
? { widenedGetterResult: true }
: {}),
});
}
#known(
invocation: GetterInvocation,
path: PropertyPath,
escapes: boolean,
): boolean {
const receiver = invocation.receiver ?? invocation.owner;
const receivers = this.#memo.get(invocation.getter);
const paths = receivers?.get(receiver);
const key = JSON.stringify([path, escapes]);
if (paths?.has(key)) return true;
if (!this.#fallback && this.#visits >= SEMANTIC_GETTER_EXPANSION_LIMIT) {
this.#limited = true;
return true;
}
this.#visits++;
const selections = paths ?? new Set<string>();
const owners = receivers ?? new WeakMap<t.Node, Set<string>>();
selections.add(key);
owners.set(receiver, selections);
this.#memo.set(invocation.getter, owners);
return false;
}
#ancestor(invocation: GetterInvocation): GetterAncestor | undefined {
const receiver = invocation.receiver ?? invocation.owner;
for (
let visit = this.#current?.ancestry;
visit !== undefined;
visit = visit.parent
)
if (visit.getter === invocation.getter && visit.receiver === receiver)
return visit;
return undefined;
}
#grows(invocation: GetterInvocation, path: PropertyPath): boolean {
if (this.#fallback) return false;
const previous = this.#ancestor(invocation);
if (previous === undefined || previous.length >= path.length) return false;
// Pruning by ancestry would make memo results depend on the first caller.
// Widen growing cycles; identical finite cycles are handled by the memo alone.
this.#limited = true;
return true;
}
schedule(
invocation: GetterInvocation,
path: PropertyPath,
escaped = false,
): void {
if (this.#limited) return;
const escapes =
escaped || this.#fallback || this.#current?.escapes === true;
const selection = this.#fallback ? [] : path;
if (this.#grows(invocation, selection)) return;
if (this.#known(invocation, selection, escapes)) return;
this.#pending.push({
invocation,
path: selection,
escapes,
ancestry: {
getter: invocation.getter,
receiver: invocation.receiver ?? invocation.owner,
length: selection.length,
...(this.#current === undefined
? {}
: { parent: this.#current.ancestry }),
},
});
}
#collect(
root: () => readonly Reference[],
expand: (job: GetterQueryJob) => void,
): void {
for (const reference of root()) this.retain(reference);
for (
let cursor = 0;
cursor < this.#pending.length && !this.#limited;
cursor++
) {
const job = this.#pending[cursor];
if (job === undefined) break;
this.#current = job;
expand(job);
}
this.#current = undefined;
}
run(
root: () => readonly Reference[],
expand: (job: GetterQueryJob) => void,
): readonly Reference[] {
this.#collect(root, expand);
if (this.#limited) {
this.state.getterResourceLimits.add("getter-expansion");
// Escaping each selected getter's result covers every omitted suffix.
// Canonical empty suffixes make the retry finite in receiver/getter pairs.
this.#fallback = true;
this.#limited = false;
this.#memo = new WeakMap();
this.#references = new SemanticObjectReferenceSet();
this.#pending = [];
this.#collect(root, expand);
}
return this.#references.values();
}
}
@@ -0,0 +1,354 @@
import * as t from "@babel/types";
import {
unwrapJavaScriptExpression,
semanticStaticPropertyKey,
} from "./javascriptAstValues.js";
import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js";
import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js";
import { semanticReferenceSourcesInactive } from "./javascriptSemanticReferenceSources.js";
import {
resolveSemanticBindingState,
type JavaScriptSemanticBindingState,
} from "./javascriptSemanticState.js";
import type { JavaScriptSemanticObjectReference as Reference } from "./javascriptSemanticObjectReferences.js";
import type { JavaScriptSemanticPropertyPath as PropertyPath } from "./javascriptSemanticPropertyPaths.js";
import type {
GetterQueryJob,
SemanticGetterQuery,
} from "./javascriptSemanticGetterQueries.js";
type ClassNode = t.ClassDeclaration | t.ClassExpression;
interface AliasVisit {
readonly identity: string;
readonly pathLength: number;
readonly parent?: AliasVisit;
}
interface GetterWork {
readonly node: t.Node;
readonly path: PropertyPath;
readonly aliases?: AliasVisit | undefined;
readonly escapes?: true;
readonly privateRead?: {
readonly node: t.MemberExpression | t.OptionalMemberExpression;
readonly name: string;
readonly path: PropertyPath;
};
}
export interface GetterResultResolvers {
readonly classReferences: (
lookup: {
readonly owner: ClassNode;
readonly receiver?: ClassNode;
readonly kind: "static" | "instance" | "prototype";
},
path: PropertyPath,
escapes: boolean,
) => readonly Reference[];
readonly objectReferences: (
receiver: t.ObjectExpression,
path: PropertyPath,
) => readonly Reference[];
readonly privateReceiver: (
receiver: ClassNode,
owner: ClassNode,
path: PropertyPath,
isStatic: boolean,
) => boolean;
readonly bases: (node: t.Node) => readonly ClassNode[];
}
/** Branch syntax contributes alternatives without evaluating application code. */
const resultBranches = (node: t.Node): readonly t.Node[] | undefined => {
if (t.isConditionalExpression(node)) return [node.consequent, node.alternate];
if (t.isLogicalExpression(node)) return [node.left, node.right];
if (t.isSequenceExpression(node)) {
const last = node.expressions.at(-1);
return last === undefined ? [] : [last];
}
if (t.isAssignmentExpression(node)) {
if (node.operator === "=") return [node.right];
if (["&&=", "||=", "??="].includes(node.operator))
return [node.left, node.right];
}
return undefined;
};
/** Each walk owns local aliases; getter-to-getter traversal belongs to the query worklist. */
class GetterResultWalker {
readonly #pending: GetterWork[];
readonly #aliases = new Map<string, Set<string>>();
readonly #scope: string | undefined;
constructor(
readonly job: GetterQueryJob,
readonly query: SemanticGetterQuery,
readonly resolvers: GetterResultResolvers,
) {
this.#pending = semanticCallableResultExpressions(
job.invocation.getter,
).flatMap(({ node }) => (node === null ? [] : [{ node, path: job.path }]));
this.#scope = query.state.scopeByNode.get(job.invocation.getter)?.scopeId;
}
run(): void {
for (let cursor = 0; cursor < this.#pending.length; cursor++) {
const current = this.#pending[cursor];
if (current !== undefined) this.#visit(current);
}
}
#retain(current: GetterWork, reference: Reference): void {
this.query.retain(reference, current.escapes || this.job.escapes);
}
#privateRead(current: GetterWork): void {
if (current.privateRead !== undefined)
this.#retain(current, {
node: current.privateRead.node,
path: current.escapes ? [] : current.privateRead.path,
});
}
#localBinding(expression: t.Identifier) {
const state = this.query.state;
const binding = resolveSemanticBindingState(
state,
expression,
expression.name,
);
let scope =
binding === undefined ? undefined : state.scopesById.get(binding.scopeId);
while (scope !== undefined && scope.scopeId !== this.#scope) {
if (scope.kind === "function" || scope.parentScopeId === null)
return undefined;
scope = state.scopesById.get(scope.parentScopeId);
}
return scope !== undefined &&
binding?.referenceInitializers.every(
(initializer) => initializer.copyKind === undefined,
)
? binding
: undefined;
}
#cycle(current: GetterWork, identity: string): AliasVisit | undefined {
for (let visit = current.aliases; visit !== undefined; visit = visit.parent)
if (visit.identity === identity) return visit;
return undefined;
}
#alias(current: GetterWork, expression: t.Identifier): boolean {
const binding = this.#localBinding(expression);
if (binding === undefined) return false;
const identity = JSON.stringify([
binding.bindingId,
expression.start,
expression.end,
]);
const previous = this.#cycle(current, identity);
if (previous !== undefined) {
// Growing aliases can reach arbitrarily deep children of the same roots.
if (current.path.length > previous.pathLength && !current.escapes)
this.#pending.push({
...current,
path: [],
aliases: undefined,
escapes: true,
});
return true;
}
const key = JSON.stringify([
current.path,
current.privateRead?.name,
current.privateRead?.path,
current.escapes,
]);
const known = this.#aliases.get(identity) ?? new Set<string>();
if (!known.has(key)) {
known.add(key);
this.#aliases.set(identity, known);
this.#aliasOrigins(current, expression, binding, identity);
}
return true;
}
#aliasOrigins(
current: GetterWork,
expression: t.Identifier,
binding: JavaScriptSemanticBindingState,
identity: string,
): void {
const state = this.query.state;
const { binding: origins } = semanticBindingAtRead(
binding,
expression,
state.parentsByNode,
);
const aliases: AliasVisit = {
identity,
pathLength: current.path.length,
...(current.aliases === undefined ? {} : { parent: current.aliases }),
};
for (const initializer of [
...origins.initializers,
...origins.referenceInitializers.filter(
(origin) => !semanticReferenceSourcesInactive(origin, state),
),
])
this.#pending.push({
...current,
node: initializer.node,
path: [...initializer.projection, ...current.path],
aliases,
});
}
#ownsThis(expression: t.ThisExpression): boolean {
let owner = this.query.state.parentsByNode.get(expression);
while (
owner !== undefined &&
!t.isClass(owner) &&
!(t.isFunction(owner) && !t.isArrowFunctionExpression(owner))
)
owner = this.query.state.parentsByNode.get(owner);
return owner === this.job.invocation.getter;
}
#privateValue(current: GetterWork, owner: ClassNode): void {
const read = current.privateRead;
if (read === undefined) return;
const { getter, receiver = owner } = this.job.invocation;
const isStatic = t.isClassMethod(getter) && getter.static;
if (
!t.isClass(receiver) ||
!this.resolvers.privateReceiver(receiver, owner, current.path, isStatic)
) {
this.#privateRead(current);
return;
}
for (const member of owner.body.body)
if (
t.isClassPrivateProperty(member) &&
member.static === isStatic &&
member.key.id.name === read.name &&
member.value != null
)
this.#retain(current, {
node: member.value,
path: current.escapes ? [] : read.path,
});
}
#thisValue(current: GetterWork, expression: t.ThisExpression): void {
if (!this.#ownsThis(expression)) return;
const { getter, owner, receiver = owner } = this.job.invocation;
if (current.privateRead !== undefined && t.isClass(owner)) {
this.#privateValue(current, owner);
return;
}
const references = t.isClass(receiver)
? this.resolvers.classReferences(
{
owner: receiver,
kind:
t.isClassMethod(getter) && getter.static ? "static" : "instance",
},
current.path,
current.escapes || this.job.escapes,
)
: this.resolvers.objectReferences(receiver, current.path);
for (const reference of references) this.#retain(current, reference);
}
#superValue(
current: GetterWork,
expression: t.MemberExpression | t.OptionalMemberExpression,
): boolean {
const { getter, owner, receiver = owner } = this.job.invocation;
if (
!t.isSuper(expression.object) ||
!t.isClass(owner) ||
owner.superClass == null
)
return false;
const key = semanticStaticPropertyKey(
expression.property,
expression.computed,
);
for (const base of this.resolvers.bases(owner.superClass))
for (const reference of this.resolvers.classReferences(
{
owner: base,
receiver: t.isClass(receiver) ? receiver : owner,
kind:
t.isClassMethod(getter) && getter.static ? "static" : "prototype",
},
[key, ...current.path],
current.escapes || this.job.escapes,
))
this.#retain(current, reference);
return true;
}
#member(
current: GetterWork,
expression: t.MemberExpression | t.OptionalMemberExpression,
): void {
if (
current.privateRead !== undefined &&
t.isPrivateName(expression.property)
) {
this.#privateRead(current);
return;
}
if (
t.isPrivateName(expression.property) &&
t.isClass(this.job.invocation.owner)
) {
this.#pending.push({
...current,
node: expression.object,
path: [],
privateRead: {
node: expression,
name: expression.property.id.name,
path: current.path,
},
});
return;
}
if (!this.#superValue(current, expression))
this.#pending.push({
...current,
node: expression.object,
path: [
semanticStaticPropertyKey(expression.property, expression.computed),
...current.path,
],
});
}
#visit(current: GetterWork): void {
const expression = unwrapJavaScriptExpression(current.node).node;
if (t.isIdentifier(expression) && this.#alias(current, expression)) return;
if (t.isThisExpression(expression)) this.#thisValue(current, expression);
else if (
t.isMemberExpression(expression) ||
t.isOptionalMemberExpression(expression)
)
this.#member(current, expression);
else {
const branches = resultBranches(expression);
if (branches !== undefined)
for (const node of branches) this.#pending.push({ ...current, node });
else if (current.privateRead !== undefined) this.#privateRead(current);
else this.#retain(current, { node: expression, path: current.path });
}
}
}
export const expandSemanticGetterResult = (
job: GetterQueryJob,
query: SemanticGetterQuery,
resolvers: GetterResultResolvers,
): void => new GetterResultWalker(job, query, resolvers).run();
@@ -251,6 +251,7 @@ export function* collectSemanticMemberMutationsSteps(
readonly effect: ValueEffect;
readonly mutation?: t.Node;
readonly originAt: t.Node | undefined;
readonly widenedGetterResult?: true;
}[] = [];
const iterableReferences: IterableReference[] = [];
const iterableReferenceKeys = new WeakMap<t.Node, Set<string>>();
@@ -266,6 +267,7 @@ export function* collectSemanticMemberMutationsSteps(
reference.originAt?.start,
reference.originAt?.end,
reference.iterationOnly,
reference.widenedGetterResult === true,
reference.requiredSources?.map((source) => [
source.node.start,
source.node.end,
@@ -291,6 +293,7 @@ export function* collectSemanticMemberMutationsSteps(
mutation?: t.Node,
originAt: t.Node | undefined = mutation,
ownerInitializer?: t.Node,
widenedGetterResult = false,
): void => {
const pending: (ReferencedValue & {
readonly bindings: ReadonlySet<string>;
@@ -392,6 +395,7 @@ export function* collectSemanticMemberMutationsSteps(
mutation,
current.originAt,
current.ownerInitializer,
widenedGetterResult,
);
continue;
}
@@ -477,7 +481,7 @@ export function* collectSemanticMemberMutationsSteps(
current.originAt?.start ?? -1
}\u0001${current.originAt?.end ?? -1}\u0001${
current.ownerInitializer?.start ?? -1
}\u0001${current.ownerInitializer?.end ?? -1}`;
}\u0001${current.ownerInitializer?.end ?? -1}\u0001${widenedGetterResult}`;
let covering = coveringPaths.get(pathState);
if (covering === undefined) {
covering = new SemanticPropertyPathCoverage();
@@ -531,6 +535,7 @@ export function* collectSemanticMemberMutationsSteps(
path: copiedPath,
bindings: nested,
effect,
...(widenedGetterResult ? { widenedGetterResult: true } : {}),
originAt: initializer.node,
...(mutation === undefined ? {} : { mutation }),
});
@@ -550,9 +555,17 @@ export function* collectSemanticMemberMutationsSteps(
});
} else {
for (const value of effect === "escape"
? escapedReferenceLeaves(expression, current.path)
? escapedReferenceLeaves(
expression,
current.path,
widenedGetterResult,
)
: expandedValues(expression, current.path, effect)) {
if (value.escapes && effect !== "escape")
if (
value.escapes &&
(effect !== "escape" ||
(value.widenedGetterResult && !widenedGetterResult))
)
markValue(
value.node,
value.path,
@@ -561,6 +574,7 @@ export function* collectSemanticMemberMutationsSteps(
mutation,
current.originAt,
value.ownerInitializer,
widenedGetterResult || value.widenedGetterResult === true,
);
else if (value.iterableFallbackPath !== undefined)
deferIterable({
@@ -571,6 +585,7 @@ export function* collectSemanticMemberMutationsSteps(
bindings: current.bindings,
effect,
originAt: current.originAt,
...(widenedGetterResult ? { widenedGetterResult: true } : {}),
...(mutation === undefined ? {} : { mutation }),
});
else
@@ -592,6 +607,7 @@ export function* collectSemanticMemberMutationsSteps(
expression: t.Node,
path: PropertyPath,
effect: ValueEffect,
widenedGetterResult = false,
): readonly ReferencedValue[] {
if (
t.isArrayExpression(expression) &&
@@ -599,7 +615,20 @@ export function* collectSemanticMemberMutationsSteps(
(path[0] === "constructor" && path[1] === "prototype"))
)
arrayIterationUnknown = true;
return referencedValues(expression, path, effect, state);
const references = referencedValues(
expression,
path,
effect,
state,
widenedGetterResult,
);
return widenedGetterResult
? references.map((reference) => ({
...reference,
escapes: true,
widenedGetterResult: true,
}))
: references;
}
// Expanding a value below its references depends only on syntax, not on the
// escape site. Escaping an object reaches every method's results, so
@@ -613,53 +642,79 @@ export function* collectSemanticMemberMutationsSteps(
function escapedReferenceLeaves(
root: t.Node,
rootPath: PropertyPath,
widenedGetterResult: boolean,
): readonly ReferencedValue[] {
// Parser recovery can leave an argument hole, which expands to nothing.
if (!t.isNode(root)) return [];
const rootKey = JSON.stringify(rootPath);
const rootKey = JSON.stringify([rootPath, widenedGetterResult]);
const cached = escapedLeaves.get(root)?.get(rootKey);
if (cached !== undefined) return cached;
const leaves = collectEscapedLeaves([
...expandedValues(root, rootPath, "escape", widenedGetterResult),
]);
const byPath = escapedLeaves.get(root) ?? new Map();
byPath.set(rootKey, leaves);
escapedLeaves.set(root, byPath);
return leaves;
}
function retainEscapedLeaf(
value: ReferencedValue,
node: t.Node,
leaves: ReferencedValue[],
identities: Set<string>,
): boolean {
if (
value.iterableFallbackPath !== undefined ||
t.isMemberExpression(node) ||
t.isOptionalMemberExpression(node)
) {
leaves.push(value);
return true;
}
if (!t.isIdentifier(node)) return false;
const binding = resolveBinding(node, node.name);
const identity = JSON.stringify([
binding === undefined ? null : binding.bindingId,
binding === undefined ? node.name : null,
value.path,
value.widenedGetterResult === true,
value.ownerInitializer?.start,
value.ownerInitializer?.end,
]);
if (!identities.has(identity)) {
identities.add(identity);
leaves.push(value);
}
return true;
}
function collectEscapedLeaves(
pending: ReferencedValue[],
): readonly ReferencedValue[] {
const leaves: ReferencedValue[] = [];
const leafIdentities = new Set<string>();
const identities = new Set<string>();
const expanded = new WeakMap<t.Node, Set<string>>();
const pending = [...expandedValues(root, rootPath, "escape")];
for (let cursor = 0; cursor < pending.length; cursor++) {
const value = pending[cursor];
if (value === undefined) break;
const node = unwrapJavaScriptExpression(value.node).node;
if (!t.isNode(node)) continue;
const pathKey = JSON.stringify(value.path);
if (t.isIdentifier(node) && value.iterableFallbackPath === undefined) {
const binding = resolveBinding(node, node.name);
const identity = JSON.stringify([
binding === undefined ? null : binding.bindingId,
binding === undefined ? node.name : null,
pathKey,
value.ownerInitializer?.start,
value.ownerInitializer?.end,
]);
if (leafIdentities.has(identity)) continue;
leafIdentities.add(identity);
leaves.push(value);
} else if (
value.iterableFallbackPath !== undefined ||
t.isIdentifier(node) ||
t.isMemberExpression(node) ||
t.isOptionalMemberExpression(node)
)
leaves.push(value);
else {
const paths = expanded.get(node) ?? new Set<string>();
if (paths.has(pathKey)) continue;
paths.add(pathKey);
expanded.set(node, paths);
for (const reference of expandedValues(node, value.path, "escape"))
pending.push(reference);
}
if (!t.isNode(node) || retainEscapedLeaf(value, node, leaves, identities))
continue;
const key = JSON.stringify([
value.path,
value.widenedGetterResult === true,
]);
const paths = expanded.get(node) ?? new Set<string>();
if (paths.has(key)) continue;
paths.add(key);
expanded.set(node, paths);
for (const reference of expandedValues(
node,
value.path,
"escape",
value.widenedGetterResult === true,
))
pending.push(reference);
}
const byPath = escapedLeaves.get(root) ?? new Map();
byPath.set(rootKey, leaves);
escapedLeaves.set(root, byPath);
return leaves;
}
const markReceiver = (callee: t.Node, mutation: t.Node): void => {
@@ -743,6 +798,9 @@ export function* collectSemanticMemberMutationsSteps(
fallbackPath: [],
bindings: reference.bindings,
effect: reference.effect,
...(reference.widenedGetterResult
? { widenedGetterResult: true }
: {}),
originAt: initializer.node,
...(reference.mutation === undefined
? {}
@@ -761,6 +819,9 @@ export function* collectSemanticMemberMutationsSteps(
fallbackPath: projection,
bindings: reference.bindings,
effect: reference.effect,
...(reference.widenedGetterResult
? { widenedGetterResult: true }
: {}),
originAt: reference.originAt,
...(reference.mutation === undefined
? {}
@@ -774,6 +835,8 @@ export function* collectSemanticMemberMutationsSteps(
reference.effect,
reference.mutation,
reference.originAt,
undefined,
reference.widenedGetterResult === true,
);
}
for (const [index, reference] of [...iterableReferences].entries()) {
@@ -803,6 +866,8 @@ export function* collectSemanticMemberMutationsSteps(
knownArray ? reference.effect : "escape",
reference.mutation,
reference.originAt,
undefined,
reference.widenedGetterResult === true,
);
}
if (
@@ -899,6 +964,7 @@ const referencedValues = (
path: PropertyPath,
effect: ValueEffect,
state: JavaScriptSemanticAnalysisState,
widenedGetterResult = false,
): readonly ReferencedValue[] => {
if (effect === "escape" && t.isFunction(node))
return semanticCallableResultExpressions(node).flatMap(
@@ -912,30 +978,47 @@ const referencedValues = (
? semanticObjectPropertyReferences(node, path, state)
: arrayReferencedValues(node, path);
}
if (t.isClass(node)) {
const [key, ...remaining] = path;
if (key === undefined) return [];
// Replacing or deleting the getter's property does not use the value it
// would return. Only a deeper path can mutate the returned object.
if (effect === "write" && remaining.length === 0) return [];
return semanticClassPropertyReferences(
{ owner: node, kind: "static" },
if (t.isClass(node) || t.isNewExpression(node))
return constructedReferenceValues(
node,
path,
effect,
state,
widenedGetterResult,
);
return branchReferenceValues(node, path);
};
const constructedReferenceValues = (
node: t.ClassDeclaration | t.ClassExpression | t.NewExpression,
path: PropertyPath,
effect: ValueEffect,
state: JavaScriptSemanticAnalysisState,
widenedGetterResult: boolean,
): readonly ReferencedValue[] => {
// Only retry results lost a suffix that could reach any constructed field.
// Preserve that reachability through aliases and nested allocations, without
// broadening ordinary whole-receiver escapes.
const [key = widenedGetterResult ? null : undefined, ...remaining] = path;
// Replacing a receiver property does not mutate the object it previously held.
if (key === undefined || (effect === "write" && remaining.length === 0))
return [];
const classes = t.isClass(node)
? [node]
: semanticClassCandidates(node.callee, state);
return classes.flatMap((owner) =>
semanticClassPropertyReferences(
{ owner, kind: t.isClass(node) ? "static" : "instance" },
[key, ...remaining],
state,
);
}
if (t.isNewExpression(node)) {
const [key, ...remaining] = path;
if (key === undefined) return [];
if (effect === "write" && remaining.length === 0) return [];
return semanticClassCandidates(node.callee, state).flatMap((klass) =>
semanticClassPropertyReferences(
{ owner: klass, kind: "instance" },
[key, ...remaining],
state,
),
);
}
),
);
};
const branchReferenceValues = (
node: t.Node,
path: PropertyPath,
): readonly ReferencedValue[] => {
if (t.isAssignmentExpression(node))
return node.operator === "||=" || node.operator === "??="
? [
@@ -2,9 +2,13 @@ import * as t from "@babel/types";
import { expect, it } from "vitest";
import { indexJavaScriptBindingsSteps } from "./javascriptSemanticAnalysis.js";
import { semanticObjectPropertyReferences } from "./javascriptSemanticObjectReferences.js";
import {
semanticClassPropertyReferences,
semanticObjectPropertyReferences,
} from "./javascriptSemanticObjectReferences.js";
import { completeSemanticSteps } from "./javascriptSemanticTraversal.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import { getterExpansionSource } from "../../../tests/fixtures/javascriptGetterExpansion.js";
const objectAndState = (source: string) => {
const parsed = parseJavaScriptSource(source, "object.js");
@@ -71,3 +75,67 @@ it("resolves exact keys with source-order overrides, spreads and accessors", ()
spelled(objectAndState(`const o = { 1: "one", 2: "two" };`), 1),
).toEqual([['"one"', "[]"]]);
});
it("preserves references through a small static getter permutation", () => {
const getters = Array.from(
{ length: 5 },
(_, index) =>
`static get field${index}() { return this[key].field${index}; }`,
).join("\n");
const source = `const key = getKey(); const shared = {}; class Request {
${getters}
static get state() { return shared; }
}`;
const parsed = parseJavaScriptSource(source, "getters.js");
if (parsed === null) throw new Error("Expected a parsed fixture");
const klass = parsed.program.body.find((node): node is t.ClassDeclaration =>
t.isClassDeclaration(node),
);
if (klass === undefined) throw new Error("Expected a class declaration");
const state = completeSemanticSteps(
indexJavaScriptBindingsSteps(parsed.program),
);
const references = semanticClassPropertyReferences(
{ owner: klass, kind: "static" },
["field0"],
state,
);
expect(
references.some(
({ node }) => source.slice(node.start ?? 0, node.end ?? 0) === "shared",
),
).toBe(true);
const distinctReferences = new Set(
references.map(({ node, path, escapes }) =>
JSON.stringify([node.start, node.end, path, escapes]),
),
);
expect(references).toHaveLength(distinctReferences.size);
});
it("widens a large static getter permutation to one escaped reference", () => {
const source = getterExpansionSource(30).replaceAll("get ", "static get ");
const parsed = parseJavaScriptSource(source, "getters.js");
if (parsed === null) throw new Error("Expected a parsed fixture");
const klass = parsed.program.body.find((node): node is t.ClassDeclaration =>
t.isClassDeclaration(node),
);
if (klass === undefined) throw new Error("Expected a class declaration");
const state = completeSemanticSteps(
indexJavaScriptBindingsSteps(parsed.program),
);
const references = semanticClassPropertyReferences(
{ owner: klass, kind: "static" },
["field0"],
state,
);
expect(references).toHaveLength(1);
expect(references[0]).toMatchObject({
node: { type: "Identifier", name: "shared" },
path: [],
escapes: true,
});
expect([...state.getterResourceLimits]).toEqual(["getter-expansion"]);
});
@@ -3,9 +3,6 @@ import {
resolveSemanticBindingState,
type JavaScriptSemanticAnalysisState,
} from "./javascriptSemanticState.js";
import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js";
import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js";
import { semanticReferenceSourcesInactive } from "./javascriptSemanticReferenceSources.js";
import {
semanticStaticPropertyKey,
unwrapJavaScriptExpression,
@@ -15,11 +12,18 @@ import {
type JavaScriptSemanticPropertyPath as PropertyPath,
} from "./javascriptSemanticPropertyPaths.js";
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
import {
SemanticGetterQuery,
type GetterQueryJob,
} from "./javascriptSemanticGetterQueries.js";
import { expandSemanticGetterResult } from "./javascriptSemanticGetterResults.js";
export interface JavaScriptSemanticObjectReference {
readonly node: t.Node;
readonly path: PropertyPath;
readonly escapes?: true;
/** A retry omitted suffixes, so escaping constructed children must cover them. */
readonly widenedGetterResult?: true;
/** The allocation owned by this binding, rather than a read of its prior value. */
readonly ownerInitializer?: t.Node;
}
@@ -229,68 +233,106 @@ export const semanticClassPropertyReferences = (
lookup: ClassPropertyLookup,
selection: PropertyPath,
state: JavaScriptSemanticAnalysisState,
activeGetters: ReadonlyMap<t.Node, number> = new Map(),
): readonly JavaScriptSemanticObjectReference[] => {
const root = lookup.owner;
const receiver = lookup.receiver ?? root;
const [key, ...path] = selection;
const propertyName =
key == null || typeof key === "object" ? null : String(key);
const pending: (t.ClassDeclaration | t.ClassExpression)[] = [root];
const seen = new Set<t.Node>();
const possibleResults: JavaScriptSemanticObjectReference[] = [];
// Instance fields are own properties, including fields initialized by base
// constructors. They take precedence over every prototype descriptor.
let instanceFields: ReadonlySet<string> = new Set();
if (lookup.kind === "instance") {
const fields = instanceFieldReferences(
root,
propertyName,
path,
state,
lookup.initializedBefore,
);
possibleResults.push(...fields.references);
instanceFields = fields.definiteNames;
if (propertyName !== null && instanceFields.has(propertyName))
return possibleResults;
const query = new SemanticGetterQuery(state);
return query.run(
() => classPropertyReferences(lookup, selection, state, { query }),
(job) => expandGetterResult(job, query),
);
};
class ClassReferenceWalk {
readonly #references: JavaScriptSemanticObjectReference[] = [];
readonly #pending: (t.ClassDeclaration | t.ClassExpression)[];
readonly #seen = new Set<t.Node>();
readonly #name: string | null;
readonly #path: PropertyPath;
#fields: ReadonlySet<string> = new Set();
constructor(
readonly lookup: ClassPropertyLookup,
selection: PropertyPath,
readonly state: JavaScriptSemanticAnalysisState,
readonly context: {
readonly query: SemanticGetterQuery;
readonly escapes?: boolean;
},
) {
const [key, ...path] = selection;
this.#name = key == null || typeof key === "object" ? null : String(key);
this.#path = path;
this.#pending = [lookup.owner];
}
for (let cursor = 0; cursor < pending.length; cursor++) {
const klass = pending[cursor];
if (klass === undefined || seen.has(klass)) continue;
seen.add(klass);
#instanceFields(): boolean {
if (this.lookup.kind !== "instance") return false;
// Own fields, including inherited constructor fields, suppress prototype descriptors.
const fields = instanceFieldReferences(
this.lookup.owner,
this.#name,
this.#path,
this.state,
this.lookup.initializedBefore,
);
for (const reference of fields.references) this.#references.push(reference);
this.#fields = fields.definiteNames;
return this.#name !== null && this.#fields.has(this.#name);
}
#getter(
klass: t.ClassDeclaration | t.ClassExpression,
getter: t.ClassMethod,
): void {
const key = semanticStaticPropertyKey(getter.key, getter.computed);
if (key !== null && this.#fields.has(key)) return;
this.context.query.schedule(
{
getter,
owner: klass,
receiver: this.lookup.receiver ?? this.lookup.owner,
},
this.#path,
this.context.escapes,
);
}
#visit(klass: t.ClassDeclaration | t.ClassExpression): void {
const descriptors = classPropertyDescriptors(
klass,
propertyName,
lookup.kind,
lookup.initializedBefore,
this.#name,
this.lookup.kind,
this.lookup.initializedBefore,
);
for (const field of descriptors.fields)
if (field.value != null)
possibleResults.push({ node: field.value, path });
for (const getter of lookup.fieldsOnly ? [] : descriptors.getters) {
const key = semanticStaticPropertyKey(getter.key, getter.computed);
if (key !== null && instanceFields.has(key)) continue;
possibleResults.push(
...getterResultReferences(
{ getter, owner: klass, receiver },
path,
state,
activeGetters,
),
);
}
// An exact own descriptor suppresses inherited lookup. Dynamic computed
// descriptors may or may not match, so retain possible superclass results.
if (
descriptors.inherits &&
klass.superClass !== null &&
klass.superClass !== undefined
)
pending.push(...semanticClassCandidates(klass.superClass, state));
this.#references.push({ node: field.value, path: this.#path });
for (const getter of this.lookup.fieldsOnly ? [] : descriptors.getters)
this.#getter(klass, getter);
// Only a definite own descriptor can suppress possible superclass results.
if (descriptors.inherits && klass.superClass != null)
for (const base of semanticClassCandidates(klass.superClass, this.state))
this.#pending.push(base);
}
return possibleResults;
};
run(): readonly JavaScriptSemanticObjectReference[] {
if (this.#instanceFields()) return this.#references;
for (let cursor = 0; cursor < this.#pending.length; cursor++) {
const klass = this.#pending[cursor];
if (klass === undefined || this.#seen.has(klass)) continue;
this.#seen.add(klass);
this.#visit(klass);
}
return this.#references;
}
}
const classPropertyReferences = (
lookup: ClassPropertyLookup,
selection: PropertyPath,
state: JavaScriptSemanticAnalysisState,
context: { readonly query: SemanticGetterQuery; readonly escapes?: boolean },
): readonly JavaScriptSemanticObjectReference[] =>
new ClassReferenceWalk(lookup, selection, state, context).run();
const objectReceiverReference = (
object: t.ObjectExpression,
@@ -363,15 +405,6 @@ const objectReceiverReference = (
// Getter return expressions use the receiver of the property read. Keep that
// identity when translating `this`, private fields, and superclass reads into
// the same reference paths used by ordinary member mutations.
interface GetterInvocation {
readonly getter: t.ObjectMethod | t.ClassMethod;
readonly owner: t.ObjectExpression | t.ClassDeclaration | t.ClassExpression;
readonly receiver?:
| t.ObjectExpression
| t.ClassDeclaration
| t.ClassExpression;
}
/** Follow only data fields whose initializers retain a class receiver. */
const privateReceiverProjection = (
receiver: t.ClassDeclaration | t.ClassExpression,
@@ -468,276 +501,20 @@ const privateReceiverProjection = (
return false;
};
const getterResultReferences = (
invocation: GetterInvocation,
path: PropertyPath,
state: JavaScriptSemanticAnalysisState,
activeGetters: ReadonlyMap<t.Node, number> = new Map(),
): readonly JavaScriptSemanticObjectReference[] => {
const { getter, owner, receiver = owner } = invocation;
const previousLength = activeGetters.get(getter);
if (previousLength !== undefined && previousLength <= path.length) return [];
// Repeated receiver reads may revisit a getter while consuming a finite
// path; recursive getters that preserve or grow that path cannot progress.
const active = new Map(activeGetters).set(getter, path.length);
const isStatic = t.isClassMethod(getter) && getter.static;
interface AliasVisit {
readonly identity: string;
readonly pathLength: number;
readonly parent: AliasVisit | undefined;
}
const pending: {
readonly node: t.Node;
readonly path: PropertyPath;
readonly aliases: AliasVisit | undefined;
readonly escapes?: true;
readonly privateRead?: {
readonly node: t.MemberExpression | t.OptionalMemberExpression;
readonly name: string;
readonly path: PropertyPath;
};
}[] = semanticCallableResultExpressions(getter).flatMap(({ node }) =>
node === null ? [] : [{ node, path, aliases: undefined }],
);
const references: JavaScriptSemanticObjectReference[] = [];
const expandedAliases = new Map<string, Set<string>>();
const getterScope = state.scopeByNode.get(getter)?.scopeId;
for (let cursor = 0; cursor < pending.length; cursor++) {
const current = pending[cursor];
if (current === undefined) break;
const { path: remaining } = current;
const expression = unwrapJavaScriptExpression(current.node).node;
const retain = (reference: JavaScriptSemanticObjectReference): void => {
references.push(
current.escapes ? { ...reference, escapes: true } : reference,
);
};
const retainPrivateRead = (): void => {
if (current.privateRead !== undefined)
retain({
node: current.privateRead.node,
path: current.escapes ? [] : current.privateRead.path,
});
};
if (t.isIdentifier(expression)) {
const binding = resolveSemanticBindingState(
state,
expression,
expression.name,
);
let scope =
binding === undefined
? undefined
: state.scopesById.get(binding.scopeId);
while (scope !== undefined && scope.scopeId !== getterScope) {
if (scope.kind === "function" || scope.parentScopeId === null) {
scope = undefined;
break;
}
scope = state.scopesById.get(scope.parentScopeId);
}
if (
binding !== undefined &&
scope !== undefined &&
binding.referenceInitializers.every(
(initializer) => initializer.copyKind === undefined,
)
) {
const identity = JSON.stringify([
binding.bindingId,
expression.start,
expression.end,
]);
const known = expandedAliases.get(identity);
const key = JSON.stringify([
remaining,
current.privateRead?.name,
current.privateRead?.path,
current.escapes,
]);
let cyclic = false;
let growing = false;
if (known !== undefined)
for (
let visit = current.aliases;
visit !== undefined;
visit = visit.parent
)
if (visit.identity === identity) {
cyclic = true;
growing = remaining.length > visit.pathLength;
break;
}
if (cyclic) {
// A repeated alias can select arbitrarily deep children. Revisit its
// roots as escapes rather than enumerating iterations or dropping them.
if (growing && !current.escapes)
pending.push({
...current,
path: [],
aliases: undefined,
escapes: true,
});
continue;
}
if (known?.has(key)) continue;
const selections = known ?? new Set<string>();
selections.add(key);
expandedAliases.set(identity, selections);
const aliases = {
identity,
pathLength: remaining.length,
parent: current.aliases,
};
// Unknown read ordering retains possible aliases for effect reachability.
const { binding: origins } = semanticBindingAtRead(
binding,
expression,
state.parentsByNode,
);
for (const initializer of [
...origins.initializers,
...origins.referenceInitializers.filter(
(initializer) =>
!semanticReferenceSourcesInactive(initializer, state),
),
])
pending.push({
...current,
node: initializer.node,
path: [...initializer.projection, ...remaining],
aliases,
});
continue;
}
}
if (t.isThisExpression(expression)) {
// An outer binding or a nested ordinary function has a different receiver.
let ownerNode = state.parentsByNode.get(expression);
while (
ownerNode !== undefined &&
!t.isClass(ownerNode) &&
!(t.isFunction(ownerNode) && !t.isArrowFunctionExpression(ownerNode))
)
ownerNode = state.parentsByNode.get(ownerNode);
if (ownerNode !== getter) continue;
if (current.privateRead !== undefined && t.isClass(owner)) {
if (
!t.isClass(receiver) ||
!privateReceiverProjection(
receiver,
owner,
remaining,
isStatic,
state,
)
) {
retainPrivateRead();
continue;
}
for (const member of owner.body.body)
if (
t.isClassPrivateProperty(member) &&
member.static === isStatic &&
member.key.id.name === current.privateRead.name &&
member.value != null
)
retain({
node: member.value,
path: current.escapes ? [] : current.privateRead.path,
});
continue;
}
for (const reference of t.isClass(receiver)
? semanticClassPropertyReferences(
{ owner: receiver, kind: isStatic ? "static" : "instance" },
remaining,
state,
active,
)
: objectReceiverReference(receiver, remaining, state))
retain(reference);
continue;
}
if (
t.isMemberExpression(expression) ||
t.isOptionalMemberExpression(expression)
) {
if (
current.privateRead !== undefined &&
t.isPrivateName(expression.property)
) {
retainPrivateRead();
continue;
}
if (t.isPrivateName(expression.property) && t.isClass(owner)) {
pending.push({
...current,
node: expression.object,
path: [],
privateRead: {
node: expression,
name: expression.property.id.name,
path: remaining,
},
});
continue;
}
const key = semanticStaticPropertyKey(
expression.property,
expression.computed,
);
if (
t.isSuper(expression.object) &&
t.isClass(owner) &&
owner.superClass != null
) {
for (const base of semanticClassCandidates(owner.superClass, state))
for (const reference of semanticClassPropertyReferences(
{
owner: base,
receiver: t.isClass(receiver) ? receiver : owner,
kind: isStatic ? "static" : "prototype",
},
[key, ...remaining],
state,
active,
))
retain(reference);
continue;
}
pending.push({
...current,
node: expression.object,
path: [key, ...remaining],
});
} else if (t.isConditionalExpression(expression))
pending.push(
{ ...current, node: expression.consequent },
{ ...current, node: expression.alternate },
);
else if (t.isLogicalExpression(expression))
pending.push(
{ ...current, node: expression.left },
{ ...current, node: expression.right },
);
else if (t.isSequenceExpression(expression)) {
const last = expression.expressions.at(-1);
if (last !== undefined) pending.push({ ...current, node: last });
} else if (t.isAssignmentExpression(expression, { operator: "=" }))
pending.push({ ...current, node: expression.right });
else if (
t.isAssignmentExpression(expression) &&
["&&=", "||=", "??="].includes(expression.operator)
)
pending.push(
{ ...current, node: expression.left },
{ ...current, node: expression.right },
);
else if (current.privateRead !== undefined) retainPrivateRead();
else retain({ node: expression, path: remaining });
}
return references;
const expandGetterResult = (
job: GetterQueryJob,
query: SemanticGetterQuery,
): void => {
const state = query.state;
expandSemanticGetterResult(job, query, {
classReferences: (lookup, path, escapes) =>
classPropertyReferences(lookup, path, state, { query, escapes }),
objectReferences: (receiver, path) =>
objectReceiverReference(receiver, path, state),
privateReceiver: (receiver, owner, path, isStatic) =>
privateReceiverProjection(receiver, owner, path, isStatic, state),
bases: (node) => semanticClassCandidates(node, state),
});
};
type ObjectLiteralMember = t.ObjectExpression["properties"][number];
@@ -814,127 +591,160 @@ export const semanticObjectPropertyReferences = (
path: PropertyPath,
state: JavaScriptSemanticAnalysisState,
): readonly JavaScriptSemanticObjectReference[] => {
const [key, ...remaining] = path;
const references: JavaScriptSemanticObjectReference[] = [];
const overwritten = new Set<string>();
const dataOverwritten = new Set<string>();
const accessorNames = new Set<string>();
const getterNames = new Set<string>();
const setterNames = new Set<string>();
for (const property of relevantPropertiesInReverse(node, key)) {
if (t.isSpreadElement(property)) {
const selected = key ?? null;
if (typeof selected !== "object" && overwritten.has(String(selected)))
continue;
references.push({
node: property.argument,
path: [
typeof selected === "object"
? {
...selected,
excludedKeys: [
...new Set([
...(selected?.excludedKeys ?? []),
...overwritten,
]),
].sort(compareUnicodeCodePoints),
}
: selected,
...remaining,
],
});
if (
selected !== null &&
typeof selected !== "object" &&
t.isObjectExpression(property.argument) &&
objectExpressionDefinesKey(property.argument, String(selected))
) {
overwritten.add(String(selected));
dataOverwritten.add(String(selected));
}
continue;
}
const name = semanticStaticPropertyKey(property.key, property.computed);
const query = new SemanticGetterQuery(state);
return query.run(
() => objectPropertyReferences(node, path, query),
(job) => expandGetterResult(job, query),
);
};
class ObjectReferenceWalk {
readonly #references: JavaScriptSemanticObjectReference[] = [];
readonly #overwritten = new Set<string>();
readonly #data = new Set<string>();
readonly #accessors = new Set<string>();
readonly #getters = new Set<string>();
readonly #setters = new Set<string>();
readonly #key: PropertyPath[number] | undefined;
readonly #remaining: PropertyPath;
constructor(
readonly node: t.ObjectExpression,
path: PropertyPath,
readonly query: SemanticGetterQuery,
) {
[this.#key, ...this.#remaining] = path;
}
#spreadKey(): PropertyPath[number] {
const selected = this.#key ?? null;
return typeof selected !== "object"
? selected
: {
...selected,
excludedKeys: [
...new Set([
...(selected?.excludedKeys ?? []),
...this.#overwritten,
]),
].sort(compareUnicodeCodePoints),
};
}
#spread(property: t.SpreadElement): void {
const selected = this.#key ?? null;
if (typeof selected !== "object" && this.#overwritten.has(String(selected)))
return;
this.#references.push({
node: property.argument,
path: [this.#spreadKey(), ...this.#remaining],
});
if (
t.isObjectProperty(property) &&
!property.computed &&
!property.shorthand &&
name === "__proto__"
selected !== null &&
typeof selected !== "object" &&
t.isObjectExpression(property.argument) &&
objectExpressionDefinesKey(property.argument, String(selected))
) {
const selected = key ?? null;
if (
(selected === null || typeof selected === "object") &&
t.isExpression(property.value)
) {
references.push({
node: property.value,
path: [selected, ...remaining],
});
} else if (
typeof selected === "string" &&
!objectExpressionDefinesKey(node, selected) &&
t.isExpression(property.value)
) {
references.push({
node: property.value,
path: [selected, ...remaining],
});
}
continue;
}
const isGetter = t.isObjectMethod(property) && property.kind === "get";
const isSetter = t.isObjectMethod(property) && property.kind === "set";
if (
name !== null &&
(isGetter || isSetter
? dataOverwritten.has(name) ||
(isGetter ? getterNames.has(name) : setterNames.has(name))
: dataOverwritten.has(name) || accessorNames.has(name))
)
continue;
if (name === null || semanticPropertyPathKeyMatches(key ?? null, name)) {
if (t.isObjectMethod(property) && property.kind === "get") {
references.push(
...getterResultReferences(
{ getter: property, owner: node },
remaining,
state,
),
);
} else if (t.isObjectMethod(property) && property.kind === "set") {
// A setter contributes no readable reference. A paired getter is
// handled independently when it appears earlier in this object.
} else {
references.push({
node: t.isObjectProperty(property) ? property.value : property,
path: remaining,
});
}
}
if (name !== null) {
if (isGetter) {
getterNames.add(name);
accessorNames.add(name);
overwritten.add(name);
} else if (isSetter) {
setterNames.add(name);
accessorNames.add(name);
overwritten.add(name);
} else if (
!(
t.isObjectProperty(property) &&
!property.computed &&
!property.shorthand &&
name === "__proto__"
)
) {
overwritten.add(name);
dataOverwritten.add(name);
}
this.#overwritten.add(String(selected));
this.#data.add(String(selected));
}
}
return references;
};
#prototype(
property: t.ObjectMethod | t.ObjectProperty,
name: string | null,
): boolean {
if (
!t.isObjectProperty(property) ||
property.computed ||
property.shorthand ||
name !== "__proto__"
)
return false;
const selected = this.#key ?? null;
if (
t.isExpression(property.value) &&
(selected === null ||
typeof selected === "object" ||
(typeof selected === "string" &&
!objectExpressionDefinesKey(this.node, selected)))
)
this.#references.push({
node: property.value,
path: [selected, ...this.#remaining],
});
return true;
}
#read(
property: t.ObjectMethod | t.ObjectProperty,
name: string | null,
): void {
if (
name !== null &&
!semanticPropertyPathKeyMatches(this.#key ?? null, name)
)
return;
if (t.isObjectMethod(property)) {
if (property.kind === "get")
this.query.schedule(
{ getter: property, owner: this.node },
this.#remaining,
);
// A paired getter remains readable even when its setter appears later.
if (property.kind === "get" || property.kind === "set") return;
}
this.#references.push({
node: t.isObjectProperty(property) ? property.value : property,
path: this.#remaining,
});
}
#record(name: string, kind: "get" | "set" | "data"): void {
this.#overwritten.add(name);
if (kind === "data") this.#data.add(name);
else {
this.#accessors.add(name);
(kind === "get" ? this.#getters : this.#setters).add(name);
}
}
#visit(property: ObjectLiteralMember): void {
if (t.isSpreadElement(property)) {
this.#spread(property);
return;
}
const name = semanticStaticPropertyKey(property.key, property.computed);
if (this.#prototype(property, name)) return;
const kind =
t.isObjectMethod(property) && property.kind !== "method"
? property.kind
: "data";
if (
name !== null &&
(kind === "data"
? this.#data.has(name) || this.#accessors.has(name)
: this.#data.has(name) ||
(kind === "get" ? this.#getters : this.#setters).has(name))
)
return;
this.#read(property, name);
if (name !== null) this.#record(name, kind);
}
run(): readonly JavaScriptSemanticObjectReference[] {
for (const property of relevantPropertiesInReverse(this.node, this.#key))
this.#visit(property);
return this.#references;
}
}
const objectPropertyReferences = (
node: t.ObjectExpression,
path: PropertyPath,
query: SemanticGetterQuery,
): readonly JavaScriptSemanticObjectReference[] =>
new ObjectReferenceWalk(node, path, query).run();
const objectExpressionDefinesKey = (
root: t.ObjectExpression,
@@ -7,6 +7,8 @@ import type {
export const SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT = 256;
/** Maximum active expression nesting during inert semantic evaluation. */
export const SEMANTIC_EXPRESSION_DEPTH_LIMIT = 256;
/** Distinct getter, receiver and path states retained in one precise query. */
export const SEMANTIC_GETTER_EXPANSION_LIMIT = 512;
/** Bound normalized strings and transient keys independently of transport limits. */
export const SEMANTIC_PRIMITIVE_JSON_BYTES_LIMIT = 1_048_576;
@@ -17,6 +19,8 @@ const RESOURCE_LIMIT_REASON: Record<JavaScriptSemanticResourceLimit, string> = {
"Primitive string-byte budget exceeded (maximum 1048576 estimated JSON bytes).",
"expression-depth":
"Semantic expression depth budget exceeded (maximum 256 nested expressions).",
"getter-expansion":
"Getter expansion was conservatively bounded (maximum 512 distinct states or a growing receiver cycle).",
};
/** Check UTF-16 length against worst-case JSON escaping without flattening. */
@@ -81,6 +81,8 @@ export interface JavaScriptSemanticAnalysisState {
readonly staticImportResolutionMode: JavaScriptModuleResolutionMode;
/** Bounds encountered during this analysis, independent of later projections. */
readonly valueResourceLimits: Set<JavaScriptSemanticResourceLimit>;
/** Getter query bounds encountered while resolving object and class properties. */
readonly getterResourceLimits: Set<JavaScriptSemanticResourceLimit>;
readonly parentsByNode: WeakMap<t.Node, t.Node>;
readonly scopes: JavaScriptSemanticScopeState[];
readonly scopesById: Map<string, JavaScriptSemanticScopeState>;
@@ -5,7 +5,8 @@ export type JavaScriptSemanticPrimitive = string | number | boolean | null;
export type JavaScriptSemanticResourceLimit =
| "primitive-candidates"
| "primitive-bytes"
| "expression-depth";
| "expression-depth"
| "getter-expansion";
type JavaScriptSemanticObjectValue = {
readonly status: "object";
@@ -1,7 +1,32 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js";
import { javaScriptWorkerStaticAnalysisSchema } from "./JavaScriptAnalysisWorkerSchemas.js";
import {
javaScriptWorkerModuleHeaderSchema,
javaScriptWorkerModuleRecordSchema,
javaScriptWorkerStaticAnalysisSchema,
} from "./JavaScriptAnalysisWorkerSchemas.js";
import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js";
import { getterExpansionSource } from "../../../tests/fixtures/javascriptGetterExpansion.js";
it("transfers getter expansion limits and unknown bindings across the worker boundary", () => {
const ir = analyzeJavaScriptSemantics(getterExpansionSource());
const header = { coverage: ir.coverage, limitations: ir.limitations };
expect(
javaScriptWorkerModuleHeaderSchema.parse(
JSON.parse(JSON.stringify(header)) as unknown,
),
).toEqual(header);
for (const value of ir.bindings) {
const record = { kind: "binding", value };
expect(
javaScriptWorkerModuleRecordSchema.parse(
JSON.parse(JSON.stringify(record)) as unknown,
),
).toEqual(record);
}
expect(ir.coverage.resourceLimits).toEqual(["getter-expansion"]);
});
it("retains candidate endpoint interpretation across the worker boundary", () => {
const facts = analyzeJavaScriptStaticSource(`
@@ -417,6 +417,7 @@ const resourceLimit = z.enum([
"primitive-candidates",
"primitive-bytes",
"expression-depth",
"getter-expansion",
]);
const primitive = z.union([z.string(), z.number(), z.boolean(), z.null()]);
const property: z.ZodType<JavaScriptSemanticProperty> = z.lazy(() =>
@@ -7,6 +7,13 @@ import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
import { parseJavaScriptApplicationGraph } from "../../../../src/domain/javascript/javascriptApplicationGraph.js";
import { compareJavaScriptApplicationVersions } from "../../../../src/domain/javascript/javascriptApplicationVersionComparison.js";
import { semanticResourceLimitReason } from "../../../../src/domain/javascript/javascriptSemanticResourceLimits.js";
import { getterExpansionSource } from "../../../fixtures/javascriptGetterExpansion.js";
import { scanCanonicalArtifactInventory } from "../../../fixtures/artifactInventory.js";
import { readJavaScriptArtifactFiles } from "../../../support/javascriptApplicationScope.js";
import { createJavaScriptArtifactReader } from "../../../../src/artifacts/javascript/JavaScriptArtifactReader.js";
import { analyzeJavaScriptArtifactFiles } from "../../../../src/application/javascript/JavaScriptArtifactAnalysis.js";
import { buildJavaScriptArtifactGraph } from "../../../../src/application/javascript/JavaScriptArtifactGraphBuilder.js";
import {
findExportNode,
@@ -16,6 +23,64 @@ import {
writeFixtureFiles,
} from "../../../support/javascriptApplicationFixture.js";
const relationshipGraph = async (root: string) => {
const snapshot = await scanCanonicalArtifactInventory(root, {});
const reader = createJavaScriptArtifactReader(root, "directory");
try {
const files = await readJavaScriptArtifactFiles(reader, snapshot);
return buildJavaScriptArtifactGraph(
snapshot,
files,
analyzeJavaScriptArtifactFiles(files),
);
} finally {
await reader.close();
}
};
it.each([
["getter-expansion", getterExpansionSource()],
[
"primitive-candidates",
`const result = ${Array.from({ length: 20 }, () => '(true ? "a" : "b")').join(" + ")};`,
],
] as const)(
"deduplicates %s limitations on import and export relationships",
async (limit, source) => {
const root = await createTestTempDirectory("rea-module-limit-evidence-");
await writeFixtureFiles(root, {
"entry.js": `import imported from "./dep.js"; ${source} export { result };`,
"dep.js": "export default 42;",
});
const graph = await relationshipGraph(root);
const module = findSourceModule(graph, "entry.js");
const relationships = graph.edges.filter(
({ source_node_id, relation, properties }) =>
source_node_id === module?.node_id &&
(relation === "exposes" ||
(relation === "imports" && properties.module_link_kind === "import")),
);
expect(relationships.map(({ relation }) => relation).sort()).toEqual([
"exposes",
"imports",
]);
for (const { evidence } of relationships) {
expect(
evidence.limitations.filter(
(reason) => reason === semanticResourceLimitReason(limit),
),
).toEqual([semanticResourceLimitReason(limit)]);
expect(evidence.coverage.status).toBe("partial");
expect(evidence.limitations).toContain(
"Static syntax does not prove that this relationship executes at runtime.",
);
expect(evidence.limitations).toContain(
"Values and aliases were recovered from inert syntax only; no JavaScript was executed.",
);
}
},
);
describe("cross-version source module identity", () => {
it("keeps a source module unchanged when only a sibling file changes", async () => {
const root = await createTestTempDirectory("rea-source-module-sibling-");
+13
View File
@@ -0,0 +1,13 @@
/** Dynamic receiver keys enumerate permutations of these getter names. */
export const getterExpansionSource = (count = 10): string => `
const shared = { value: 1 };
class Request {
${Array.from(
{ length: count },
(_, index) => `get field${index}() { return this[key].field${index}; }`,
).join("\n")}
get state() { return shared; }
}
consume(new Request().field0);
const result = shared.value;
`;