diff --git a/src/domain/javascript/javascriptSemanticAnalysis.reference_lifetimes.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.reference_lifetimes.test.ts index 73c7897b0f..ecd3e99a6f 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.reference_lifetimes.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.reference_lifetimes.test.ts @@ -48,6 +48,26 @@ describe("bounded member path propagation", () => { expect(performance.now() - start).toBeLessThan(2000); }, 30000); + // A dynamic self alias escapes its repeated prefix, which conservatively + // covers every child; a static key without that slot keeps the literal. + it.each([ + ["node = node[key]", "node.name.toLowerCase();", "unknown"], + ["node = node[key]", "mutate(node);", "unknown"], + ["node = node[key]", "node.value = 2;", "unknown"], + ["node = node.next", "node.value = 2;", "literal"], + ])( + "bounds a self alias below an observed container: %s; %s", + (alias, effect, status) => { + const start = performance.now(); + const value = resultValue( + `class Item {} const seed = { child: { value: 1 } }; let node = flag ? seed : new Item(); while ((${alias})) ${effect} return seed.child.value;`, + ); + expect(performance.now() - start).toBeLessThan(2000); + expect(value?.status).toBe(status); + }, + 30000, + ); + it.each([ ["child:external,", "child", "present"], ["", "missing", "unknown-coverage"], @@ -112,6 +132,10 @@ describe("bounded member path propagation", () => { "let alias = owner; let i = 0; while (i++ < 1) { alias = flag ? alias.child : alias; alias.value = 2; }", "value", ], + [ + "let alias = owner; let i = 0; while (i++ < 1) { alias = flag ? alias.child : alias; alias.value = 2; }", + "child.value", + ], ])( "keeps a write that reaches a child through a longer path: %s", (body, read) => { diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 5acc783c8c..52d4b25dad 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -46,7 +46,7 @@ export function* collectSemanticMemberMutationsSteps( state: JavaScriptSemanticAnalysisState, ): Generator { const parents = state.parentsByNode; - const normalizePath = observableMutationPaths(state); + const observablePaths = observableMutationPaths(state); const returnedReferences = createSemanticReturnedReferences(state); const recordedEffects = new Set(); const selections = new Map(); @@ -128,18 +128,48 @@ export function* collectSemanticMemberMutationsSteps( // An alias restored from a saved reference can revisit the same binding // at an earlier capture or a shorter getter-receiver path. Only a // repeated capture and path closes a cycle. - const lifetime = JSON.stringify([ + const capture = [ binding.bindingId, current.originAt?.start, current.originAt?.end, - current.path, - ]); + ]; + const lifetime = JSON.stringify([...capture, current.path]); if (current.bindings.has(lifetime)) continue; + // A self alias such as `node = node[key]` revisits its capture with the + // same prefix added on every cycle, so exact paths never repeat. When + // that prefix can never be pruned, escaping it once covers every longer + // effect, including writes below shared children. + let repeatedPrefix: PropertyPath | undefined; + for (let start = 1; start <= current.path.length; start++) { + const suffix = current.path.slice(start); + if (!current.bindings.has(JSON.stringify([...capture, suffix]))) + continue; + repeatedPrefix = current.path.slice(0, start); + break; + } + if ( + repeatedPrefix !== undefined && + observablePaths.repeatsUnpruned(binding.bindingId, repeatedPrefix) + ) { + markValue( + current.node, + repeatedPrefix, + new Set(), + "escape", + mutation, + current.originAt, + ); + continue; + } // Union compatible copied-slot selections at each binding. Carrying every // combination of exclusions through branch diamonds is exponential. // 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); + let path = observablePaths.normalize( + binding.bindingId, + current.path, + effect, + ); if (path === null) continue; const selectorIndex = path.findIndex( (key) => key !== null && typeof key === "object", @@ -698,11 +728,17 @@ interface MutationObserverNode { const observableMutationPaths = ( state: JavaScriptSemanticAnalysisState, -): (( - bindingId: string, - path: PropertyPath, - effect: ValueEffect, -) => PropertyPath | null) => { +): { + readonly normalize: ( + bindingId: string, + path: PropertyPath, + effect: ValueEffect, + ) => PropertyPath | null; + readonly repeatsUnpruned: ( + bindingId: string, + prefix: PropertyPath, + ) => boolean; +} => { const bindingNodes = new Map(); const expressions = new WeakMap(); const pendingExpressions: { @@ -897,7 +933,11 @@ const observableMutationPaths = ( } }; settle(); - return (bindingId, path, effect) => { + const normalize = ( + bindingId: string, + path: PropertyPath, + effect: ValueEffect, + ): PropertyPath | null => { let observer = bindingNodes.get(bindingId); if (observer === undefined) return path; if (path.some((key) => key !== null && typeof key === "object")) @@ -919,6 +959,44 @@ const observableMutationPaths = ( } return path; }; + // A projection's origins depend only on its source origins and key. Once + // repeating a prefix revisits an origin set, every longer repetition stays + // observable and normalization can never prune it. The answer depends only + // on the binding and prefix, so every revisit of a long chain shares it. + const repeated = new Map(); + const repeatsUnpruned = ( + bindingId: string, + prefix: PropertyPath, + ): boolean => { + const key = JSON.stringify([bindingId, prefix]); + const cached = repeated.get(key); + if (cached !== undefined) return cached; + let observer = bindingNodes.get(bindingId); + const signatures = new Set(); + let result = false; + while (observer !== undefined && prefix.length > 0) { + observer = project(observer, prefix); + settle(); + if ((observer.origins?.size ?? 0) === 0) break; + const signature = JSON.stringify( + [...(observer.origins ?? [])] + .map((origin) => + typeof origin === "string" + ? origin + : `${String(origin.start)}:${String(origin.end)}`, + ) + .sort(compareUnicodeCodePoints), + ); + if (signatures.has(signature)) { + result = true; + break; + } + signatures.add(signature); + } + repeated.set(key, result); + return result; + }; + return { normalize, repeatsUnpruned }; }; const arrayReferencedValues = (