diff --git a/src/domain/javascript/javascriptSemanticAnalysis.reference_lifetimes.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.reference_lifetimes.test.ts index 2795427bd4..73c7897b0f 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.reference_lifetimes.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.reference_lifetimes.test.ts @@ -9,6 +9,163 @@ const resultValue = (body: string) => "result", ).returnSites[0]?.value; +describe("bounded member path propagation", () => { + it("bounds cyclic member paths with no observed container origin", () => { + const branches = Array.from( + { length: 16 }, + (_, index) => `if (flag === ${index}) node = node.m${index};`, + ).join(" else "); + const start = performance.now(); + const callable = onlyCallable( + analyzeJavaScriptSemantics( + `export function result(seed) { let node=seed; while(node) { ${branches} use(node.state); } return seed.keep; }`, + ), + "result", + ); + expect(callable.returnSites[0]?.value.status).toBe("unknown"); + expect(performance.now() - start).toBeLessThan(2000); + }, 30000); + + it("preserves Array prototype effects through an otherwise unknown alias", () => { + expect( + resultValue( + "const values=[1]; const prototype=Array.prototype; prototype[Symbol.iterator]=custom; for(const item of values) { mutate(item); } return values[0];", + )?.status, + ).toBe("unknown"); + }); + + it("bounds loops derived from a scalar member while preserving sibling facts", () => { + const branches = Array.from( + { length: 16 }, + (_, index) => `if (flag === ${index}) node = node.m${index};`, + ).join(" else "); + const start = performance.now(); + expect( + resultValue( + `const source={child:1,keep:{value:3}}; let node=source.child; while(node) { ${branches} use(node.state); } return source.keep.value;`, + ), + ).toEqual({ status: "literal", value: 3 }); + expect(performance.now() - start).toBeLessThan(2000); + }, 30000); + + it.each([ + ["child:external,", "child", "present"], + ["", "missing", "unknown-coverage"], + ])( + "retains slot presence below an unobserved container: %s", + (property, name, presence) => { + expect( + resultValue( + `const source={${property}keep:3}; const alias=source.${name}; alias.deep.value=2; return source;`, + ), + ).toMatchObject({ + status: "object", + properties: expect.arrayContaining([ + { + name, + presence, + value: { + status: "unknown", + reason: "This property may have been mutated.", + }, + }, + { + name: "keep", + presence: "present", + value: { status: "literal", value: 3 }, + }, + ]), + }); + }, + ); + + it.each([ + ["use(node);", "m0.value", "unknown"], + ["node.value = 2;", "m0.value", "unknown"], + ["node.value = 2;", "mode", "literal"], + ])( + "bounds a loop that reassigns one reference through many members: %s seed.%s", + (effect, read, status) => { + const branches = Array.from( + { length: 9 }, + (_, index) => `if (flag === ${index}) node = node.m${index};`, + ).join(" else "); + const start = performance.now(); + const value = resultValue( + `const seed = { mode: "initial", m0: { value: 1 } }; let node = seed; while (node) { ${branches} ${effect} } return seed.${read};`, + ); + expect(performance.now() - start).toBeLessThan(2000); + expect(value?.status).toBe(status); + }, + 30000, + ); + + it.each([ + ["const alias = flag ? owner.child : owner; alias.value = 2;", "value"], + ["const alias = flag ? owner : owner.child; alias.value = 2;", "value"], + ["const alias = flag ? owner.child : owner; alias[key] = 2;", "value"], + [ + "const alias = flag ? owner.child : owner; alias.child = 2;", + "child.value", + ], + [ + "let alias = owner; let i = 0; while (i++ < 1) { alias = flag ? alias.child : alias; alias.value = 2; }", + "value", + ], + ])( + "keeps a write that reaches a child through a longer path: %s", + (body, read) => { + expect( + resultValue( + `const shared = { value: 1, child: { value: 1 } }; const owner = { child: shared }; ${body} return shared.${read};`, + )?.status, + ).toBe("unknown"); + }, + ); + + it.each([ + "mutate(source.left, source.right);", + "const alias = flag ? source.left : source.right; mutate(alias);", + "const copy = flag ? [source.left] : [source.right]; mutate(copy);", + "const alias = flag ? source.left : source.right; alias.value = 2;", + ])("keeps members outside the mutated paths: %s", (body) => { + expect( + resultValue( + `const source = { left: { value: 1 }, right: { value: 2 }, keep: { value: 3 } }; ${body} return source.keep.value;`, + ), + ).toEqual({ status: "literal", value: 3 }); + }); +}); + +describe("distinct observable mutation paths", () => { + it.each([ + ["alias.value = 2;", "left"], + ["alias.value = 2;", "right"], + ["mutate(alias);", "left"], + ["mutate(alias);", "right"], + ])( + "propagates both conditional paths to shared bindings: %s %s", + (effect, target) => { + expect( + resultValue( + `const left={value:1}; const right={value:2}; const source={left,right}; const alias=flag?source.left:source.right; ${effect} return ${target}.value;`, + )?.status, + ).toBe("unknown"); + }, + ); + + it.each(["mutate(alias);", "alias.value = 2;"])( + "preserves sibling facts below a shared prefix: %s", + (effect) => { + expect( + resultValue( + `const shared={left:{value:1},right:{value:2},keep:{value:3}}; const source={nested:shared}; const alias=flag?source.nested.left:source.nested.right; ${effect} return shared.keep.value;`, + ), + ).toEqual({ status: "literal", value: 3 }); + }, + ); +}); + describe("reference lifetimes across calls and shallow copies", () => { it("bounds dynamic destructuring across shared alias branches", () => { const declarations = Array.from( diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index ae887b9c79..21a6aded04 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -18,6 +18,7 @@ import { semanticIterationSources } from "./javascriptSemanticIterationSources.j import { semanticMutationInitializers } from "./javascriptSemanticMutationInitializers.js"; import { semanticArrayIndex, + SemanticPropertyPathCoverage, semanticPropertyPathKeyMatches, type JavaScriptSemanticPropertyPath, } from "./javascriptSemanticPropertyPaths.js"; @@ -42,8 +43,10 @@ export const collectSemanticMemberMutations = ( if (parent !== null) parents.set(node, parent); }, }); + const normalizePath = observableMutationPaths(state); const recordedEffects = new Set(); const selections = new Map(); + const coveringPaths = new Map(); let arrayIterationUnknown = false; const pendingReferences: { readonly initializer: JavaScriptSemanticBindingState["referenceInitializers"][number]; @@ -93,8 +96,10 @@ export const collectSemanticMemberMutations = ( originAt: t.Node | undefined = mutation, ): void => { const pending = [{ node, path, bindings, originAt }]; - while (pending.length > 0) { - const current = pending.pop(); + // Visit shallower alias traversals first so their exact prefixes suppress + // longer loop paths before those paths enumerate every assignment order. + for (let cursor = 0; cursor < pending.length; cursor++) { + const current = pending[cursor]; if (current === undefined) break; const expression = unwrapJavaScriptExpression(current.node).node; if (t.isIdentifier(expression)) { @@ -126,10 +131,13 @@ export const collectSemanticMemberMutations = ( if (current.bindings.has(lifetime)) continue; // Union compatible copied-slot selections at each binding. Carrying every // combination of exclusions through branch diamonds is exponential. - const selectorIndex = current.path.findIndex( + // Preserve paths that can change observed slots or Array prototype + // effects; canonicalize suffixes that cannot distinguish lattice facts. + let path = normalizePath(binding.bindingId, current.path, effect); + if (path === null) continue; + const selectorIndex = path.findIndex( (key) => key !== null && typeof key === "object", ); - let path = current.path; const selector = path[selectorIndex]; if (selector !== null && typeof selector === "object") { const selectionIdentity = JSON.stringify([ @@ -157,6 +165,25 @@ export const collectSemanticMemberMutations = ( index === selectorIndex ? { ...selector, excludedKeys } : key, ); } + // Keep an antichain of exact paths. Widening distinct member keys would + // also invalidate unrelated siblings that never reach this effect. + const pathState = JSON.stringify([ + binding.bindingId, + effect, + // Short writes stop at owned literal slots, whereas longer writes + // reach shared children. Only equal-depth writes cover one another. + effect === "write" ? path.length : null, + mutation?.start, + mutation?.end, + current.originAt?.start, + current.originAt?.end, + ]); + let covering = coveringPaths.get(pathState); + if (covering === undefined) { + covering = new SemanticPropertyPathCoverage(); + coveringPaths.set(pathState, covering); + } + if (!covering.retain(path)) continue; const identity = JSON.stringify([ binding.bindingId, effect, @@ -544,6 +571,241 @@ const referencedValues = ( return []; }; +type MutationObservableOrigin = + | t.ObjectExpression + | t.ArrayExpression + | "ambient" + | "array" + | "array-prototype" + | "opaque"; + +interface MutationObserverNode { + readonly origins: Set; + readonly sources: Set; + readonly dependents: Set; + readonly projections: Map; + readonly read?: { + readonly source: MutationObserverNode; + readonly key: PropertyPath[number]; + }; +} + +const observableMutationPaths = ( + state: JavaScriptSemanticAnalysisState, +): (( + bindingId: string, + path: PropertyPath, + effect: ValueEffect, +) => PropertyPath | null) => { + const bindingNodes = new Map(); + const expressions = new WeakMap(); + const pendingExpressions: { + readonly expression: t.Node; + readonly observer: MutationObserverNode; + }[] = []; + const pending: MutationObserverNode[] = []; + const queued = new Set(); + const schedule = (node: MutationObserverNode): void => { + if (queued.has(node)) return; + queued.add(node); + pending.push(node); + }; + const create = ( + read?: MutationObserverNode["read"], + ): MutationObserverNode => ({ + origins: new Set(), + sources: new Set(), + dependents: new Set(), + projections: new Map(), + ...(read === undefined ? {} : { read }), + }); + const link = ( + source: MutationObserverNode, + target: MutationObserverNode, + ): void => { + if (target.sources.has(source)) return; + target.sources.add(source); + source.dependents.add(target); + schedule(target); + }; + const expressionNode = (node: t.Node): MutationObserverNode => { + const expression = unwrapJavaScriptExpression(node).node; + const existing = expressions.get(expression); + if (existing !== undefined) return existing; + const observer = create(); + expressions.set(expression, observer); + pendingExpressions.push({ expression, observer }); + return observer; + }; + const project = ( + source: MutationObserverNode, + path: PropertyPath, + ): MutationObserverNode => { + let observer = source; + for (const key of path) { + const identity = JSON.stringify( + key === null || typeof key === "object" ? key : String(key), + ); + let selected = observer.projections.get(identity); + if (selected === undefined) { + selected = create({ source: observer, key }); + observer.projections.set(identity, selected); + observer.dependents.add(selected); + schedule(selected); + } + observer = selected; + } + return observer; + }; + for (const binding of state.bindingsById.values()) + bindingNodes.set(binding.bindingId, create()); + for (const binding of state.bindingsById.values()) { + const observer = bindingNodes.get(binding.bindingId); + if (observer === undefined) continue; + for (const initializer of binding.initializers) + link( + project(expressionNode(initializer.node), initializer.projection), + observer, + ); + // Preserve exact paths where copies, defaults or iteration need their + // reference-specific projections; an opaque origin prevents pruning. + if (binding.referenceInitializers.length > 0) + observer.origins.add("opaque"); + for (const initializer of binding.referenceInitializers) { + for (const source of [ + initializer, + ...(initializer.requiredSources ?? []), + ...(initializer.fallbackSources ?? []), + ]) + link(expressionNode(source.node), observer); + } + } + const settle = (): void => { + while (pendingExpressions.length > 0 || pending.length > 0) { + while (pendingExpressions.length > 0) { + const selected = pendingExpressions.pop(); + if (selected === undefined) break; + const { expression, observer } = selected; + if (t.isObjectExpression(expression) || t.isArrayExpression(expression)) + observer.origins.add(expression); + else if (t.isIdentifier(expression)) { + const binding = resolveSemanticBindingState( + state, + expression, + expression.name, + ); + if (binding !== undefined) { + const origin = bindingNodes.get(binding.bindingId); + if (origin !== undefined) link(origin, observer); + } else if (expression.name === "Array") observer.origins.add("array"); + else if ( + ["globalThis", "global", "window", "self"].includes(expression.name) + ) + observer.origins.add("ambient"); + } else if ( + t.isMemberExpression(expression) || + t.isOptionalMemberExpression(expression) + ) + link( + project(expressionNode(expression.object), [ + semanticStaticPropertyKey( + expression.property, + expression.computed, + ), + ]), + observer, + ); + else + for (const reference of referencedValues(expression, [], "escape")) + link( + project(expressionNode(reference.node), reference.path), + observer, + ); + schedule(observer); + } + const observer = pending.pop(); + if (observer === undefined) continue; + queued.delete(observer); + let changed = false; + const add = (origin: MutationObservableOrigin): void => { + if (observer.origins.has(origin)) return; + observer.origins.add(origin); + changed = true; + }; + for (const source of observer.sources) + for (const origin of source.origins) add(origin); + if (observer.read !== undefined) { + const { source, key } = observer.read; + if (key === null || typeof key === "object") { + // Dynamic keys and copied-slot masks retain their original paths. + // Enumerating their exclusion combinations would recreate the growth + // that the mutation collector's selection union already prevents. + if (source.origins.size > 0) add("opaque"); + } else + for (const origin of source.origins) { + if (typeof origin === "string") { + if (origin === "array-prototype" || origin === "opaque") + add(origin); + else if ( + origin === "ambient" && + semanticPropertyPathKeyMatches(key, "Array") + ) + add("array"); + else if ( + origin === "array" && + semanticPropertyPathKeyMatches(key, "prototype") + ) + add("array-prototype"); + } else { + for (const reference of referencedValues( + origin, + [key], + "escape", + )) { + link( + project(expressionNode(reference.node), reference.path), + observer, + ); + if (reference.iterableFallbackPath !== undefined) + link( + project( + expressionNode(reference.node), + reference.iterableFallbackPath, + ), + observer, + ); + } + } + } + } + if (changed) + for (const dependent of observer.dependents) schedule(dependent); + } + }; + settle(); + return (bindingId, path, effect) => { + let observer = bindingNodes.get(bindingId); + if (observer === undefined) return path; + if (path.some((key) => key !== null && typeof key === "object")) + return path; + if (effect === "escape") { + observer = project(observer, path); + settle(); + return observer.origins.size === 0 ? null : path; + } + if (observer.origins.size === 0) return path.length === 0 ? path : [null]; + for (let offset = 0; offset < path.length; offset++) { + const key = path[offset]; + if (key === undefined) break; + observer = project(observer, [key]); + settle(); + if (observer.origins.size === 0 && offset + 1 < path.length) + return [...path.slice(0, offset + 1), null]; + } + return path; + }; +}; + const objectReferencedValues = ( node: t.ObjectExpression, path: PropertyPath, diff --git a/src/domain/javascript/javascriptSemanticPropertyPaths.ts b/src/domain/javascript/javascriptSemanticPropertyPaths.ts index 9bee0e9b7a..95cbeef974 100644 --- a/src/domain/javascript/javascriptSemanticPropertyPaths.ts +++ b/src/domain/javascript/javascriptSemanticPropertyPaths.ts @@ -35,3 +35,89 @@ export const semanticPropertyPathKeyMatches = ( const index = semanticArrayIndex(name); return index !== null && index >= key.startIndex; }; + +interface PropertyPathCoverageNode { + terminal: boolean; + readonly exact: Map; + readonly selectors: Map< + string, + { + readonly key: JavaScriptSemanticPropertyPath[number]; + readonly node: PropertyPathCoverageNode; + } + >; +} + +const coverageNode = (): PropertyPathCoverageNode => ({ + terminal: false, + exact: new Map(), + selectors: new Map(), +}); + +/** Index exact effect prefixes without widening distinct member paths. */ +export class SemanticPropertyPathCoverage { + readonly #root = coverageNode(); + + /** Retain a path only when no previously retained prefix covers it. */ + retain(path: JavaScriptSemanticPropertyPath): boolean { + const pending = [{ node: this.#root, offset: 0 }]; + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined) break; + if (current.node.terminal) return false; + const key = path[current.offset]; + if (key === undefined) continue; + if (key !== null && typeof key !== "object") { + const exact = current.node.exact.get(String(key)); + if (exact !== undefined) + pending.push({ node: exact, offset: current.offset + 1 }); + } + for (const selected of current.node.selectors.values()) { + if (keyCovers(selected.key, key)) + pending.push({ node: selected.node, offset: current.offset + 1 }); + } + } + let node = this.#root; + for (const key of path) { + if (key !== null && typeof key !== "object") { + const name = String(key); + let child = node.exact.get(name); + if (child === undefined) { + child = coverageNode(); + node.exact.set(name, child); + } + node = child; + } else { + const identity = JSON.stringify(key); + let selected = node.selectors.get(identity); + if (selected === undefined) { + selected = { key, node: coverageNode() }; + node.selectors.set(identity, selected); + } + node = selected.node; + } + } + node.terminal = true; + node.exact.clear(); + node.selectors.clear(); + return true; + } +} + +const keyCovers = ( + covering: JavaScriptSemanticPropertyPath[number], + key: JavaScriptSemanticPropertyPath[number], +): boolean => { + if (covering === null) return true; + if (key === null) return false; + if (typeof key !== "object") + return typeof covering === "object" + ? semanticPropertyPathKeyMatches(covering, String(key)) + : String(covering) === String(key); + return ( + typeof covering === "object" && + covering.excludedKeys.every((name) => key.excludedKeys.includes(name)) && + (covering.startIndex === undefined || + (key.startIndex !== undefined && covering.startIndex <= key.startIndex)) + ); +}; diff --git a/src/domain/javascript/javascriptSemanticValues.ts b/src/domain/javascript/javascriptSemanticValues.ts index 2f53626708..884915948c 100644 --- a/src/domain/javascript/javascriptSemanticValues.ts +++ b/src/domain/javascript/javascriptSemanticValues.ts @@ -211,12 +211,17 @@ const evaluateBinding = ( if (isPrimitive(captured)) value = captured; } } - const mutated = binding.mutatedPaths.reduce( - invalidateSemanticMutationPath, - value, - ); - const projected = binding.escapedPaths.reduce( - (value, escape) => + let projected = value; + for (const path of binding.mutatedPaths) { + projected = invalidateSemanticMutationPath(projected, path); + // Every subsequent write returns this same root unknown. Replaying those + // paths at every newly collected effect otherwise becomes quadratic. + if (projected.status === "unknown") break; + } + for (const escape of binding.escapedPaths) { + // Escapes only invalidate object references; other lattice values survive. + if (projected.status !== "object" && projected.status !== "array") break; + if ( context.capturePoint !== undefined && semanticEscapeFollowsCapture( binding, @@ -224,10 +229,10 @@ const evaluateBinding = ( escape.node, context.state.parentsByNode, ) - ? value - : invalidateSemanticEscapedPath(value, escape.path), - mutated, - ); + ) + continue; + projected = invalidateSemanticEscapedPath(projected, escape.path); + } // Point-local memoization also bounds failed speculative captures. Only a // primitive result is allowed back into ordinary value evaluation. if (