mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
1 parent
9af0b6113c
commit
e4c3d72396
19 files changed
+791
-166
No files matched your search
@@ -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.",
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
Reference in new issue
Block a user