diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 92310f4d2c..50ebc287b8 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -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; diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts index a02b60c0c6..d39a7ef27c 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts @@ -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. diff --git a/src/domain/javascript/javascriptReturnedReferenceCoverage.test.ts b/src/domain/javascript/javascriptReturnedReferenceCoverage.test.ts new file mode 100644 index 0000000000..b6131c0996 --- /dev/null +++ b/src/domain/javascript/javascriptReturnedReferenceCoverage.test.ts @@ -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", + }), + ]), + ); +}); diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index b6a2377340..54bccbfc26 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -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 diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index ad7ff2f7d0..239c0a79a6 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -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]]), diff --git a/src/domain/javascript/javascriptSemanticCallResolution.test.ts b/src/domain/javascript/javascriptSemanticCallResolution.test.ts index fa953ce49f..54e1dc0ef7 100644 --- a/src/domain/javascript/javascriptSemanticCallResolution.test.ts +++ b/src/domain/javascript/javascriptSemanticCallResolution.test.ts @@ -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); +}); diff --git a/src/domain/javascript/javascriptSemanticCallResolution.ts b/src/domain/javascript/javascriptSemanticCallResolution.ts index c034751570..4ca40cc9f6 100644 --- a/src/domain/javascript/javascriptSemanticCallResolution.ts +++ b/src/domain/javascript/javascriptSemanticCallResolution.ts @@ -33,6 +33,8 @@ interface CallResolutionContext { readonly state: JavaScriptSemanticAnalysisState; readonly callableById: ReadonlyMap; readonly seenInitializers: ReadonlySet; + /** Bindings on the active chain whose read selected every initializer. */ + readonly seenBindings: ReadonlySet; readonly bindingCache: WeakMap; } @@ -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 = diff --git a/src/domain/javascript/javascriptSemanticCoverage.ts b/src/domain/javascript/javascriptSemanticCoverage.ts index 143f8aa09a..cd1d55fb9d 100644 --- a/src/domain/javascript/javascriptSemanticCoverage.ts +++ b/src/domain/javascript/javascriptSemanticCoverage.ts @@ -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 = ( diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 18f3be72e6..872d8bb46c 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -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)) diff --git a/src/domain/javascript/javascriptSemanticReturnedReferences.projection.test.ts b/src/domain/javascript/javascriptSemanticReturnedReferences.projection.test.ts new file mode 100644 index 0000000000..28957b55af --- /dev/null +++ b/src/domain/javascript/javascriptSemanticReturnedReferences.projection.test.ts @@ -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"), + ); +}); diff --git a/src/domain/javascript/javascriptSemanticReturnedReferences.ts b/src/domain/javascript/javascriptSemanticReturnedReferences.ts index fd3fa4fa40..403a2c01a8 100644 --- a/src/domain/javascript/javascriptSemanticReturnedReferences.ts +++ b/src/domain/javascript/javascriptSemanticReturnedReferences.ts @@ -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; + /** `undefined` means any projection depth after different projection depths merge. */ + readonly path: readonly (string | number | null)[] | undefined; } -type InitializerContext = ReadonlySet; -type CompletedExpansions = WeakMap< - t.Node, - Map ->; - -interface ExpansionFrame { - readonly item: Candidate; - readonly node: t.Node; - readonly pathKey: string; - readonly parent: ExpansionFrame | null; - readonly blockers: Set; +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(); - 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(); +): ((callee: t.Node) => SemanticReturnedReferenceResult) => { + const results = new WeakMap(); const objectTargets = new WeakMap(); return (callee) => { const existing = results.get(callee); if (existing !== undefined) return existing; - const functions = new Set(); - 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(); + 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(); + const queued = new WeakSet(); + 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, ): 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; } diff --git a/src/domain/javascript/javascriptSemanticReturnedReferencesBounded.test.ts b/src/domain/javascript/javascriptSemanticReturnedReferencesBounded.test.ts new file mode 100644 index 0000000000..e9aec4049a --- /dev/null +++ b/src/domain/javascript/javascriptSemanticReturnedReferencesBounded.test.ts @@ -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); +}); diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index 2f3eec92df..e6e388280a 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -109,6 +109,8 @@ export interface JavaScriptSemanticAnalysisState { readonly valueResourceLimits: Set; /** Getter query bounds encountered while resolving object and class properties. */ readonly getterResourceLimits: Set; + /** Callee sites whose possible local returns needed a broader projection. */ + readonly returnedReferenceWidenings: Set; readonly parentsByNode: WeakMap; readonly scopes: JavaScriptSemanticScopeState[]; readonly scopesById: Map; diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 758355af25..ebf69a2f0e 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -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.", diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index ff13fde934..291978c507 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -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 = ` diff --git a/src/domain/webBundleAnalyzerInspection.ts b/src/domain/webBundleAnalyzerInspection.ts index 87e1022d12..696a049f8f 100644 --- a/src/domain/webBundleAnalyzerInspection.ts +++ b/src/domain/webBundleAnalyzerInspection.ts @@ -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 = ( diff --git a/src/domain/webBundleAnalyzerResult.ts b/src/domain/webBundleAnalyzerResult.ts index b34867f85e..903f5d6864 100644 --- a/src/domain/webBundleAnalyzerResult.ts +++ b/src/domain/webBundleAnalyzerResult.ts @@ -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 ? [] : [ diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index e3da83f938..2ac16ad355 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -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 diff --git a/tests/acceptance/applications/javascriptReturnedReferenceCycles.test.ts b/tests/acceptance/applications/javascriptReturnedReferenceCycles.test.ts new file mode 100644 index 0000000000..981806e4dd --- /dev/null +++ b/tests/acceptance/applications/javascriptReturnedReferenceCycles.test.ts @@ -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, +);