diff --git a/src/domain/javascript/javascriptSemanticAnalysis.getter_receivers.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.getter_receivers.test.ts new file mode 100644 index 0000000000..3b83bf4820 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.getter_receivers.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js"; + +const resultValue = (body: string) => + onlyCallable( + analyzeJavaScriptSemantics(`export function result() { ${body} }`), + "result", + ).returnSites[0]?.value; + +const getterReceivers = [ + [ + "this", + "const box = { x: 1, get self() { return this; } }; box.self.x = 2; return box.x;", + ], + [ + "this property", + "const shared = { x: 1 }; const box = { s: shared, get v() { return this.s; } }; box.v.x = 2; return shared.x;", + ], + [ + "private field", + "const shared = { x: 1 }; class B { #s = shared; get v() { return this.#s; } } new B().v.x = 2; return shared.x;", + ], + [ + "super getter", + "const shared = { x: 1 }; class A { get v() { return shared; } } class B extends A { get v() { return super.v; } } new B().v.x = 2; return shared.x;", + ], + [ + "saved receiver alias", + "const box = { x: 1, get self() { return this; } }; const alias = box.self; alias.x = 2; return box.x;", + ], + [ + "nested owner", + "const outer = { box: { x: 1, get self() { return this; } } }; outer.box.self.x = 2; return outer.box.x;", + ], + [ + "inherited receiver field", + "const shared = { x: 1 }; class A { get v() { return this.s; } } class B extends A { s = shared; } new B().v.x = 2; return shared.x;", + ], + [ + "super receiver field", + "const shared = { x: 1 }; class A { get v() { return this.s; } } class B extends A { s = shared; get v() { return super.v; } } new B().v.x = 2; return shared.x;", + ], + [ + "static private field", + "const shared = { x: 1 }; class B { static #s = shared; static get v() { return this.#s; } } B.v.x = 2; return shared.x;", + ], + [ + "inherited field", + "const shared = { x: 1 }; class A { s = shared; } class B extends A { get v() { return this.s; } } new B().v.x = 2; return shared.x;", + ], + [ + "class getter chain", + "const shared = { x: 1 }; class B { get s() { return shared; } get v() { return this.s; } } new B().v.x = 2; return shared.x;", + ], + [ + "inherited field shadows prototype getter", + "const shared={x:1};class A{s=shared}class B extends A{get s(){return {x:1}} get v(){return this.s}}new B().v.x=2;return shared.x;", + ], + [ + "alternative inherited fields", + "const shared={x:1};const Base=flag?class{s={x:1}}:class{s=shared};class B extends Base{get v(){return this.s}}new B().v.x=2;return shared.x;", + ], + [ + "alternative inherited getter", + "const shared={x:1};const Base=flag?class{s={x:1}}:class{get s(){return shared}};class B extends Base{get v(){return this.s}}new B().v.x=2;return shared.x;", + ], + [ + "repeated class receiver", + "const shared={x:1};class B{s=shared;get self(){return this}}new B().self.self.s.x=2;return shared.x;", + ], + [ + "getter chain", + "const shared = { x: 1 }; const box = { s: shared, get self() { return this; }, get v() { return this.self.s; } }; box.v.x = 2; return shared.x;", + ], +] as const; + +describe("getter receiver mutation aliases (#1665)", () => { + it.each(getterReceivers)( + "does not report a stale literal through %s", + (_shape, body) => { + expect(resultValue(body)?.status).toBe("unknown"); + }, + ); + it.each([ + [ + "duplicate field", + "const shared={x:1};class B{s=shared;s={x:1};get v(){return this.s}}new B().v.x=2;return shared.x;", + ], + [ + "super excludes instance fields", + "const shared={x:1};class A{s=shared}class B extends A{get v(){return super.s ?? {x:1}}}new B().v.x=2;return shared.x;", + ], + [ + "fresh return", + "const shared = { x: 1 }; const box = { get v() { return { x: 1 }; } }; box.v.x = 2; return shared.x;", + ], + [ + "read without write", + "const box = { x: 1, get self() { return this; } }; const alias = box.self; return box.x;", + ], + [ + "unrelated sibling", + "const box = { x: 1, y: 1, get self() { return this; } }; box.self.y = 2; return box.x;", + ], + [ + "replace getter", + "const shared = { x: 1 }; const box = { s: shared, get v() { return this.s; } }; box.v = {}; return shared.x;", + ], + [ + "delete getter", + "const shared = { x: 1 }; const box = { s: shared, get v() { return this.s; } }; delete box.v; return shared.x;", + ], + [ + "ordinary method read", + "const box = { x: 1, self() { return this; } }; const method = box.self; return box.x;", + ], + ])("preserves a known literal for %s", (_shape, body) => { + expect(resultValue(body)).toMatchObject({ status: "literal", value: 1 }); + }); + it("terminates recursive receiver getters", () => { + expect( + resultValue( + "const box = { x: 1, get self() { return this.self; } }; box.self.x = 2; return box.x;", + ), + ).toMatchObject({ status: "literal", value: 1 }); + }); +}); diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 980ff197c9..5acc783c8c 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -14,6 +14,11 @@ import { semanticContainer, semanticSlotAtPath, } from "./javascriptSemanticSlots.js"; +import { + semanticClassCandidates, + semanticClassPropertyReferences, + semanticObjectPropertyReferences, +} from "./javascriptSemanticObjectReferences.js"; import { semanticIterationSources } from "./javascriptSemanticIterationSources.js"; import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js"; import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js"; @@ -121,11 +126,13 @@ export function* collectSemanticMemberMutationsSteps( continue; } // An alias restored from a saved reference can revisit the same binding - // at an earlier capture. Only a repeated lifetime closes a cycle. + // at an earlier capture or a shorter getter-receiver path. Only a + // repeated capture and path closes a cycle. const lifetime = JSON.stringify([ binding.bindingId, current.originAt?.start, current.originAt?.end, + current.path, ]); if (current.bindings.has(lifetime)) continue; // Union compatible copied-slot selections at each binding. Carrying every @@ -618,7 +625,7 @@ const referencedValues = ( if (t.isObjectExpression(node) || t.isArrayExpression(node)) { if (effect === "write" && path.length < 2) return []; return t.isObjectExpression(node) - ? objectReferencedValues(node, path) + ? semanticObjectPropertyReferences(node, path, state) : arrayReferencedValues(node, path); } if (t.isClass(node)) { @@ -627,11 +634,9 @@ const referencedValues = ( // Replacing or deleting the getter's property does not use the value it // would return. Only a deeper path can mutate the returned object. if (effect === "write" && remaining.length === 0) return []; - return classGetterResults( - node, - key === null ? null : String(key), - true, - remaining, + return semanticClassPropertyReferences( + { owner: node, kind: "static" }, + [key, ...remaining], state, ); } @@ -639,12 +644,10 @@ const referencedValues = ( const [key, ...remaining] = path; if (key === undefined) return []; if (effect === "write" && remaining.length === 0) return []; - return classCandidates(node.callee, state).flatMap((klass) => - classGetterResults( - klass, - key === null ? null : String(key), - false, - remaining, + return semanticClassCandidates(node.callee, state).flatMap((klass) => + semanticClassPropertyReferences( + { owner: klass, kind: "instance" }, + [key, ...remaining], state, ), ); @@ -918,266 +921,6 @@ const observableMutationPaths = ( }; }; -const classCandidates = ( - root: t.Node, - state: JavaScriptSemanticAnalysisState, -): readonly (t.ClassDeclaration | t.ClassExpression)[] => { - const pending: t.Node[] = [root]; - const seenNodes = new Set(); - const seenBindings = new Set(); - const classes: (t.ClassDeclaration | t.ClassExpression)[] = []; - for (let cursor = 0; cursor < pending.length; cursor++) { - const node = pending[cursor]; - if (node === undefined) continue; - const expression = unwrapJavaScriptExpression(node).node; - if (seenNodes.has(expression)) continue; - seenNodes.add(expression); - if (t.isClass(expression)) { - classes.push(expression); - } else if (t.isIdentifier(expression)) { - const binding = resolveSemanticBindingState( - state, - expression, - expression.name, - ); - if (binding === undefined || seenBindings.has(binding.bindingId)) - continue; - seenBindings.add(binding.bindingId); - for (const initializer of binding.initializers) - pending.push(initializer.node); - } else if ( - t.isConditionalExpression(expression) || - t.isLogicalExpression(expression) - ) { - pending.push( - t.isConditionalExpression(expression) - ? expression.consequent - : expression.left, - t.isConditionalExpression(expression) - ? expression.alternate - : expression.right, - ); - } else if (t.isSequenceExpression(expression)) { - const last = expression.expressions.at(-1); - if (last !== undefined) pending.push(last); - } - } - return classes; -}; - -const classGetterResults = ( - root: t.ClassDeclaration | t.ClassExpression, - propertyName: string | null, - isStatic: boolean, - path: PropertyPath, - state: JavaScriptSemanticAnalysisState, -): readonly ReferencedValue[] => { - const pending: (t.ClassDeclaration | t.ClassExpression)[] = [root]; - const seen = new Set(); - const possibleResults: ReferencedValue[] = []; - for (let cursor = 0; cursor < pending.length; cursor++) { - const klass = pending[cursor]; - if (klass === undefined || seen.has(klass)) continue; - seen.add(klass); - const indexedMembers: { - readonly member: t.ClassMethod | t.ClassProperty; - readonly index: number; - readonly key: string | null; - }[] = []; - klass.body.body.forEach((member, index) => { - if ( - (t.isClassMethod(member) || t.isClassProperty(member)) && - member.static === isStatic - ) - indexedMembers.push({ - member, - index, - key: semanticStaticPropertyKey(member.key, member.computed), - }); - }); - const matchingMembers = - propertyName === null - ? [] - : indexedMembers.filter(({ key }) => key === propertyName); - const uncertainGetters = indexedMembers.filter( - ({ member, key }) => - t.isClassMethod(member) && - member.kind === "get" && - (propertyName === null || (member.computed && key === null)), - ); - const getters = matchingMembers.filter( - (entry): entry is typeof entry & { member: t.ClassMethod } => - t.isClassMethod(entry.member) && entry.member.kind === "get", - ); - const getter = getters.at(-1); - const getterIndex = getter?.index ?? -1; - const laterDataMember = matchingMembers.some( - ({ member, index }) => - t.isClassProperty(member) || - (t.isClassMethod(member) && - member.kind === "method" && - index > getterIndex), - ); - if (getters.length > 0 && !laterDataMember && getter !== undefined) - possibleResults.push( - ...semanticCallableResultExpressions(getter.member).flatMap( - ({ node }) => (node === null ? [] : [{ node, path }]), - ), - ); - for (const { member: uncertain, index } of uncertainGetters) - if ( - !matchingMembers.some( - ({ member, index: matchingIndex }) => - (t.isClassProperty(member) || - (t.isClassMethod(member) && - (member.kind === "method" || member.kind === "get"))) && - matchingIndex > index, - ) - ) - possibleResults.push( - ...semanticCallableResultExpressions(uncertain).flatMap(({ node }) => - node === null ? [] : [{ node, path }], - ), - ); - // An exact own descriptor suppresses inherited lookup. Dynamic computed - // descriptors may or may not match, so retain possible superclass results. - if ( - matchingMembers.length === 0 && - klass.superClass !== null && - klass.superClass !== undefined - ) - pending.push(...classCandidates(klass.superClass, state)); - } - return possibleResults; -}; - -const objectReferencedValues = ( - node: t.ObjectExpression, - path: PropertyPath, -): readonly ReferencedValue[] => { - const [key, ...remaining] = path; - const references: ReferencedValue[] = []; - const overwritten = new Set(); - const dataOverwritten = new Set(); - const accessorNames = new Set(); - const getterNames = new Set(); - const setterNames = new Set(); - for (const property of [...node.properties].reverse()) { - if (t.isSpreadElement(property)) { - const selected = key ?? null; - if (typeof selected !== "object" && overwritten.has(String(selected))) - continue; - references.push({ - node: property.argument, - path: [ - typeof selected === "object" - ? { - ...selected, - excludedKeys: [ - ...new Set([ - ...(selected?.excludedKeys ?? []), - ...overwritten, - ]), - ].sort(compareUnicodeCodePoints), - } - : selected, - ...remaining, - ], - }); - if ( - selected !== null && - typeof selected !== "object" && - t.isObjectExpression(property.argument) && - objectExpressionDefinesKey(property.argument, String(selected)) - ) { - overwritten.add(String(selected)); - dataOverwritten.add(String(selected)); - } - continue; - } - const name = semanticStaticPropertyKey(property.key, property.computed); - const isGetter = t.isObjectMethod(property) && property.kind === "get"; - const isSetter = t.isObjectMethod(property) && property.kind === "set"; - if ( - name !== null && - (isGetter || isSetter - ? dataOverwritten.has(name) || - (isGetter ? getterNames.has(name) : setterNames.has(name)) - : dataOverwritten.has(name) || accessorNames.has(name)) - ) - continue; - if (name === null || semanticPropertyPathKeyMatches(key ?? null, name)) { - if (t.isObjectMethod(property) && property.kind === "get") { - for (const result of semanticCallableResultExpressions(property)) - if (result.node !== null) - references.push({ node: result.node, path: remaining }); - } else if (t.isObjectMethod(property) && property.kind === "set") { - // A setter contributes no readable reference. A paired getter is - // handled independently when it appears earlier in this object. - } else { - references.push({ - node: t.isObjectProperty(property) ? property.value : property, - path: remaining, - }); - } - } - if (name !== null) { - if (isGetter) { - getterNames.add(name); - accessorNames.add(name); - overwritten.add(name); - } else if (isSetter) { - setterNames.add(name); - accessorNames.add(name); - overwritten.add(name); - } else if ( - !( - t.isObjectProperty(property) && - !property.computed && - !property.shorthand && - name === "__proto__" - ) - ) { - overwritten.add(name); - dataOverwritten.add(name); - } - } - } - return references; -}; - -const objectExpressionDefinesKey = ( - root: t.ObjectExpression, - key: string, -): boolean => { - const pending: t.ObjectExpression[] = [root]; - const seen = new Set(); - for (let cursor = 0; cursor < pending.length; cursor++) { - const object = pending[cursor]; - if (object === undefined || seen.has(object)) continue; - seen.add(object); - for (const property of object.properties) { - if (t.isSpreadElement(property)) { - if (t.isObjectExpression(property.argument)) - pending.push(property.argument); - continue; - } - const name = semanticStaticPropertyKey(property.key, property.computed); - if ( - name === key && - !( - t.isObjectProperty(property) && - !property.computed && - !property.shorthand && - name === "__proto__" - ) - ) - return true; - } - } - return false; -}; - const arrayReferencedValues = ( node: t.ArrayExpression, path: PropertyPath, diff --git a/src/domain/javascript/javascriptSemanticObjectReferences.ts b/src/domain/javascript/javascriptSemanticObjectReferences.ts new file mode 100644 index 0000000000..387ba09d28 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticObjectReferences.ts @@ -0,0 +1,534 @@ +import * as t from "@babel/types"; +import { + resolveSemanticBindingState, + type JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; +import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js"; +import { + semanticStaticPropertyKey, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; +import { + semanticPropertyPathKeyMatches, + type JavaScriptSemanticPropertyPath as PropertyPath, +} from "./javascriptSemanticPropertyPaths.js"; +import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; + +interface ReferencedValue { + readonly node: t.Node; + readonly path: PropertyPath; +} + +export const semanticClassCandidates = ( + root: t.Node, + state: JavaScriptSemanticAnalysisState, +): readonly (t.ClassDeclaration | t.ClassExpression)[] => { + const pending: t.Node[] = [root]; + const seenNodes = new Set(); + const seenBindings = new Set(); + const classes: (t.ClassDeclaration | t.ClassExpression)[] = []; + for (let cursor = 0; cursor < pending.length; cursor++) { + const node = pending[cursor]; + if (node === undefined) continue; + const expression = unwrapJavaScriptExpression(node).node; + if (seenNodes.has(expression)) continue; + seenNodes.add(expression); + if (t.isClass(expression)) { + classes.push(expression); + } else if (t.isIdentifier(expression)) { + const binding = resolveSemanticBindingState( + state, + expression, + expression.name, + ); + if (binding === undefined || seenBindings.has(binding.bindingId)) + continue; + seenBindings.add(binding.bindingId); + for (const initializer of binding.initializers) + pending.push(initializer.node); + } else if ( + t.isConditionalExpression(expression) || + t.isLogicalExpression(expression) + ) { + pending.push( + t.isConditionalExpression(expression) + ? expression.consequent + : expression.left, + t.isConditionalExpression(expression) + ? expression.alternate + : expression.right, + ); + } else if (t.isSequenceExpression(expression)) { + const last = expression.expressions.at(-1); + if (last !== undefined) pending.push(last); + } + } + return classes; +}; + +const instanceFieldReferences = ( + root: t.ClassDeclaration | t.ClassExpression, + propertyName: string | null, + path: PropertyPath, + state: JavaScriptSemanticAnalysisState, +): { + readonly references: readonly ReferencedValue[]; + readonly definiteNames: ReadonlySet; +} => { + const references: ReferencedValue[] = []; + const pending = [{ klass: root, excluded: new Set() }]; + const selections = new Map>(); + let definiteNames: ReadonlySet | undefined; + for (let cursor = 0; cursor < pending.length; cursor++) { + const current = pending[cursor]; + if (current === undefined) continue; + const previous = selections.get(current.klass); + // A shared base can be reached through different derived classes. Only + // exclusions common to those paths can suppress a field in that base. + const excluded = + previous === undefined + ? current.excluded + : new Set([...previous].filter((name) => current.excluded.has(name))); + 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()) { + if (!t.isClassProperty(member) || member.static) continue; + const key = semanticStaticPropertyKey(member.key, member.computed); + if (key !== null && shadowed.has(key)) continue; + if (key !== null) shadowed.add(key); + if ( + (propertyName === null || key === null || key === propertyName) && + member.value != null + ) + references.push({ node: member.value, path }); + } + const bases = + current.klass.superClass == null + ? [] + : semanticClassCandidates(current.klass.superClass, state); + if (bases.length === 0) + definiteNames = + definiteNames === undefined + ? shadowed + : new Set([...definiteNames].filter((name) => shadowed.has(name))); + for (const klass of bases) pending.push({ klass, excluded: shadowed }); + } + return { references, definiteNames: definiteNames ?? new Set() }; +}; + +interface ClassPropertyLookup { + readonly owner: t.ClassDeclaration | t.ClassExpression; + readonly receiver?: t.ClassDeclaration | t.ClassExpression; + readonly kind: "instance" | "static" | "prototype"; +} + +const classPropertyDescriptors = ( + klass: t.ClassDeclaration | t.ClassExpression, + propertyName: string | null, + kind: ClassPropertyLookup["kind"], +) => { + const isStatic = kind === "static"; + const selectedGetters: t.ClassMethod[] = []; + const fields: t.ClassProperty[] = []; + const indexedMembers: { + readonly member: t.ClassMethod | t.ClassProperty; + readonly index: number; + readonly key: string | null; + }[] = []; + klass.body.body.forEach((member, index) => { + if ( + (t.isClassMethod(member) || + (kind === "static" && t.isClassProperty(member))) && + member.static === isStatic + ) + indexedMembers.push({ + member, + index, + key: semanticStaticPropertyKey(member.key, member.computed), + }); + }); + const matchingMembers = + propertyName === null + ? [] + : indexedMembers.filter(({ key }) => key === propertyName); + const uncertainGetters = indexedMembers.filter( + (entry): entry is typeof entry & { member: t.ClassMethod } => { + const { member, key } = entry; + return ( + t.isClassMethod(member) && + member.kind === "get" && + (propertyName === null || (member.computed && key === null)) + ); + }, + ); + const getters = matchingMembers.filter( + (entry): entry is typeof entry & { member: t.ClassMethod } => + t.isClassMethod(entry.member) && entry.member.kind === "get", + ); + const getter = getters.at(-1); + const getterIndex = getter?.index ?? -1; + const laterDataMember = matchingMembers.some( + ({ member, index }) => + t.isClassProperty(member) || + (t.isClassMethod(member) && + member.kind === "method" && + index > getterIndex), + ); + const seenFields = new Set(); + for (const { member, key } of [...indexedMembers].reverse()) { + if (!t.isClassProperty(member)) continue; + if (key !== null && seenFields.has(key)) continue; + if (key !== null) seenFields.add(key); + if (propertyName === null || key === null || key === propertyName) + fields.push(member); + } + if (getters.length > 0 && !laterDataMember && getter !== undefined) + selectedGetters.push(getter.member); + for (const { member, index } of uncertainGetters) + if ( + !matchingMembers.some( + ({ member: matching, index: matchingIndex }) => + (t.isClassProperty(matching) || + (t.isClassMethod(matching) && + (matching.kind === "method" || matching.kind === "get"))) && + matchingIndex > index, + ) + ) + selectedGetters.push(member); + return { + getters: selectedGetters, + fields, + inherits: matchingMembers.length === 0, + }; +}; + +export const semanticClassPropertyReferences = ( + lookup: ClassPropertyLookup, + selection: PropertyPath, + state: JavaScriptSemanticAnalysisState, + activeGetters: ReadonlyMap = new Map(), +): readonly ReferencedValue[] => { + const root = lookup.owner; + const receiver = lookup.receiver ?? root; + const [key, ...path] = selection; + const propertyName = + key == null || typeof key === "object" ? null : String(key); + const pending: (t.ClassDeclaration | t.ClassExpression)[] = [root]; + const seen = new Set(); + const possibleResults: ReferencedValue[] = []; + // Instance fields are own properties, including fields initialized by base + // constructors. They take precedence over every prototype descriptor. + let instanceFields: ReadonlySet = new Set(); + if (lookup.kind === "instance") { + const fields = instanceFieldReferences(root, propertyName, path, state); + possibleResults.push(...fields.references); + instanceFields = fields.definiteNames; + if (propertyName !== null && instanceFields.has(propertyName)) + return possibleResults; + } + for (let cursor = 0; cursor < pending.length; cursor++) { + const klass = pending[cursor]; + if (klass === undefined || seen.has(klass)) continue; + seen.add(klass); + const descriptors = classPropertyDescriptors( + klass, + propertyName, + lookup.kind, + ); + for (const field of descriptors.fields) + if (field.value != null) + possibleResults.push({ node: field.value, path }); + for (const getter of descriptors.getters) { + const key = semanticStaticPropertyKey(getter.key, getter.computed); + if (key !== null && instanceFields.has(key)) continue; + possibleResults.push( + ...getterResultReferences( + { getter, owner: klass, receiver }, + path, + state, + activeGetters, + ), + ); + } + // An exact own descriptor suppresses inherited lookup. Dynamic computed + // descriptors may or may not match, so retain possible superclass results. + if ( + descriptors.inherits && + klass.superClass !== null && + klass.superClass !== undefined + ) + pending.push(...semanticClassCandidates(klass.superClass, state)); + } + return possibleResults; +}; + +const objectReceiverReference = ( + object: t.ObjectExpression, + path: PropertyPath, + state: JavaScriptSemanticAnalysisState, +): readonly ReferencedValue[] => { + // A literal's enclosing binding owns its slots. Returning the literal + // itself would bypass invalidation of that binding's observed properties. + let current: t.Node = object; + const projection: (string | number | null)[] = []; + while (true) { + const parent = state.parentsByNode.get(current); + if ( + t.isVariableDeclarator(parent) && + parent.init === current && + t.isIdentifier(parent.id) + ) + return [{ node: parent.id, path: [...projection, ...path] }]; + if (t.isObjectProperty(parent) && parent.value === current) { + projection.unshift( + semanticStaticPropertyKey(parent.key, parent.computed), + ); + const container = state.parentsByNode.get(parent); + if (t.isObjectExpression(container)) { + current = container; + continue; + } + } + break; + } + return [{ node: object, path }]; +}; + +// Getter return expressions use the receiver of the property read. Keep that +// identity when translating `this`, private fields, and superclass reads into +// the same reference paths used by ordinary member mutations. +interface GetterInvocation { + readonly getter: t.ObjectMethod | t.ClassMethod; + readonly owner: t.ObjectExpression | t.ClassDeclaration | t.ClassExpression; + readonly receiver?: + | t.ObjectExpression + | t.ClassDeclaration + | t.ClassExpression; +} + +const getterResultReferences = ( + invocation: GetterInvocation, + path: PropertyPath, + state: JavaScriptSemanticAnalysisState, + activeGetters: ReadonlyMap = new Map(), +): readonly ReferencedValue[] => { + const { getter, owner, receiver = owner } = invocation; + const previousLength = activeGetters.get(getter); + if (previousLength !== undefined && previousLength <= path.length) return []; + // Repeated receiver reads may revisit a getter while consuming a finite + // 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 ReferencedValue[] => { + const expression = unwrapJavaScriptExpression(node).node; + if (t.isThisExpression(expression)) { + if (t.isClass(receiver)) { + return semanticClassPropertyReferences( + { owner: receiver, kind: isStatic ? "static" : "instance" }, + remaining, + state, + active, + ); + } + return objectReceiverReference(receiver, remaining, state); + } + if ( + t.isMemberExpression(expression) || + t.isOptionalMemberExpression(expression) + ) { + if ( + t.isPrivateName(expression.property) && + t.isThisExpression(expression.object) && + t.isClass(owner) + ) { + 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 }] + : [], + ); + } + const key = semanticStaticPropertyKey( + expression.property, + expression.computed, + ); + if ( + t.isSuper(expression.object) && + 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, + ), + ); + } + 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)) { + 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), + ); +}; + +export const semanticObjectPropertyReferences = ( + node: t.ObjectExpression, + path: PropertyPath, + state: JavaScriptSemanticAnalysisState, +): readonly ReferencedValue[] => { + const [key, ...remaining] = path; + const references: ReferencedValue[] = []; + const overwritten = new Set(); + const dataOverwritten = new Set(); + const accessorNames = new Set(); + const getterNames = new Set(); + const setterNames = new Set(); + for (const property of [...node.properties].reverse()) { + if (t.isSpreadElement(property)) { + const selected = key ?? null; + if (typeof selected !== "object" && overwritten.has(String(selected))) + continue; + references.push({ + node: property.argument, + path: [ + typeof selected === "object" + ? { + ...selected, + excludedKeys: [ + ...new Set([ + ...(selected?.excludedKeys ?? []), + ...overwritten, + ]), + ].sort(compareUnicodeCodePoints), + } + : selected, + ...remaining, + ], + }); + if ( + selected !== null && + typeof selected !== "object" && + t.isObjectExpression(property.argument) && + objectExpressionDefinesKey(property.argument, String(selected)) + ) { + overwritten.add(String(selected)); + dataOverwritten.add(String(selected)); + } + continue; + } + const name = semanticStaticPropertyKey(property.key, property.computed); + const isGetter = t.isObjectMethod(property) && property.kind === "get"; + const isSetter = t.isObjectMethod(property) && property.kind === "set"; + if ( + name !== null && + (isGetter || isSetter + ? dataOverwritten.has(name) || + (isGetter ? getterNames.has(name) : setterNames.has(name)) + : dataOverwritten.has(name) || accessorNames.has(name)) + ) + continue; + if (name === null || semanticPropertyPathKeyMatches(key ?? null, name)) { + if (t.isObjectMethod(property) && property.kind === "get") { + references.push( + ...getterResultReferences( + { getter: property, owner: node }, + remaining, + state, + ), + ); + } else if (t.isObjectMethod(property) && property.kind === "set") { + // A setter contributes no readable reference. A paired getter is + // handled independently when it appears earlier in this object. + } else { + references.push({ + node: t.isObjectProperty(property) ? property.value : property, + path: remaining, + }); + } + } + if (name !== null) { + if (isGetter) { + getterNames.add(name); + accessorNames.add(name); + overwritten.add(name); + } else if (isSetter) { + setterNames.add(name); + accessorNames.add(name); + overwritten.add(name); + } else if ( + !( + t.isObjectProperty(property) && + !property.computed && + !property.shorthand && + name === "__proto__" + ) + ) { + overwritten.add(name); + dataOverwritten.add(name); + } + } + } + return references; +}; + +const objectExpressionDefinesKey = ( + root: t.ObjectExpression, + key: string, +): boolean => { + const pending: t.ObjectExpression[] = [root]; + const seen = new Set(); + for (let cursor = 0; cursor < pending.length; cursor++) { + const object = pending[cursor]; + if (object === undefined || seen.has(object)) continue; + seen.add(object); + for (const property of object.properties) { + if (t.isSpreadElement(property)) { + if (t.isObjectExpression(property.argument)) + pending.push(property.argument); + continue; + } + const name = semanticStaticPropertyKey(property.key, property.computed); + if ( + name === key && + !( + t.isObjectProperty(property) && + !property.computed && + !property.shorthand && + name === "__proto__" + ) + ) + return true; + } + } + return false; +}; diff --git a/tests/acceptance/applications/getterReceiverMutationsCli.test.ts b/tests/acceptance/applications/getterReceiverMutationsCli.test.ts new file mode 100644 index 0000000000..a5ba0bfeb3 --- /dev/null +++ b/tests/acceptance/applications/getterReceiverMutationsCli.test.ts @@ -0,0 +1,89 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { z } from "zod"; + +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"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "keeps getter receiver mutations uncertain in exported Evidence through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-getter-receivers-"); + await writeFile( + join(root, "app.mjs"), + ` +export function self() { + const box = { x: 1, get self() { return this; } }; + box.self.x = 2; + return box.x; +} +export function property() { + const shared = { x: 1 }; + const box = { s: shared, get v() { return this.s; } }; + box.v.x = 2; + return shared.x; +} +export function privateField() { + const shared = { x: 1 }; + class B { #s = shared; get v() { return this.#s; } } + new B().v.x = 2; + return shared.x; +} +export function superclass() { + const shared = { x: 1 }; + class A { get v() { return shared; } } + class B extends A { get v() { return super.v; } } + new B().v.x = 2; + return shared.x; +} +export function untouched() { + const box = { x: 1, y: 1, get self() { return this; } }; + box.self.y = 2; + return box.x; +} +`, + ); + let document: unknown; + if (surface === "CLI") { + const response = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + environment: { REA_LOG_LEVEL: "silent" }, + }); + expect(response.exitCode, response.stderr).toBe(0); + document = response.json; + } else { + const { call } = await connectLocalToolsMcp(); + const response = await call("analyze_javascript_application", { + input_path: root, + format: "directory", + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + document = response.structuredContent; + } + const { graph } = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(document).normalized_result; + for (const name of [ + "self", + "property", + "privateField", + "superclass", + "untouched", + ]) { + const projection = graph.nodes + .flatMap(({ observations }) => observations) + .find( + ({ properties }) => + properties.semantic_role === "export-return-shapes" && + properties.exported_name === name, + ); + expect(projection, name).toBeDefined(); + expect(projection?.properties.static_return_shapes).toMatchObject([ + { value_status: name === "untouched" ? "literal" : "unknown" }, + ]); + } + }, +);