fix(javascript): converge cyclic aliases with explicit projection widening

Replace initializer-history and full-path enumeration with a finite projection
join at each source read. Retain conservative local returned-reference effects
and disclose precision loss through static, semantic and captured-web results.
Close full-binding callback cycles while preserving narrowed read semantics.

Fixes #4348
Fixes #5138
Refs #4349
Refs #4364

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Fuxuan-CN <3072252442@qq.com>
This commit is contained in:
committed 2026-10-19 12:26:54 +08:00
1 parent 9af0b6113c
commit e4c3d72396
19 files changed
+791 -166

No files matched your search

+11
View File
@@ -195,6 +195,17 @@ earlier read or captured alias. Unproved ordering across branches or function
activations remains conservative. Returned-reference effects use the same
read-position facts as call resolution.
Returned-reference effects share one property-projection state per source read.
When several paths reach that read, differing keys at the same depth become
wildcards; differing depths become an unknown-depth selection. This retains a
conservative superset of the represented local targets rather than enumerating
every alias history or dropping paths at a query cap. It can add possible effects,
including for finite projection cycles. Static limitations disclose this loss of
precision; semantic graphs retain `dynamic-property` unknowns at the affected
callee coordinates, and captured web bundles identify the affected script keys.
These candidates do not prove runtime calls or mutations. Existing source-worker
and result-admission limits still apply.
Mutation tracking covers explicit member assignments, updates, deletion, and
loop assignment targets, including the supported local aliases and shared
nested references. Calls conservatively invalidate exposed mutable references;
@@ -39,6 +39,7 @@ import {
unknownSemanticEvidence,
} from "./JavaScriptSemanticGraphEvidence.js";
import {
RETURNED_REFERENCE_WIDENING_LIMITATION,
semanticCoverageResourceLimits,
semanticResourceLimitCoverage,
} from "../../domain/javascript/javascriptSemanticCoverage.js";
@@ -555,6 +556,19 @@ function* projectFileSteps(
if (moduleNode === null) return [];
state.roots.add(moduleNode.node_id);
projectFileResourceLimits({ file, ir, state, moduleNode });
for (const location of ir.coverage.widenedReturnedReferences ?? [])
addSemanticGraphUnknown(state, {
node_id: moduleNode.node_id,
family: "data-flow",
relation_kinds: ["defines"],
reason: "dynamic-property",
detail: RETURNED_REFERENCE_WIDENING_LIMITATION,
candidate_node_ids: [],
evidence: {
...unknownSemanticEvidence(file, location),
limitations: [RETURNED_REFERENCE_WIDENING_LIMITATION],
},
});
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.
@@ -0,0 +1,33 @@
import { expect, it } from "vitest";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
import { RETURNED_REFERENCE_WIDENING_LIMITATION } from "./javascriptSemanticCoverage.js";
it("preserves possible ambient effects and discloses projection widening in source coverage", () => {
const source = `
function expose() { return Map.prototype; }
let cursor = { target: expose };
function walk(flag) {
while (flag) cursor = cursor.next;
patch(cursor.target());
}
fetch('/preserved');
new Map().get('/candidate');
`;
const ir = analyzeJavaScriptSemantics(source);
expect(ir.coverage).toMatchObject({ status: "partial", omittedCount: 0 });
expect(ir.coverage.widenedReturnedReferences?.length).toBeGreaterThan(0);
expect(ir.limitations).toContain(RETURNED_REFERENCE_WIDENING_LIMITATION);
const facts = analyzeJavaScriptStaticSource(source);
expect(facts.limitations).toContain(RETURNED_REFERENCE_WIDENING_LIMITATION);
expect(facts.endpoints).toEqual(
expect.arrayContaining([
expect.objectContaining({ value: "/preserved", mechanism: "call:fetch" }),
expect.objectContaining({
value: "/candidate",
interpretation: "candidate",
}),
]),
);
});
@@ -154,7 +154,7 @@ export const createAmbientPathResolver = (
...(current.readsAtSelf && { readsAtSelf: true }),
});
const continueReturned = (callee: t.Node, path: AmbientProjection) => {
for (const source of returnedReferences(callee))
for (const source of returnedReferences(callee).references)
continueWith(
source.node,
path === undefined
@@ -86,7 +86,10 @@ import {
ELECTRON_MODULE,
} from "./javascriptElectronMemberWrites.js";
import { range } from "./javascriptStaticAnalysisHelpers.js";
import { semanticCoverage } from "./javascriptSemanticCoverage.js";
import {
semanticCoverage,
RETURNED_REFERENCE_WIDENING_LIMITATION,
} from "./javascriptSemanticCoverage.js";
import { semanticResourceLimitReason } from "./javascriptSemanticResourceLimits.js";
import {
parseJavaScriptSource,
@@ -414,8 +417,12 @@ export function* analyzeParsedJavaScriptSemanticsSteps(
...state.getterResourceLimits,
]),
].sort(),
[...state.returnedReferenceWidenings].map(range),
),
limitations: [
...(state.returnedReferenceWidenings.size > 0
? [RETURNED_REFERENCE_WIDENING_LIMITATION]
: []),
...[...state.getterResourceLimits]
.sort()
.map(semanticResourceLimitReason),
@@ -956,6 +963,7 @@ const createState = (
staticImportResolutionMode,
valueResourceLimits: new Set(),
getterResourceLimits: new Set(),
returnedReferenceWidenings: new Set(),
parentsByNode: new WeakMap(),
scopes: [root],
scopesById: new Map([[root.scopeId, root]]),
@@ -61,7 +61,8 @@ it("bounds shared partial callback alias expansion by AST size", () => {
);
try {
expect(
createSemanticReturnedReferences(context.state)(last.argument.callee),
createSemanticReturnedReferences(context.state)(last.argument.callee)
.references,
).toHaveLength(1);
return {
callReads,
@@ -154,7 +155,8 @@ return a${depth}.make();}`);
);
try {
expect(
createSemanticReturnedReferences(context.state)(last.argument.callee),
createSemanticReturnedReferences(context.state)(last.argument.callee)
.references,
).toHaveLength(1);
return lookups.reduce(
(total, lookup) => total + lookup.mock.calls.length,
@@ -231,3 +233,37 @@ it("does not memoize alias cycle cuts across entry paths", () => {
});
}
});
const meshLookups = (writes: number): number => {
const source = `function f(){} function g(){} let a=f,b=g;
function run(){${Array.from({ length: writes }, (_, index) =>
index % 2 === 0 ? "a=b;" : "b=a;",
).join("")} a();}`;
const context = indexed(source);
const run = context.state.program.body[3];
if (!t.isFunctionDeclaration(run)) throw new Error("Missing run");
const statement = run.body.body.at(-1);
if (
!t.isExpressionStatement(statement) ||
!t.isCallExpression(statement.expression)
)
throw new Error("Missing mesh call");
const lookups = vi.spyOn(context.bindingCache, "get");
const result = context.resolve(statement.expression.callee);
expect(result).toMatchObject({ complete: false, cycleDependent: true });
expect(
result.callableIds.map((id) => context.callableById.get(id)?.name).sort(),
).toEqual(["f", "g"]);
return lookups.mock.calls.length;
};
it("closes cycles at full-binding reads with bounded lookup growth", () => {
const six = meshLookups(6);
const eight = meshLookups(8);
const ten = meshLookups(10);
const twelve = meshLookups(12);
expect(six).toBeGreaterThan(0);
expect(eight).toBeLessThan(3 * six);
expect(ten).toBeLessThan(3 * eight);
expect(twelve).toBeLessThan(3 * ten);
});
@@ -33,6 +33,8 @@ interface CallResolutionContext {
readonly state: JavaScriptSemanticAnalysisState;
readonly callableById: ReadonlyMap<string, JavaScriptSemanticCallable>;
readonly seenInitializers: ReadonlySet<JavaScriptSemanticInitializer>;
/** Bindings on the active chain whose read selected every initializer. */
readonly seenBindings: ReadonlySet<JavaScriptSemanticBindingState>;
readonly bindingCache: WeakMap<t.Identifier, LocalCallableResolution>;
}
@@ -53,6 +55,7 @@ export const resolveLocalCallables = (
state: input.state,
callableById: input.callableById,
seenInitializers: input.seenInitializers,
seenBindings: new Set(),
bindingCache: input.bindingCache,
});
@@ -103,7 +106,26 @@ const resolveIdentifier = (
node,
context.state.parentsByNode,
);
const candidates = resolveBindingCallables(read.binding, context);
// A full read re-enters the same candidate set wherever the chain returns
// to this binding. Close that cycle at the binding; position-narrowed reads
// keep their per-initializer cycle handling.
const selectsAll = read.binding === binding;
if (selectsAll && context.seenBindings.has(binding))
return {
callableIds: [],
complete: false,
cycleDependent: true,
reason: `Call target initializer cycle at ${binding.name}.`,
};
const candidates = resolveBindingCallables(
read.binding,
selectsAll
? {
...context,
seenBindings: new Set(context.seenBindings).add(binding),
}
: context,
);
// Knowing every local candidate does not prove that any write replaced the
// incoming value, or that a write in another activation precedes this read.
const covered =
@@ -1,3 +1,4 @@
import type { JavaScriptSourceRange } from "./javascriptStaticAnalysisTypes.js";
import type { JavaScriptSemanticResourceLimit } from "./javascriptSemanticValueTypes.js";
import type { ApplicationCoverage } from "./javascriptApplicationEvidenceSchemas.js";
import {
@@ -21,26 +22,49 @@ export type JavaScriptSemanticCoverage =
readonly status: "complete";
readonly omittedCount: 0;
readonly resourceLimits?: readonly JavaScriptSemanticResourceLimit[];
readonly widenedReturnedReferences?: readonly JavaScriptSourceRange[];
}
| {
readonly status: "partial";
readonly omittedCount: 0 | null;
readonly resourceLimits?: readonly JavaScriptSemanticResourceLimit[];
readonly widenedReturnedReferences?: readonly JavaScriptSourceRange[];
}
| {
readonly status: "failed";
readonly omittedCount: null;
readonly resourceLimits?: readonly JavaScriptSemanticResourceLimit[];
readonly widenedReturnedReferences?: readonly JavaScriptSourceRange[];
};
/** Classify whole-file semantic coverage from parsing and value bounds. */
/** Source-local effect candidates broadened at a projection merge, without dropping targets. */
export const RETURNED_REFERENCE_WIDENING_LIMITATION =
"Local returned-reference projections were conservatively widened; effects include additional source-local candidates.";
/** Classify whole-file semantic coverage from parsing, value bounds and widened projections. */
export const semanticCoverage = (
parserPartial: boolean,
resourceLimits: readonly JavaScriptSemanticResourceLimit[] = [],
): JavaScriptSemanticCoverage =>
resourceLimits.length > 0
? { status: "partial", omittedCount: null, resourceLimits }
: { status: parserPartial ? "partial" : "complete", omittedCount: 0 };
widenedReturnedReferences: readonly JavaScriptSourceRange[] = [],
): JavaScriptSemanticCoverage => {
const widened =
widenedReturnedReferences.length > 0 ? { widenedReturnedReferences } : {};
if (resourceLimits.length > 0)
return {
status: "partial",
omittedCount: null,
resourceLimits,
...widened,
};
return {
status:
parserPartial || widenedReturnedReferences.length > 0
? "partial"
: "complete",
omittedCount: 0,
...widened,
};
};
/** Read typed resource-limit classifications from any coverage state. */
export const semanticCoverageResourceLimits = (
@@ -761,7 +761,7 @@ export function* collectSemanticMemberMutationsSteps(
t.isOptionalCallExpression(node) ||
t.isNewExpression(node)
) {
for (const source of returnedReferences(node.callee))
for (const source of returnedReferences(node.callee).references)
markEscaped(source.node, node, source.projection);
for (const argument of node.arguments)
if (t.isSpreadElement(argument))
@@ -0,0 +1,207 @@
import * as t from "@babel/types";
import { expect, it } from "vitest";
import { indexJavaScriptBindingsSteps } from "./javascriptSemanticAnalysis.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js";
import { completeSemanticSteps } from "./javascriptSemanticTraversal.js";
const indexed = (source: string) => {
const parsed = parseJavaScriptSource(
source,
"returned-reference-projection.js",
);
if (parsed === null) throw new Error("Expected valid fixture");
const state = completeSemanticSteps(
indexJavaScriptBindingsSteps(parsed.program),
);
return { parsed, state };
};
const callReads = (program: t.Program, name: string): t.Identifier[] => {
const reads: t.Identifier[] = [];
t.traverseFast(program, (node) => {
if (t.isCallExpression(node) && t.isIdentifier(node.callee, { name }))
reads.push(node.callee);
});
return reads;
};
const callCallees = (program: t.Program, sourceName: string): t.Node[] => {
const callees: t.Node[] = [];
t.traverseFast(program, (node) => {
if (!t.isCallExpression(node)) return;
let root = node.callee;
while (t.isMemberExpression(root)) root = root.object;
if (t.isIdentifier(root, { name: sourceName })) callees.push(node.callee);
});
return callees;
};
const returnedExpression = (program: t.Program, name: string): t.Node => {
let found: t.Node | undefined;
t.traverseFast(program, (node) => {
if (
found === undefined &&
t.isFunctionDeclaration(node) &&
node.id?.name === name
)
t.traverseFast(node.body, (child) => {
if (
found === undefined &&
t.isReturnStatement(child) &&
child.argument !== null
)
found = child.argument;
});
});
if (found === undefined)
throw new Error(`Missing return expression in ${name}`);
return found;
};
const resultIdentifier = (program: t.Program, name: string): t.Identifier => {
let found: t.Identifier | undefined;
t.traverseFast(program, (node) => {
if (
found === undefined &&
((t.isReturnStatement(node) && node.argument !== null) ||
(t.isYieldExpression(node) && node.argument !== null))
)
t.traverseFast(node.argument, (child) => {
if (found === undefined && t.isIdentifier(child, { name }))
found = child;
});
});
if (found === undefined) throw new Error(`Missing result expression ${name}`);
return found;
};
it("keeps returned-reference targets at the source read position", () => {
const { parsed, state } = indexed(`
function beforeTarget() { return beforeResult; }
function afterTarget() { return afterResult; }
function choose() {
let callback = beforeTarget;
callback();
callback = afterTarget;
callback();
}
`);
const [beforeRead, afterRead] = callReads(parsed.program, "callback");
if (beforeRead === undefined || afterRead === undefined)
throw new Error("Missing callback reads");
const returnedReferences = createSemanticReturnedReferences(state);
const before = returnedReferences(beforeRead);
const after = returnedReferences(afterRead);
expect(before.precision).toBe("exact");
expect(before.references.map(({ node }) => node)).toEqual([
returnedExpression(parsed.program, "beforeTarget"),
]);
expect(after.precision).toBe("exact");
expect(after.references.map(({ node }) => node)).toEqual([
returnedExpression(parsed.program, "afterTarget"),
]);
});
it("resolves a finite object and array projection exactly", () => {
const { parsed, state } = indexed(`
function target() { return targetResult; }
const box = { nested: [target] };
const callback = box.nested[0];
callback();
`);
const [read] = callReads(parsed.program, "callback");
if (read === undefined) throw new Error("Missing callback read");
const result = createSemanticReturnedReferences(state)(read);
expect(result.precision).toBe("exact");
expect(result.references.map(({ node }) => node)).toEqual([
returnedExpression(parsed.program, "target"),
]);
});
it("reports conservative widening for a shrinking projection cycle", () => {
const { parsed, state } = indexed(`
function target() { return targetResult; }
let cursor = { target };
while (flag) cursor = { next: cursor, target };
cursor.next.next.target();
`);
const [callee] = callCallees(parsed.program, "cursor");
if (callee === undefined) throw new Error("Missing projected call");
const result = createSemanticReturnedReferences(state)(callee);
expect(result.precision).toBe("widened");
expect(result.references.map(({ node }) => node)).toEqual([
returnedExpression(parsed.program, "target"),
]);
});
it("widens a growing projection while retaining zero-depth and nested targets", () => {
const { parsed, state } = indexed(`
function zeroDepthTarget() { return zeroDepthResult; }
function objectTarget() { return objectResult; }
function arrayTarget() { return arrayResult; }
function* generatorTarget() { yield* generatorResult; }
const shape = {
zero: zeroDepthTarget,
nested: {
object: objectTarget,
array: [arrayTarget],
class: class NestedTarget { method() { return classResult; } },
generator: generatorTarget,
},
};
let cursor = shape;
function walk(flag) {
while (flag) cursor = cursor.next;
return cursor();
}
walk(true);
`);
const [read] = callReads(parsed.program, "cursor");
if (read === undefined) throw new Error("Missing cursor read");
const result = createSemanticReturnedReferences(state)(read);
const expressions = result.references.map(({ node }) => node);
expect(result.precision).toBe("widened");
for (const name of ["zeroDepthTarget", "objectTarget", "arrayTarget"])
expect(expressions).toContain(returnedExpression(parsed.program, name));
expect(expressions).toContain(
resultIdentifier(parsed.program, "classResult"),
);
const generatorResult = resultIdentifier(parsed.program, "generatorResult");
expect(result.references).toContainEqual({
node: generatorResult,
projection: [null],
});
});
it("widens a cycle that rewrites the path head while increasing its depth", () => {
const { parsed, state } = indexed(`
function target() { return targetResult; }
let value = {};
while (flag)
value = {
a: value.x.y,
x: value.a.z,
target,
};
value.a.b();
`);
const [callee] = callCallees(parsed.program, "value");
if (callee === undefined) throw new Error("Missing projected call");
const result = createSemanticReturnedReferences(state)(callee);
expect(result.precision).toBe("widened");
expect(result.references.map(({ node }) => node)).toContain(
returnedExpression(parsed.program, "target"),
);
});
@@ -13,7 +13,6 @@ import {
import {
resolveSemanticBindingState,
type JavaScriptSemanticAnalysisState,
type JavaScriptSemanticInitializer,
} from "./javascriptSemanticState.js";
/** A reference potentially handed to a caller or generator consumer. */
@@ -24,73 +23,34 @@ export interface SemanticReturnedReference {
interface Candidate {
readonly node: t.Node;
readonly path: readonly (string | number | null)[];
readonly initializers: ReadonlySet<JavaScriptSemanticInitializer>;
/** `undefined` means any projection depth after different projection depths merge. */
readonly path: readonly (string | number | null)[] | undefined;
}
type InitializerContext = ReadonlySet<JavaScriptSemanticInitializer>;
type CompletedExpansions = WeakMap<
t.Node,
Map<string, readonly InitializerContext[]>
>;
interface ExpansionFrame {
readonly item: Candidate;
readonly node: t.Node;
readonly pathKey: string;
readonly parent: ExpansionFrame | null;
readonly blockers: Set<JavaScriptSemanticInitializer>;
export interface SemanticReturnedReferenceResult {
readonly references: readonly SemanticReturnedReference[];
/** Both states close the supported source-local may-target graph. Widened joins projections without dropping represented targets; it does not claim runtime completeness. */
readonly precision: "exact" | "widened";
}
type ExpansionTask =
| {
readonly kind: "visit";
readonly item: Candidate;
readonly parent: ExpansionFrame | null;
}
| { readonly kind: "complete"; readonly frame: ExpansionFrame };
const contextContains = (
context: InitializerContext,
required: InitializerContext,
): boolean => {
for (const initializer of required)
if (!context.has(initializer)) return false;
return true;
const joinProjection = (
left: Candidate["path"],
right: Candidate["path"],
): Candidate["path"] => {
if (left === undefined || right === undefined || left.length !== right.length)
return undefined;
return left.map((key, index) => (key === right[index] ? key : null));
};
const propagateBlockers = (
blockers: InitializerContext,
parent: ExpansionFrame | null,
): void => {
if (parent === null) return;
for (const initializer of blockers)
if (parent.item.initializers.has(initializer))
parent.blockers.add(initializer);
};
const completeExpansion = (
frame: ExpansionFrame,
expanded: CompletedExpansions,
): void => {
const paths =
expanded.get(frame.node) ??
new Map<string, readonly InitializerContext[]>();
const contexts = paths.get(frame.pathKey) ?? [];
const covered = contexts.find((context) =>
contextContains(frame.blockers, context),
);
if (covered === undefined) {
paths.set(frame.pathKey, [
...contexts.filter(
(context) => !contextContains(context, frame.blockers),
),
frame.blockers,
]);
expanded.set(frame.node, paths);
}
propagateBlockers(covered ?? frame.blockers, frame.parent);
};
const sameProjection = (
left: Candidate["path"],
right: Candidate["path"],
): boolean =>
left === undefined
? right === undefined
: right !== undefined &&
left.length === right.length &&
left.every((key, index) => key === right[index]);
interface ObjectTargetIndex {
readonly propertyByName: ReadonlyMap<
@@ -111,51 +71,45 @@ interface ObjectTargetUnknown {
/** Resolve local result references conservatively, without claiming call execution. */
export const createSemanticReturnedReferences = (
state: JavaScriptSemanticAnalysisState,
): ((callee: t.Node) => readonly SemanticReturnedReference[]) => {
const results = new WeakMap<t.Node, readonly SemanticReturnedReference[]>();
): ((callee: t.Node) => SemanticReturnedReferenceResult) => {
const results = new WeakMap<t.Node, SemanticReturnedReferenceResult>();
const objectTargets = new WeakMap<t.ObjectExpression, ObjectTargetIndex>();
return (callee) => {
const existing = results.get(callee);
if (existing !== undefined) return existing;
const functions = new Set<t.Node>();
const work: ExpansionTask[] = [
{
kind: "visit",
item: { node: callee, path: [], initializers: new Set() },
parent: null,
},
];
// Finish descendants before memoizing an expansion. Only actual external
// cycle blockers affect reuse; branch history is not a dependency. An
// acyclic expression/path is consequently expanded once across a diamond.
const expanded: CompletedExpansions = new WeakMap();
const functions = new Set<t.Function>();
const work: t.Node[] = [];
// A transition depends on the source read and current projection, not the
// initializer history used to reach it. Keeping history out of the key lets
// shared diamonds and reassignment cycles converge to the graph fixed point.
const projections = new WeakMap<t.Node, Candidate["path"]>();
const queued = new WeakSet<t.Node>();
const result: {
references: readonly SemanticReturnedReference[];
precision: SemanticReturnedReferenceResult["precision"];
} = { references: [], precision: "exact" };
const enqueue = (node: t.Node, path: Candidate["path"]): void => {
const source = unwrapJavaScriptExpression(node).node;
if (!projections.has(source)) {
projections.set(source, path);
} else {
const previous = projections.get(source);
const joined = joinProjection(previous, path);
if (sameProjection(previous, joined)) return;
projections.set(source, joined);
result.precision = "widened";
}
if (!queued.has(source)) {
work.push(source);
queued.add(source);
}
};
enqueue(callee, []);
while (work.length > 0) {
const task = work.pop();
if (task === undefined) break;
if (task.kind === "complete") {
completeExpansion(task.frame, expanded);
continue;
}
const { item, parent } = task;
const node = unwrapJavaScriptExpression(item.node).node;
const pathKey = JSON.stringify(item.path);
const covered = expanded
.get(node)
?.get(pathKey)
?.find((context) => contextContains(item.initializers, context));
if (covered !== undefined) {
// Cached cycle dependencies must reach the caller's proof too.
propagateBlockers(covered, parent);
continue;
}
const frame: ExpansionFrame = {
item,
node,
pathKey,
parent,
blockers: new Set(),
};
work.push({ kind: "complete", frame });
const node = work.pop();
if (node === undefined) break;
queued.delete(node);
const current: Candidate = { node, path: projections.get(node) };
const pending: Candidate[] = [];
if (t.isIdentifier(node)) {
const binding = resolveSemanticBindingState(state, node, node.name);
@@ -169,14 +123,12 @@ export const createSemanticReturnedReferences = (
...selected.initializers,
...selected.referenceInitializers,
]) {
if (item.initializers.has(initializer)) {
frame.blockers.add(initializer);
continue;
}
pending.push({
node: initializer.node,
path: [...initializer.projection, ...item.path],
initializers: new Set([...item.initializers, initializer]),
path:
current.path === undefined
? undefined
: [...initializer.projection, ...current.path],
});
}
} else if (
@@ -195,9 +147,12 @@ export const createSemanticReturnedReferences = (
receiver = unwrapJavaScriptExpression(receiver.object).node;
}
pending.push({
...item,
...current,
node: receiver,
path: [...path.reverse(), ...item.path],
path:
current.path === undefined
? undefined
: [...path.reverse(), ...current.path],
});
} else if (
t.isConditionalExpression(node) ||
@@ -205,90 +160,102 @@ export const createSemanticReturnedReferences = (
) {
pending.push(
{
...item,
...current,
node: t.isConditionalExpression(node) ? node.consequent : node.left,
},
{
...item,
...current,
node: t.isConditionalExpression(node) ? node.alternate : node.right,
},
);
} else if (t.isSequenceExpression(node)) {
const last = node.expressions.at(-1);
if (last !== undefined) pending.push({ ...item, node: last });
} else if (t.isFunction(node) && item.path.length === 0)
if (last !== undefined) pending.push({ ...current, node: last });
} else if (
t.isFunction(node) &&
(current.path === undefined || current.path.length === 0)
)
functions.add(node);
else if (t.isObjectExpression(node))
selectObjectTargets(node, item, pending, objectTargets);
selectObjectTargets(node, current, pending, objectTargets);
else if (t.isArrayExpression(node)) {
const [key, ...path] = item.path;
const [key, ...path] = current.path ?? [];
const index =
key === undefined || key === null ? null : semanticArrayIndex(key);
current.path === undefined || key === undefined || key === null
? null
: semanticArrayIndex(key);
let uncertainOffset = false;
node.elements.forEach((element, offset) => {
if (element === null) return;
if (t.isSpreadElement(element)) {
uncertainOffset = true;
pending.push({
...item,
...current,
node: element.argument,
path: [null, ...path],
path: current.path === undefined ? undefined : [null, ...path],
});
} else if (index === null || uncertainOffset || index === offset)
pending.push({ ...item, node: element, path });
pending.push({
...current,
node: element,
path: current.path === undefined ? undefined : path,
});
});
} else if (t.isNewExpression(node))
pending.push({ ...item, node: node.callee });
pending.push({ ...current, node: node.callee });
else if (t.isClass(node)) {
const [key, ...path] = item.path;
const [key, ...path] = current.path ?? [];
for (const member of node.body.body) {
if (t.isClassMethod(member)) {
if (
(item.path.length === 0 && member.kind === "constructor") ||
((current.path === undefined || current.path.length === 0) &&
member.kind === "constructor") ||
current.path === undefined ||
(key !== undefined &&
(key === null ||
semanticStaticPropertyKey(member.key, member.computed) ===
String(key)))
)
pending.push({ ...item, node: member, path });
pending.push({
...current,
node: member,
path: current.path === undefined ? undefined : path,
});
} else if (
t.isClassProperty(member) &&
member.value !== null &&
member.value !== undefined &&
key !== undefined &&
(key === null ||
(key !== undefined || current.path === undefined) &&
(current.path === undefined ||
key === null ||
semanticStaticPropertyKey(member.key, member.computed) ===
String(key))
)
pending.push({ ...item, node: member.value, path });
pending.push({
...current,
node: member.value,
path: current.path === undefined ? undefined : path,
});
}
if (node.superClass !== null && node.superClass !== undefined)
pending.push({ ...item, node: node.superClass });
pending.push({ ...current, node: node.superClass });
}
for (let index = pending.length - 1; index >= 0; index--) {
const child = pending[index];
if (child !== undefined)
work.push({ kind: "visit", item: child, parent: frame });
if (child !== undefined) enqueue(child.node, child.path);
}
}
const output = [...functions].flatMap((node) => {
const callables = t.isClass(node)
? node.body.body.filter(
(member) =>
t.isClassMethod(member) && member.kind === "constructor",
)
: [node];
return callables.flatMap((callable) =>
semanticCallableResultExpressions(callable).flatMap(
({ node, delegated }) =>
node === null
? []
: [{ node, projection: delegated ? [null] : [] }],
),
);
});
results.set(callee, output);
return output;
const output = [...functions].flatMap((callable) =>
semanticCallableResultExpressions(callable).flatMap(
({ node, delegated }) =>
node === null ? [] : [{ node, projection: delegated ? [null] : [] }],
),
);
result.references = output;
results.set(callee, result);
if (result.precision === "widened")
state.returnedReferenceWidenings.add(callee);
return result;
};
};
@@ -298,7 +265,8 @@ const selectObjectTargets = (
pending: Candidate[],
indexes: WeakMap<t.ObjectExpression, ObjectTargetIndex>,
): void => {
const [key, ...path] = item.path;
const [key, ...path] = item.path ?? [];
const childPath = item.path === undefined ? undefined : path;
if (key !== null && key !== undefined) {
const index = objectTargetIndex(node, indexes);
const property = index.propertyByName.get(String(key));
@@ -318,12 +286,12 @@ const selectObjectTargets = (
: t.isObjectProperty(unknown)
? unknown.value
: unknown,
...(t.isSpreadElement(unknown) ? {} : { path }),
...(t.isSpreadElement(unknown) ? {} : { path: childPath }),
});
pending.push({
...item,
node: t.isObjectProperty(property) ? property.value : property,
path,
path: childPath,
});
return;
}
@@ -344,8 +312,8 @@ const selectObjectTargets = (
)
continue;
if (t.isObjectProperty(property))
pending.push({ ...item, node: property.value, path });
else pending.push({ ...item, node: property, path });
pending.push({ ...item, node: property.value, path: childPath });
else pending.push({ ...item, node: property, path: childPath });
if (key !== null && key !== undefined && name === String(key))
replaced = true;
}
@@ -0,0 +1,125 @@
import * as t from "@babel/types";
import { expect, it, vi } from "vitest";
import { indexJavaScriptBindingsSteps } from "./javascriptSemanticAnalysis.js";
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js";
const indexed = (source: string) => {
const parsed = parseJavaScriptSource(source, "returned-references.js");
if (parsed === null) throw new Error("Expected valid fixture");
const steps = indexJavaScriptBindingsSteps(parsed.program);
let step = steps.next();
while (step.done !== true) step = steps.next();
return step.value;
};
const measuredSwap = (writes: number) => {
const source = `
function first(){return {x:{deep:{leaf:1}}}}
function second(){return {x:{deep:{leaf:2}}}}
let a=first,b=second;
function run(){
${Array.from({ length: writes }, () => "a=b;b=a;").join("")}
a().x=1;
}
`;
const state = indexed(source);
let callee: t.Identifier | undefined;
t.traverseFast(state.program, (node) => {
if (
callee === undefined &&
t.isCallExpression(node) &&
t.isIdentifier(node.callee, { name: "a" })
)
callee = node.callee;
});
if (callee === undefined) throw new Error("Missing selected call read");
const lookups = state.scopes.map((scope) => vi.spyOn(scope.bindings, "get"));
try {
const result = createSemanticReturnedReferences(state)(callee);
return {
result,
reads: lookups.reduce(
(count, lookup) => count + lookup.mock.calls.length,
0,
),
};
} finally {
for (const lookup of lookups) lookup.mockRestore();
}
};
const measuredSwitchWeb = (arms: number) => {
const bindings = ["t", "e", "a", "n", "r"];
const keys = [
"child",
"sibling",
"return",
"memoizedState",
"pendingProps",
"stateNode",
];
const cases = Array.from({ length: arms }, (_, index) => {
const target = bindings[index % bindings.length];
const source = bindings[(index + 1) % bindings.length];
const key = keys[index % keys.length];
return `case ${index}: ${target} = ${source}.${key}; break;`;
}).join("\n");
const state = indexed(`
function beginWork(t,e,a,n,r) {
switch(t.tag) { ${cases} }
const d=t.deletions;
d.push(n);
return t;
}
beginWork(fiber,e0,a0,n0,r0);
`);
let callee: t.MemberExpression | undefined;
t.traverseFast(state.program, (node) => {
if (
callee === undefined &&
t.isCallExpression(node) &&
t.isMemberExpression(node.callee) &&
t.isIdentifier(node.callee.object, { name: "d" }) &&
t.isIdentifier(node.callee.property, { name: "push" })
)
callee = node.callee;
});
if (callee === undefined) throw new Error("Missing switch-web sink");
const lookups = state.scopes.map((scope) => vi.spyOn(scope.bindings, "get"));
try {
const result = createSemanticReturnedReferences(state)(callee);
return {
result,
reads: lookups.reduce(
(count, lookup) => count + lookup.mock.calls.length,
0,
),
};
} finally {
for (const lookup of lookups) lookup.mockRestore();
}
};
it("bounds shared reassignment initializer expansion by visited AST projections", () => {
const small = measuredSwap(8);
const large = measuredSwap(18);
expect(small.result.precision).toBe("exact");
expect(large.result.precision).toBe("exact");
expect(small.result.references.length).toBeGreaterThan(0);
expect(large.result.references.length).toBeGreaterThanOrEqual(
small.result.references.length,
);
expect(small.reads).toBeGreaterThan(0);
expect(large.reads).toBeLessThan(3 * small.reads);
});
it("bounds returned-reference expansion across a React-shaped switch mesh", () => {
const small = measuredSwitchWeb(12);
const large = measuredSwitchWeb(24);
expect(small.result.references).toEqual([]);
expect(large.result.references).toEqual([]);
expect(small.reads).toBeGreaterThan(0);
expect(large.reads).toBeLessThan(4 * small.reads);
});
@@ -109,6 +109,8 @@ export interface JavaScriptSemanticAnalysisState {
readonly valueResourceLimits: Set<JavaScriptSemanticResourceLimit>;
/** Getter query bounds encountered while resolving object and class properties. */
readonly getterResourceLimits: Set<JavaScriptSemanticResourceLimit>;
/** Callee sites whose possible local returns needed a broader projection. */
readonly returnedReferenceWidenings: Set<t.Node>;
readonly parentsByNode: WeakMap<t.Node, t.Node>;
readonly scopes: JavaScriptSemanticScopeState[];
readonly scopesById: Map<string, JavaScriptSemanticScopeState>;
@@ -1,4 +1,5 @@
import * as t from "@babel/types";
import { RETURNED_REFERENCE_WIDENING_LIMITATION } from "./javascriptSemanticCoverage.js";
import { collectElectronWrappedCallPaths } from "./electronWrappedCallPaths.js";
import {
ELECTRON_IDENTITY_LIMITATION,
@@ -118,7 +119,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
electronIdentity,
);
addSourceMapDirectives(source, file.comments ?? [], accumulator);
return finalizeStaticAnalysis(source, file, accumulator);
return finalizeStaticAnalysis(source, file, accumulator, bindings);
}
function* traverseStaticSourceSteps(
@@ -140,9 +141,13 @@ const finalizeStaticAnalysis = (
source: string,
file: ParsedJavaScriptSource,
accumulator: AnalysisAccumulator,
bindings: JavaScriptSemanticAnalysisState,
): JavaScriptStaticAnalysis => {
const parserErrors = file.errors.length;
const limitations = [
...(bindings.returnedReferenceWidenings.size > 0
? [RETURNED_REFERENCE_WIDENING_LIMITATION]
: []),
...(accumulator.electronCallPaths?.length
? [
"Bundled Electron call paths use static argument and closure substitutions. Conditions, ambient helper overrides, runtime reachability and arbitrary side effects remain unproven.",
+39
View File
@@ -113,6 +113,45 @@ it.each([
},
);
it("discloses widened returned-reference effects only for the affected script", () => {
const captured = inspection(`
function expose() { return Map.prototype; }
let cursor = { target: expose };
function walk(flag) {
while (flag) cursor = cursor.next;
patch(cursor.target());
}
new Map().get('/candidate');
fetch('/preserved');
`);
const independent = inspection(
"new Map().get('/intrinsic'); fetch('/independent');",
).scripts.items[0];
if (independent === undefined) throw new Error("Missing independent script");
captured.scripts.items.push({ ...independent, script_key: "independent" });
captured.scripts.total += 1;
const result = analyzeCapturedWebBundle(captured);
expect(result.completeness).toMatchObject({
status: "partial",
parsed_scripts: 2,
parse_failures: 0,
});
expect(result.unknowns).toContainEqual({
dimension: "javascript_ast",
reason: expect.stringContaining("returned-reference projections"),
affected_script_keys: [captured.scripts.items[0]?.script_key],
});
expect(result.observations.endpoints.map(({ value }) => value)).toEqual([
"/preserved",
"/independent",
]);
expect(
result.inferences
.filter(({ kind }) => kind === "endpoint_candidate")
.map(({ value }) => value),
).toEqual(["/candidate"]);
});
describe("web bundle analyzer", () => {
it("extracts graph, route, endpoint, vendor, and WebMCP evidence", () => {
const source = `
@@ -52,6 +52,7 @@ export interface AnalysisAccumulator {
parsedScripts: number;
readonly failedScripts: string[];
readonly recoveredScripts: string[];
readonly widenedScripts: string[];
}
export const analyzeScript = (
@@ -116,6 +117,8 @@ export const analyzeScript = (
node: endpoint.node,
accumulator,
});
if (index.bindings.returnedReferenceWidenings.size > 0)
accumulator.widenedScripts.push(script.script_key);
};
const inspectNode = (
@@ -470,6 +473,7 @@ export const emptyAccumulator = (): AnalysisAccumulator => ({
parsedScripts: 0,
failedScripts: [],
recoveredScripts: [],
widenedScripts: [],
});
export const isIncludedScript = (
+13
View File
@@ -1,3 +1,4 @@
import { RETURNED_REFERENCE_WIDENING_LIMITATION } from "./javascript/javascriptSemanticCoverage.js";
import type { WebPageInspection } from "./browserObservationSchemas.js";
import {
webBundleAnalysisSchema,
@@ -27,6 +28,7 @@ export const buildWebBundleAnalysis = (
const partial =
accumulator.failedScripts.length > 0 ||
accumulator.recoveredScripts.length > 0 ||
accumulator.widenedScripts.length > 0 ||
unavailable.length > 0 ||
sourceMapIncomplete ||
omittedScripts !== undefined ||
@@ -48,6 +50,7 @@ export const buildWebBundleAnalysis = (
unavailable,
failedScripts: accumulator.failedScripts,
recoveredScripts: accumulator.recoveredScripts,
widenedScripts: accumulator.widenedScripts,
sourceMaps,
omittedScripts,
omittedSourceMaps,
@@ -92,6 +95,7 @@ interface UnknownsInput {
readonly unavailable: string[];
readonly failedScripts: readonly string[];
readonly recoveredScripts: readonly string[];
readonly widenedScripts: readonly string[];
readonly sourceMaps: WebBundleAnalysis["observations"]["source_maps"];
readonly omittedScripts: number | null | undefined;
readonly omittedSourceMaps: number | null | undefined;
@@ -125,6 +129,15 @@ const buildUnknowns = (input: UnknownsInput): WebBundleAnalysis["unknowns"] => [
affected_script_keys: [...input.failedScripts],
},
]),
...(input.widenedScripts.length === 0
? []
: [
{
dimension: "javascript_ast" as const,
reason: RETURNED_REFERENCE_WIDENING_LIMITATION,
affected_script_keys: [...input.widenedScripts],
},
]),
...(input.recoveredScripts.length === 0
? []
: [
@@ -528,23 +528,29 @@ const coverage = z
status: z.literal("complete"),
omittedCount: z.literal(0),
resourceLimits: z.array(resourceLimit).optional(),
widenedReturnedReferences: z.array(range).optional(),
}),
z.strictObject({
status: z.literal("partial"),
omittedCount: z.union([z.literal(0), z.null()]),
resourceLimits: z.array(resourceLimit).optional(),
widenedReturnedReferences: z.array(range).optional(),
}),
z.strictObject({
status: z.literal("failed"),
omittedCount: z.null(),
resourceLimits: z.array(resourceLimit).optional(),
widenedReturnedReferences: z.array(range).optional(),
}),
])
.transform((value) => {
const { resourceLimits, ...rest } = value;
const { resourceLimits, widenedReturnedReferences, ...rest } = value;
return {
...rest,
...(resourceLimits === undefined ? {} : { resourceLimits }),
...(widenedReturnedReferences === undefined
? {}
: { widenedReturnedReferences }),
};
});
const moduleLink = z
@@ -0,0 +1,108 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const verify = (value: unknown) => {
const result = javascriptApplicationAnalysisResultSchema.parse(
parseEvidence(value).normalized_result,
);
expect(result.statistics).toMatchObject({
parsed_javascript_files: 3,
skipped_javascript_files: 0,
parse_failures: 0,
});
const endpoints = result.graph.nodes
.filter(({ kind }) => kind === "endpoint")
.flatMap(({ observations }) =>
observations.map(({ properties }) => ({
value: properties.value,
interpretation: properties.interpretation,
})),
);
expect(endpoints).toContainEqual({
value: "/preserved",
interpretation: undefined,
});
expect(endpoints).toContainEqual({
value: "/possibly-patched",
interpretation: "candidate",
});
expect(endpoints).toContainEqual({
value: "/growing",
interpretation: "candidate",
});
expect(endpoints.some(({ value }) => value === "/intrinsic")).toBe(false);
expect(result.semantic_graph.unknowns).toContainEqual(
expect.objectContaining({
reason: "dynamic-property",
detail: expect.stringContaining("returned-reference projections"),
}),
);
};
cliTest(
"retains static facts and possible prototype effects across a recursive alias web in CLI and MCP",
async ({ cli }) => {
const root = await createTestTempDirectory(
"rea-returned-reference-cycles-",
);
await writeFile(
join(root, "cycles.js"),
`function local() { return {}; }
function exposed() { return Map.prototype; }
let a = local, b = exposed;
function patch(flag) {
${Array.from({ length: 28 }, (_, index) =>
index % 2 === 0 ? "a = b;" : "b = a;",
).join("\n")}
a().get = fetch;
}
fetch("/preserved");
new Map().get("/possibly-patched");`,
);
await writeFile(
join(root, "independent.js"),
'const records = new Map(); records.get("/intrinsic");',
);
await writeFile(
join(root, "growing.js"),
`function expose() { return Map.prototype; }
let cursor = { target: expose };
function walk(flag) {
while (flag) cursor = cursor.next;
patch(cursor.target());
}
new Map().get("/growing");`,
);
const response = await cli.run({
arguments: [
"analyze-javascript-application",
root,
"--max-heap-mb",
"256",
"--analysis-timeout-ms",
"30000",
"--format",
"json",
],
});
expect(response.exitCode, response.stderr).toBe(0);
verify(response.json);
const { call } = await connectLocalToolsMcp();
const analyzed = await call("analyze_javascript_application", {
input_path: root,
max_heap_mb: 256,
analysis_timeout_ms: 30_000,
});
expect(analyzed.isError, JSON.stringify(analyzed.content)).not.toBe(true);
verify(analyzed.structuredContent);
},
90_000,
);