diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 041015b9c..e9fed813f 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -208,6 +208,18 @@ arrays, and later explicit data properties and methods replace earlier object spread references. A known array prefix does not expose values from a following spread. Accessors, custom or uncertain iteration, unknown computed keys, and unknown spread lengths retain conservative uncertainty. +Getter-local aliases of `this`, including direct destructuring and assignments, +retain their receiver when a returned shared reference is written. These writes +invalidate the earlier literal instead of claiming the getter executed or the +new value is known. Straight-line alias reads retain their capture order; +uncertain control flow remains conservative. A binding outside the getter or +`this` in a nested ordinary function is not attributed to the getter's receiver. +Private reads keep receiver projections separate from the private value's path. +Receiver projections follow known data fields initialized from `this` or earlier +self-referencing fields; static fields retain their declaring class identity. +An alias cycle that repeatedly selects deeper children retains escape uncertainty +at its recovered roots rather than claiming a fixed number of loop iterations. + Writes through an object getter's returned receiver retain that uncertainty when the object is an array element, nested inside mixed object/array containers, or assigned to a previously declared identifier. Saving the getter diff --git a/src/domain/javascript/javascriptSemanticAnalysis.getter_aliases.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.getter_aliases.test.ts new file mode 100644 index 000000000..1eeaec350 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.getter_aliases.test.ts @@ -0,0 +1,351 @@ +import { describe, expect, it } from "vitest"; +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js"; + +const returnedValue = (getter: string, write = "box.v.x = 2;") => + onlyCallable( + analyzeJavaScriptSemantics(`export function result() { + const shared = { x: 1, y: 1 }; + const box = { s: shared, get v() { ${getter} } }; + ${write} + return shared.x; + }`), + "result", + ).returnSites[0]?.value; + +describe("projected private getter receivers", () => { + it.each([ + [ + "destructured self field", + "shared", + "peer = this;", + "const { peer: self } = this; return self.#s;", + true, + "unknown", + ], + [ + "unrelated private child", + "{ peer: shared }", + "peer = this;", + "const { peer: self } = this; return self.#s;", + true, + "literal", + ], + [ + "read without write", + "shared", + "peer = this;", + "const { peer: self } = this; return self.#s;", + false, + "literal", + ], + [ + "two self fields", + "shared", + "peer = this; next = this.peer;", + "const { next: self } = this; return self.#s;", + true, + "unknown", + ], + [ + "nested receiver projection", + "shared", + "peer = this;", + "const { peer: { peer: self } } = this; return self.#s;", + true, + "unknown", + ], + [ + "member receiver alias", + "shared", + "peer = this;", + "const self = this.peer.peer; return self.#s;", + true, + "unknown", + ], + [ + "projected result suffix", + "{ child: shared }", + "peer = this;", + "const { peer: self } = this; return self.#s.child;", + true, + "unknown", + ], + [ + "external receiver", + "shared", + "peer = external;", + "const { peer: self } = this; return self.#s;", + true, + "literal", + ], + [ + "field receiver cycle", + "shared", + "peer = this.other; other = this.peer;", + "const { peer: self } = this; return self.#s;", + true, + "literal", + ], + ] as const)( + "keeps receiver and result paths separate for %s", + (_name, value, fields, getter, write, status) => { + const ir = analyzeJavaScriptSemantics(`export function result() { + const shared = { x: 1 }; + class Box { + #s = ${value}; ${fields} + get v() { ${getter} } + } + const box = new Box(); + ${write ? "box.v.x = 2;" : "const saved = box.v;"} + return shared.x; + }`); + expect(onlyCallable(ir, "result").returnSites[0]?.value).toMatchObject( + status === "literal" ? { status, value: 1 } : { status }, + ); + }, + ); +}); + +describe("private receiver field ownership", () => { + it("bounds growing receiver projections under cyclic class inheritance", () => { + const ir = analyzeJavaScriptSemantics(`export function result() { + const shared = { x: 1 }; + class A extends B { + #s = shared; + peer = this.other.other; + get v() { const { peer: self } = this; return self.#s; } + } + class B extends A { other = this.peer.peer; } + new A().v.x = 2; + return shared.x; + }`); + expect(onlyCallable(ir, "result").returnSites[0]?.value).toMatchObject({ + status: "literal", + value: 1, + }); + }, 1000); + + it.each([ + [ + "own static receiver", + "class Box { static #s=shared; static peer=this; static get v(){const {peer:self}=this;return self.#s;} } Box.v.x=2;", + "unknown", + ], + [ + "inherited instance receiver", + "class Base {peer=this;} class Box extends Base {#s=shared;get v(){const {peer:self}=this;return self.#s;}} new Box().v.x=2;", + "unknown", + ], + [ + "inherited static field retains its declaring class", + "class Base {static peer=this;} class Box extends Base {static #s=shared;static get v(){const {peer:self}=this;return self.#s;}} Box.v.x=2;", + "literal", + ], + [ + "inherited static getter's own private field", + "class Base {static #s=shared;static peer=this;static get v(){const {peer:self}=this;return self.#s;}} class Box extends Base {} Box.v.x=2;", + "unknown", + ], + [ + "a field that is not initialized yet", + "class Box {#s=shared;peer=this.later;later=this;get v(){const {peer:self}=this;return self.#s;}} new Box().v.x=2;", + "literal", + ], + [ + "remaining getter projection after a captured field", + "class Box {#s=shared;peer=this;next=this.peer;later=this;get v(){const {next:{later:self}}=this;return self.#s;}} new Box().v.x=2;", + "unknown", + ], + [ + "a replaced self field", + "class Box {#s=shared;peer=this;peer=external;get v(){const {peer:self}=this;return self.#s;}} new Box().v.x=2;", + "literal", + ], + ] as const)("respects %s", (_name, body, status) => { + const ir = analyzeJavaScriptSemantics(`export function result() { + const shared = { x: 1 }; + ${body} + return shared.x; + }`); + expect(onlyCallable(ir, "result").returnSites[0]?.value).toMatchObject( + status === "literal" ? { status, value: 1 } : { status }, + ); + }); +}); + +describe("getter-local receiver aliases (#2653)", () => { + it.each([ + ["initialized alias", "const self = this; return self.s;"], + ["assigned alias", "let self; self = this; return self.s;"], + [ + "chained assignment", + "let self; let alias; self = alias = this; return self.s;", + ], + ["destructured receiver", "const { s: value } = this; return value;"], + ["alias chain", "const self = this; const saved = self; return saved.s;"], + [ + "capture before reassignment", + "let self = this; const saved = self; self = { s: { x: 1 } }; return saved.s;", + ], + [ + "conditional receiver alias", + "const self = flag ? this : { s: { x: 1 } }; return self.s;", + ], + ])("invalidates shared values through %s", (_name, getter) => { + expect(returnedValue(getter)?.status).toBe("unknown"); + }); + + it.each([ + ["no write", "const self = this; return self.s;", "const saved = box.v;"], + [ + "an unrelated property write", + "const self = this; return self.s;", + "box.v.y = 2;", + ], + [ + "a cycle that does not grow the selected path", + "let self = this; while (flag) self = self; return self.s;", + "box.v.y = 2;", + ], + [ + "an alias replaced before return", + "let self = this; self = { s: { x: 1 } }; return self.s;", + "box.v.x = 2;", + ], + [ + "a shadowing block binding", + "const self = this; { const self = { s: { x: 1 } }; return self.s; }", + "box.v.x = 2;", + ], + [ + "a nested function's receiver", + "function other() { const self = this; return self.s; } return other();", + "box.v.x = 2;", + ], + [ + "an alias assigned in a different receiver scope", + "let self; function assign() { self = this; } assign(); return self.s;", + "box.v.x = 2;", + ], + [ + "an excluded destructuring default", + "const { value = shared } = { value: { x: 1 } }; return value;", + "box.v.x = 2;", + ], + ])("preserves known values with %s", (_name, getter, write) => { + expect(returnedValue(getter, write)).toMatchObject({ + status: "literal", + value: 1, + }); + }); +}); + +describe("getter receiver alias boundaries", () => { + it("does not reinterpret an outer this binding as the getter receiver", () => { + const ir = analyzeJavaScriptSemantics(`export function result() { + const shared = { x: 1 }; + const outside = this; + const box = { s: shared, get v() { return outside.s; } }; + box.v.x = 2; + return shared.x; + }`); + expect(onlyCallable(ir, "result").returnSites[0]?.value).toMatchObject({ + status: "literal", + value: 1, + }); + }); + + it("retains a private field's lexical owner through a receiver alias", () => { + const ir = analyzeJavaScriptSemantics(`export function result() { + const shared = { x: 1 }; + class Box { + #s = shared; + get v() { const self = this; return self.#s; } + } + new Box().v.x = 2; + return shared.x; + }`); + expect(onlyCallable(ir, "result").returnSites[0]?.value.status).toBe( + "unknown", + ); + }); + + it("does not append a receiver projection to a private field's result path", () => { + const ir = analyzeJavaScriptSemantics(`export function result() { + const shared = { x: 1 }; + class Box { + #s = { peer: shared }; + peer = this; + get v() { const { peer: self } = this; return self.#s; } + } + new Box().v.x = 2; + return shared.x; + }`); + expect(onlyCallable(ir, "result").returnSites[0]?.value).toMatchObject({ + status: "literal", + value: 1, + }); + }); + + it("resolves a long local alias chain without recursive expansion", () => { + const aliases = ["const a0 = this;"]; + for (let index = 1; index <= 2000; index++) + aliases.push(`const a${String(index)} = a${String(index - 1)};`); + expect(returnedValue(`${aliases.join("\n")} return a2000.s;`)?.status).toBe( + "unknown", + ); + }); + + it("bounds a growing alias cycle without losing the original receiver", () => { + expect( + returnedValue( + "let self = this; while (flag) self = self.s; return self.s;", + )?.status, + ).toBe("unknown"); + }); + + it.each([true, false])( + "retains deeper shared references through an alias cycle (write=%s)", + (write) => { + const ir = analyzeJavaScriptSemantics(`export function result() { + const shared = { x: 1 }; + const box = { + start: { child: { child: shared } }, + get v() { + let self = this.start; + while (flag) self = self.child; + return self; + } + }; + ${write ? "box.v.x = 2;" : "const saved = box.v;"} + return shared.x; + }`); + expect(onlyCallable(ir, "result").returnSites[0]?.value).toMatchObject( + write ? { status: "unknown" } : { status: "literal", value: 1 }, + ); + }, + ); + + it("does not widen a growing cycle to unrelated receiver properties", () => { + const ir = analyzeJavaScriptSemantics(`export function result() { + const shared = { x: 1 }; + const unrelated = { x: 1 }; + const box = { + start: { child: { child: shared } }, + unrelated, + get v() { + let self = this.start; + while (flag) self = self.child; + return self; + } + }; + box.v.x = 2; + return unrelated.x; + }`); + expect(onlyCallable(ir, "result").returnSites[0]?.value).toMatchObject({ + status: "literal", + value: 1, + }); + }); +}); diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 35b63c815..7b988af88 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -10,10 +10,7 @@ import { evaluateSemanticBinding, evaluateSemanticExpression, } from "./javascriptSemanticValues.js"; -import { - semanticContainer, - semanticSlotAtPath, -} from "./javascriptSemanticSlots.js"; +import { semanticContainer } from "./javascriptSemanticSlots.js"; import { semanticClassCandidates, semanticClassPropertyReferences, @@ -24,6 +21,10 @@ import { semanticIterationSources } from "./javascriptSemanticIterationSources.j import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js"; import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js"; import { semanticMutationInitializers } from "./javascriptSemanticMutationInitializers.js"; +import { + semanticReferenceSourcesInactive, + semanticSelectedReferenceSlot, +} from "./javascriptSemanticReferenceSources.js"; import { semanticArrayIndex, SemanticPropertyPathCoverage, @@ -329,7 +330,17 @@ export function* collectSemanticMemberMutationsSteps( for (const value of effect === "escape" ? escapedReferenceLeaves(expression, current.path) : expandedValues(expression, current.path, effect)) { - if (value.iterableFallbackPath !== undefined) + if (value.escapes && effect !== "escape") + markValue( + value.node, + value.path, + new Set(), + "escape", + mutation, + current.originAt, + value.ownerInitializer, + ); + else if (value.iterableFallbackPath !== undefined) deferIterable({ node: value.node, projection: [], @@ -504,7 +515,7 @@ export function* collectSemanticMemberMutationsSteps( for (const [index, reference] of pendingReferences.splice(0).entries()) { if (index % 64 === 0) yield; const { initializer } = reference; - if (referenceSourcesInactive(initializer, state)) + if (semanticReferenceSourcesInactive(initializer, state)) pendingReferences.push(reference); else if (t.isForOfStatement(initializer.node)) deferIterable({ @@ -549,7 +560,7 @@ export function* collectSemanticMemberMutationsSteps( } for (const [index, reference] of [...iterableReferences].entries()) { if (index % 64 === 0) yield; - if (referenceSourcesInactive(reference, state)) continue; + if (semanticReferenceSourcesInactive(reference, state)) continue; if (reference.iterationOnly) { const expanded = expandedIterationReferences(reference, state); if (expanded !== null) { @@ -557,7 +568,7 @@ export function* collectSemanticMemberMutationsSteps( continue; } } - const slot = selectedReferenceSlot(reference, state); + const slot = semanticSelectedReferenceSlot(reference, state); // Custom iteration can yield the iterable itself as well as any child. // Treat its root as escaped, including writes through yielded references. const knownArray = @@ -610,27 +621,6 @@ interface IterableReference extends ReferencedValue { }[]; } -const referenceSourcesInactive = ( - reference: Pick, - state: JavaScriptSemanticAnalysisState, -): boolean => - reference.requiredSources?.some( - (source) => - selectedReferenceSlot(source, state, source.node)?.presence === "absent", - ) === true || - reference.fallbackSources?.some((source) => { - // Selection precedes effects through the chosen reference. A later escape - // cannot make a fallback that was excluded at destructuring reachable. - const slot = selectedReferenceSlot(source, state, source.node); - return ( - slot?.presence === "present" && - (slot.value.status === "literal" || - slot.value.status === "union" || - slot.value.status === "object" || - slot.value.status === "array") - ); - }) === true; - const expandedIterationReferences = ( reference: IterableReference, state: JavaScriptSemanticAnalysisState, @@ -645,7 +635,10 @@ const expandedIterationReferences = ( ...(reference.fallbackSources ?? []), ].find((source) => source.node === iterable && source.projection[0] === null); if (source === undefined) return null; - const slot = selectedReferenceSlot({ node: iterable, projection: [] }, state); + const slot = semanticSelectedReferenceSlot( + { node: iterable, projection: [] }, + state, + ); const container = slot === undefined ? null : semanticContainer(slot.value); if (container?.coverage.status !== "complete") return null; const select = ( @@ -1141,19 +1134,3 @@ const copiedReferencePath = ( } return [...initializer.projection.slice(0, offset), key, ...remaining]; }; - -const selectedReferenceSlot = ( - source: { readonly node: t.Node; readonly projection: PropertyPath }, - state: JavaScriptSemanticAnalysisState, - capturePoint?: t.Node, -) => { - const path: string[] = []; - for (const key of source.projection) { - if (key === null || typeof key === "object") return undefined; - path.push(String(key)); - } - return semanticSlotAtPath( - evaluateSemanticExpression(source.node, state, capturePoint), - path, - ); -}; diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.ts index cfaaf0ef5..6de7217c1 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.ts @@ -149,7 +149,50 @@ export const semanticMutationInitializers = ( const mutationPosition = directMutationPosition(mutation, parents) ?? referenceInitializerPosition(mutation, parents); - if (mutationPosition === null) return binding; + return selectInitializers(binding, mutationPosition, parents); +}; + +/** Select a local alias at its read, before any later straight-line reassignment. */ +export const semanticReadInitializers = ( + binding: JavaScriptSemanticBindingState, + read: t.Node, + parents: WeakMap, +): Pick< + JavaScriptSemanticBindingState, + "initializers" | "referenceInitializers" +> => { + let position: StatementPosition | null = null; + let current = read; + for ( + let parent = parents.get(current); + parent; + parent = parents.get(current) + ) { + if (t.isLoop(parent)) return binding; + if (t.isFunction(parent)) break; + if (position === null) { + if (t.isVariableDeclarator(parent)) { + const declaration = parents.get(parent); + if (t.isVariableDeclaration(declaration)) + position = statementPosition(declaration, parents, parent); + } else if (t.isStatement(parent)) + position = statementPosition(parent, parents); + } + current = parent; + } + return selectInitializers(binding, position, parents, true); +}; + +const selectInitializers = ( + binding: JavaScriptSemanticBindingState, + position: StatementPosition | null, + parents: WeakMap, + atRead = false, +): Pick< + JavaScriptSemanticBindingState, + "initializers" | "referenceInitializers" +> => { + if (position === null) return binding; const origins = [ ...binding.initializers.map((initializer) => ({ initializer, @@ -169,12 +212,20 @@ export const semanticMutationInitializers = ( ]; if ( origins.some( - ({ position }) => - position === null || !positionPrecedes(position, mutationPosition), + ({ position: origin }) => + origin === null || + origin.body !== position.body || + (!atRead && !positionPrecedes(origin, position)), ) ) return binding; - const latest = origins.reduce( + const preceding = atRead + ? origins.filter( + ({ position: origin }) => + origin !== null && positionPrecedes(origin, position), + ) + : origins; + const latest = preceding.reduce( (latest, { position }) => position !== null && (latest === null || positionPrecedes(latest, position)) @@ -183,7 +234,7 @@ export const semanticMutationInitializers = ( null, ); const retained = new Set( - origins + preceding .filter( ({ position }) => position?.index === latest?.index && diff --git a/src/domain/javascript/javascriptSemanticObjectReferences.ts b/src/domain/javascript/javascriptSemanticObjectReferences.ts index 4d1bf3e76..425e5fea5 100644 --- a/src/domain/javascript/javascriptSemanticObjectReferences.ts +++ b/src/domain/javascript/javascriptSemanticObjectReferences.ts @@ -4,6 +4,8 @@ import { type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js"; +import { semanticReadInitializers } from "./javascriptSemanticMutationInitializers.js"; +import { semanticReferenceSourcesInactive } from "./javascriptSemanticReferenceSources.js"; import { semanticStaticPropertyKey, unwrapJavaScriptExpression, @@ -17,6 +19,7 @@ import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; export interface JavaScriptSemanticObjectReference { readonly node: t.Node; readonly path: PropertyPath; + readonly escapes?: true; /** The allocation owned by this binding, rather than a read of its prior value. */ readonly ownerInitializer?: t.Node; } @@ -73,6 +76,7 @@ const instanceFieldReferences = ( propertyName: string | null, path: PropertyPath, state: JavaScriptSemanticAnalysisState, + initializedBefore?: t.ClassProperty, ): { readonly references: readonly JavaScriptSemanticObjectReference[]; readonly definiteNames: ReadonlySet; @@ -94,7 +98,15 @@ const instanceFieldReferences = ( if (previous !== undefined && previous.size === excluded.size) continue; selections.set(current.klass, excluded); const shadowed = new Set(excluded); - for (const member of [...current.klass.body.body].reverse()) { + const before = + initializedBefore === undefined + ? -1 + : current.klass.body.body.indexOf(initializedBefore); + const members = + before < 0 + ? current.klass.body.body + : current.klass.body.body.slice(0, before); + for (const member of [...members].reverse()) { if (!t.isClassProperty(member) || member.static) continue; const key = semanticStaticPropertyKey(member.key, member.computed); if (key !== null && shadowed.has(key)) continue; @@ -123,12 +135,15 @@ interface ClassPropertyLookup { readonly owner: t.ClassDeclaration | t.ClassExpression; readonly receiver?: t.ClassDeclaration | t.ClassExpression; readonly kind: "instance" | "static" | "prototype"; + readonly fieldsOnly?: true; + readonly initializedBefore?: t.ClassProperty; } const classPropertyDescriptors = ( klass: t.ClassDeclaration | t.ClassExpression, propertyName: string | null, kind: ClassPropertyLookup["kind"], + initializedBefore?: t.ClassProperty, ) => { const isStatic = kind === "static"; const selectedGetters: t.ClassMethod[] = []; @@ -138,7 +153,12 @@ const classPropertyDescriptors = ( readonly index: number; readonly key: string | null; }[] = []; + const before = + initializedBefore === undefined + ? -1 + : klass.body.body.indexOf(initializedBefore); klass.body.body.forEach((member, index) => { + if (before >= 0 && index >= before && t.isClassProperty(member)) return; if ( (t.isClassMethod(member) || (kind === "static" && t.isClassProperty(member))) && @@ -223,7 +243,13 @@ export const semanticClassPropertyReferences = ( // constructors. They take precedence over every prototype descriptor. let instanceFields: ReadonlySet = new Set(); if (lookup.kind === "instance") { - const fields = instanceFieldReferences(root, propertyName, path, state); + const fields = instanceFieldReferences( + root, + propertyName, + path, + state, + lookup.initializedBefore, + ); possibleResults.push(...fields.references); instanceFields = fields.definiteNames; if (propertyName !== null && instanceFields.has(propertyName)) @@ -237,11 +263,12 @@ export const semanticClassPropertyReferences = ( klass, propertyName, lookup.kind, + lookup.initializedBefore, ); for (const field of descriptors.fields) if (field.value != null) possibleResults.push({ node: field.value, path }); - for (const getter of descriptors.getters) { + for (const getter of lookup.fieldsOnly ? [] : descriptors.getters) { const key = semanticStaticPropertyKey(getter.key, getter.computed); if (key !== null && instanceFields.has(key)) continue; possibleResults.push( @@ -345,6 +372,102 @@ interface GetterInvocation { | t.ClassExpression; } +/** Follow only data fields whose initializers retain a class receiver. */ +const privateReceiverProjection = ( + receiver: t.ClassDeclaration | t.ClassExpression, + owner: t.ClassDeclaration | t.ClassExpression, + path: PropertyPath, + isStatic: boolean, + state: JavaScriptSemanticAnalysisState, +): boolean => { + if (path.some((key) => key === null || typeof key === "object")) return false; + type Selection = { readonly key: string; readonly before?: t.ClassProperty }; + const pending: { + receiver: typeof receiver; + path: readonly Selection[]; + visits: ReadonlyMap; + }[] = [ + { + receiver, + path: path.map((key) => ({ key: String(key) })), + visits: new Map(), + }, + ]; + const seen = new Set(); + const declaringClass = (field: t.ClassProperty) => { + const body = state.parentsByNode.get(field); + return body === undefined ? undefined : state.parentsByNode.get(body); + }; + for (let cursor = 0; cursor < pending.length; cursor++) { + const current = pending[cursor]; + if (current === undefined) break; + const [selection, ...remaining] = current.path; + if (selection === undefined) { + if (!isStatic || current.receiver === owner) return true; + continue; + } + const origin = JSON.stringify([ + current.receiver.start, + selection.key, + selection.before?.start, + ]); + const previous = current.visits.get(origin); + // Repeated fields may consume a finite getter path. An initializer cycle + // that preserves or grows it cannot establish another receiver identity. + if (previous !== undefined && previous <= current.path.length) continue; + const visits = new Map(current.visits).set(origin, current.path.length); + const identity = JSON.stringify([ + current.receiver.start, + current.path.map(({ key, before }) => [key, before?.start]), + ]); + if (seen.has(identity)) continue; + seen.add(identity); + const lookupOwner = + selection.before === undefined + ? current.receiver + : declaringClass(selection.before); + if (!t.isClass(lookupOwner)) continue; + for (const reference of semanticClassPropertyReferences( + { + owner: lookupOwner, + receiver: current.receiver, + kind: isStatic ? "static" : "instance", + fieldsOnly: true, + ...(selection.before === undefined + ? {} + : { initializedBefore: selection.before }), + }, + [selection.key], + state, + )) { + const field = state.parentsByNode.get(reference.node); + if (!t.isClassProperty(field)) continue; + const fieldOwner = declaringClass(field); + if (!t.isClass(fieldOwner)) continue; + const projection: Selection[] = []; + let value = unwrapJavaScriptExpression(reference.node).node; + while ( + t.isMemberExpression(value) || + t.isOptionalMemberExpression(value) + ) { + const key = semanticStaticPropertyKey(value.property, value.computed); + if (key === null || t.isPrivateName(value.property)) break; + projection.unshift({ key, before: field }); + value = unwrapJavaScriptExpression(value.object).node; + } + if (!t.isThisExpression(value)) continue; + // Instance fields retain the actual instance. Static field `this` belongs + // to its declaring class, even when a subclass inherits the field. + pending.push({ + receiver: isStatic ? fieldOwner : current.receiver, + path: [...projection, ...remaining], + visits, + }); + } + } + return false; +}; + const getterResultReferences = ( invocation: GetterInvocation, path: PropertyPath, @@ -358,40 +481,206 @@ const getterResultReferences = ( // path; recursive getters that preserve or grow that path cannot progress. const active = new Map(activeGetters).set(getter, path.length); const isStatic = t.isClassMethod(getter) && getter.static; - const resolve = ( - node: t.Node, - remaining: PropertyPath, - ): readonly JavaScriptSemanticObjectReference[] => { - const expression = unwrapJavaScriptExpression(node).node; - if (t.isThisExpression(expression)) { - if (t.isClass(receiver)) { - return semanticClassPropertyReferences( - { owner: receiver, kind: isStatic ? "static" : "instance" }, - remaining, - state, - active, - ); + interface AliasVisit { + readonly identity: string; + readonly pathLength: number; + readonly parent: AliasVisit | undefined; + } + const pending: { + readonly node: t.Node; + readonly path: PropertyPath; + readonly aliases: AliasVisit | undefined; + readonly escapes?: true; + readonly privateRead?: { + readonly node: t.MemberExpression | t.OptionalMemberExpression; + readonly name: string; + readonly path: PropertyPath; + }; + }[] = semanticCallableResultExpressions(getter).flatMap(({ node }) => + node === null ? [] : [{ node, path, aliases: undefined }], + ); + const references: JavaScriptSemanticObjectReference[] = []; + const expandedAliases = new Map>(); + const getterScope = state.scopeByNode.get(getter)?.scopeId; + for (let cursor = 0; cursor < pending.length; cursor++) { + const current = pending[cursor]; + if (current === undefined) break; + const { path: remaining } = current; + const expression = unwrapJavaScriptExpression(current.node).node; + const retain = (reference: JavaScriptSemanticObjectReference): void => { + references.push( + current.escapes ? { ...reference, escapes: true } : reference, + ); + }; + const retainPrivateRead = (): void => { + if (current.privateRead !== undefined) + retain({ + node: current.privateRead.node, + path: current.escapes ? [] : current.privateRead.path, + }); + }; + if (t.isIdentifier(expression)) { + const binding = resolveSemanticBindingState( + state, + expression, + expression.name, + ); + let scope = + binding === undefined + ? undefined + : state.scopesById.get(binding.scopeId); + while (scope !== undefined && scope.scopeId !== getterScope) { + if (scope.kind === "function" || scope.parentScopeId === null) { + scope = undefined; + break; + } + scope = state.scopesById.get(scope.parentScopeId); } - return objectReceiverReference(receiver, remaining, state); + if ( + binding !== undefined && + scope !== undefined && + binding.referenceInitializers.every( + (initializer) => initializer.copyKind === undefined, + ) + ) { + const identity = JSON.stringify([ + binding.bindingId, + expression.start, + expression.end, + ]); + const known = expandedAliases.get(identity); + const key = JSON.stringify([ + remaining, + current.privateRead?.name, + current.privateRead?.path, + current.escapes, + ]); + let cyclic = false; + let growing = false; + if (known !== undefined) + for ( + let visit = current.aliases; + visit !== undefined; + visit = visit.parent + ) + if (visit.identity === identity) { + cyclic = true; + growing = remaining.length > visit.pathLength; + break; + } + if (cyclic) { + // A repeated alias can select arbitrarily deep children. Revisit its + // roots as escapes rather than enumerating iterations or dropping them. + if (growing && !current.escapes) + pending.push({ + ...current, + path: [], + aliases: undefined, + escapes: true, + }); + continue; + } + if (known?.has(key)) continue; + const selections = known ?? new Set(); + selections.add(key); + expandedAliases.set(identity, selections); + const aliases = { + identity, + pathLength: remaining.length, + parent: current.aliases, + }; + const origins = semanticReadInitializers( + binding, + expression, + state.parentsByNode, + ); + for (const initializer of [ + ...origins.initializers, + ...origins.referenceInitializers.filter( + (initializer) => + !semanticReferenceSourcesInactive(initializer, state), + ), + ]) + pending.push({ + ...current, + node: initializer.node, + path: [...initializer.projection, ...remaining], + aliases, + }); + continue; + } + } + if (t.isThisExpression(expression)) { + // An outer binding or a nested ordinary function has a different receiver. + let ownerNode = state.parentsByNode.get(expression); + while ( + ownerNode !== undefined && + !t.isClass(ownerNode) && + !(t.isFunction(ownerNode) && !t.isArrowFunctionExpression(ownerNode)) + ) + ownerNode = state.parentsByNode.get(ownerNode); + if (ownerNode !== getter) continue; + if (current.privateRead !== undefined && t.isClass(owner)) { + if ( + !t.isClass(receiver) || + !privateReceiverProjection( + receiver, + owner, + remaining, + isStatic, + state, + ) + ) { + retainPrivateRead(); + continue; + } + for (const member of owner.body.body) + if ( + t.isClassPrivateProperty(member) && + member.static === isStatic && + member.key.id.name === current.privateRead.name && + member.value != null + ) + retain({ + node: member.value, + path: current.escapes ? [] : current.privateRead.path, + }); + continue; + } + for (const reference of t.isClass(receiver) + ? semanticClassPropertyReferences( + { owner: receiver, kind: isStatic ? "static" : "instance" }, + remaining, + state, + active, + ) + : objectReceiverReference(receiver, remaining, state)) + retain(reference); + continue; } if ( t.isMemberExpression(expression) || t.isOptionalMemberExpression(expression) ) { if ( - t.isPrivateName(expression.property) && - t.isThisExpression(expression.object) && - t.isClass(owner) + current.privateRead !== undefined && + t.isPrivateName(expression.property) ) { - const privateName = expression.property.id.name; - return owner.body.body.flatMap((member) => - t.isClassPrivateProperty(member) && - member.static === isStatic && - member.key.id.name === privateName && - member.value != null - ? [{ node: member.value, path: remaining }] - : [], - ); + retainPrivateRead(); + continue; + } + if (t.isPrivateName(expression.property) && t.isClass(owner)) { + pending.push({ + ...current, + node: expression.object, + path: [], + privateRead: { + node: expression, + name: expression.property.id.name, + path: remaining, + }, + }); + continue; } const key = semanticStaticPropertyKey( expression.property, @@ -402,41 +691,52 @@ const getterResultReferences = ( t.isClass(owner) && owner.superClass != null ) { - return semanticClassCandidates(owner.superClass, state).flatMap( - (base) => - semanticClassPropertyReferences( - { - owner: base, - receiver: t.isClass(receiver) ? receiver : owner, - kind: isStatic ? "static" : "prototype", - }, - [key, ...remaining], - state, - active, - ), - ); + for (const base of semanticClassCandidates(owner.superClass, state)) + for (const reference of semanticClassPropertyReferences( + { + owner: base, + receiver: t.isClass(receiver) ? receiver : owner, + kind: isStatic ? "static" : "prototype", + }, + [key, ...remaining], + state, + active, + )) + retain(reference); + continue; } - return resolve(expression.object, [key, ...remaining]); - } - if (t.isConditionalExpression(expression)) - return [ - ...resolve(expression.consequent, remaining), - ...resolve(expression.alternate, remaining), - ]; - if (t.isLogicalExpression(expression)) - return [ - ...resolve(expression.left, remaining), - ...resolve(expression.right, remaining), - ]; - if (t.isSequenceExpression(expression)) { + pending.push({ + ...current, + node: expression.object, + path: [key, ...remaining], + }); + } else if (t.isConditionalExpression(expression)) + pending.push( + { ...current, node: expression.consequent }, + { ...current, node: expression.alternate }, + ); + else if (t.isLogicalExpression(expression)) + pending.push( + { ...current, node: expression.left }, + { ...current, node: expression.right }, + ); + else if (t.isSequenceExpression(expression)) { const last = expression.expressions.at(-1); - return last === undefined ? [] : resolve(last, remaining); - } - return [{ node: expression, path: remaining }]; - }; - return semanticCallableResultExpressions(getter).flatMap(({ node }) => - node === null ? [] : resolve(node, path), - ); + if (last !== undefined) pending.push({ ...current, node: last }); + } else if (t.isAssignmentExpression(expression, { operator: "=" })) + pending.push({ ...current, node: expression.right }); + else if ( + t.isAssignmentExpression(expression) && + ["&&=", "||=", "??="].includes(expression.operator) + ) + pending.push( + { ...current, node: expression.left }, + { ...current, node: expression.right }, + ); + else if (current.privateRead !== undefined) retainPrivateRead(); + else retain({ node: expression, path: remaining }); + } + return references; }; export const semanticObjectPropertyReferences = ( diff --git a/src/domain/javascript/javascriptSemanticReferenceSources.ts b/src/domain/javascript/javascriptSemanticReferenceSources.ts new file mode 100644 index 000000000..54e1d3ba9 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticReferenceSources.ts @@ -0,0 +1,53 @@ +import type * as t from "@babel/types"; +import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js"; +import type { JavaScriptSemanticPropertyPath as PropertyPath } from "./javascriptSemanticPropertyPaths.js"; +import { evaluateSemanticExpression } from "./javascriptSemanticValues.js"; +import { semanticSlotAtPath } from "./javascriptSemanticSlots.js"; + +interface ReferenceSource { + readonly node: t.Node; + readonly projection: PropertyPath; +} + +/** Exclude references whose source slot or destructuring fallback is absent. */ +export const semanticReferenceSourcesInactive = ( + reference: { + readonly requiredSources?: readonly ReferenceSource[]; + readonly fallbackSources?: readonly ReferenceSource[]; + }, + state: JavaScriptSemanticAnalysisState, +): boolean => + reference.requiredSources?.some( + (source) => + semanticSelectedReferenceSlot(source, state, source.node)?.presence === + "absent", + ) === true || + reference.fallbackSources?.some((source) => { + // Selection precedes effects through the chosen reference. A later escape + // cannot make a fallback that was excluded at destructuring reachable. + const slot = semanticSelectedReferenceSlot(source, state, source.node); + return ( + slot?.presence === "present" && + (slot.value.status === "literal" || + slot.value.status === "union" || + slot.value.status === "object" || + slot.value.status === "array") + ); + }) === true; + +/** Select a retained source slot at its capture, before later effects. */ +export const semanticSelectedReferenceSlot = ( + source: ReferenceSource, + state: JavaScriptSemanticAnalysisState, + capturePoint?: t.Node, +) => { + const path: string[] = []; + for (const key of source.projection) { + if (key === null || typeof key === "object") return undefined; + path.push(String(key)); + } + return semanticSlotAtPath( + evaluateSemanticExpression(source.node, state, capturePoint), + path, + ); +}; diff --git a/tests/acceptance/applications/getterLocalAliasesCli.test.ts b/tests/acceptance/applications/getterLocalAliasesCli.test.ts new file mode 100644 index 000000000..2b2b0196c --- /dev/null +++ b/tests/acceptance/applications/getterLocalAliasesCli.test.ts @@ -0,0 +1,85 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "retains getter-local alias mutations in exported Evidence through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-getter-local-aliases-"); + const cases = [ + ["initialized", "const self = this; return self.s;", true], + ["assigned", "let self; self = this; return self.s;", true], + ["destructured", "const { s: value } = this; return value;", true], + ["readOnly", "const self = this; return self.s;", false], + [ + "cyclic", + "let self = this.start; while (flag) self = self.child; return self;", + true, + "start: { child: { child: shared } }", + ], + ] as const; + const privateCases = [ + ["privateProjection", "shared", true, "unknown"], + ["privateUnrelated", "{ peer: shared }", true, "literal"], + ["privateReadOnly", "shared", false, "literal"], + ] as const; + await writeFile( + join(root, "app.mjs"), + cases + .map( + ([ + name, + getter, + write, + members = "s: shared", + ]) => `export function ${name}() { + const shared = { x: 1 }; + const box = { ${members}, get v() { ${getter} } }; + ${write ? "box.v.x = 2;" : "const saved = box.v;"} + return shared.x; + }`, + ) + .join("\n") + + privateCases + .map( + ([name, value, write]) => `export function ${name}() { + const shared = { x: 1 }; + class Box { + #s = ${value}; + peer = this; + get v() { const { peer: self } = this; return self.#s; } + } + const box = new Box(); + ${write ? "box.v.x = 2;" : "const saved = box.v;"} + return shared.x; + }`, + ) + .join("\n"), + ); + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); + for (const [name, status] of [ + ...cases.map(([name, , write]) => [name, write ? "unknown" : "literal"]), + ...privateCases.map(([name, , , status]) => [name, status]), + ]) { + const observation = graph.nodes + .flatMap(({ observations }) => observations) + .find( + ({ properties }) => + properties.semantic_role === "export-return-shapes" && + properties.exported_name === name, + ); + expect(observation, name).toBeDefined(); + expect(observation?.properties.static_return_shapes).toMatchObject([ + { + value_status: status, + fields: [ + { path: "", state: status, value: status === "unknown" ? null : 1 }, + ], + }, + ]); + } + }, +);