diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 56dd445865..4fcafe45e9 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -413,12 +413,22 @@ containers, or assigned to a previously declared identifier. Saving the getter result in an alias does not make its old field values immutable. Known array indices include holes; an element after a spread has only a minimum possible index. For these returned-reference writes, unrelated properties and known -array prefixes remain precise. Getter invocation has separate effects: a -property read can invoke a getter that mutates its receiver without any write -through the returned value. Current analysis can miss these invocation effects -and retain a stale receiver-field literal -([#6669](https://github.com/morluto/rea/issues/6669)); absence of a later write -does not establish getter purity. +array prefixes remain precise. Accessor applicability uses the shared object +reference owner for literal definition order, own-property shadowing, known +array elements and explicit literal prototypes. Getter invocation effects +project write and call operands through the same local alias and `this` owner +used by returned references. Reads of another selected getter continue that +effect walk. A receiver reached by these effects becomes uncertain on an +ordinary or optional read, compound assignment or update. A simple assignment +or deletion does not invoke the getter. Selected setters invalidate +the receiver on assignment, including through known array and prototype paths. +Inert getters and ordinary data slots retain their supported precision. + +These are conservative source-derived effects, not execution results. The +analysis does not establish general accessor purity, model every class or +reflectively installed descriptor, or cover getter reads introduced by object +spread/destructuring and all implicit coercion or disposal invocations. Absence of a later explicit write does not establish getter purity; +unsupported invocation forms can still retain stale receiver-field facts. Nested callable returns are not assigned to their parent callable. Projected graph observations carry source ranges but never source text. diff --git a/src/domain/javascript/javascriptSemanticAnalysis.accessor_invocations.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.accessor_invocations.test.ts new file mode 100644 index 0000000000..cd37daf3bb --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.accessor_invocations.test.ts @@ -0,0 +1,181 @@ +import { expect, it } from "vitest"; +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js"; + +const returned = (body: string) => + onlyCallable( + analyzeJavaScriptSemantics(`export function observe() { ${body} }`), + "observe", + ).returnSites[0]?.value; + +it.each([ + ["ordinary read", "box.trigger;"], + ["optional read", "box?.trigger;"], + ["compound assignment", "box.trigger += 1;"], + ["update", "box.trigger++;"], +])("invalidates a receiver after a mutating getter's %s", (_name, read) => { + expect( + returned(` + const box = { value: 1, get trigger() { this.value = 2; return 0; } }; + ${read} + return box.value; + `), + ).toMatchObject({ status: "unknown" }); +}); + +it("follows inherited getter selection while preserving own-property shadowing", () => { + const prototype = "{ get trigger() { this.value = 2; return 0; } }"; + expect( + returned(` + const prototype = ${prototype}; + const box = { __proto__: prototype, value: 1 }; + box.trigger; + return box.value; + `), + ).toMatchObject({ status: "unknown" }); + expect( + returned(` + const box = { __proto__: ${prototype}, value: 1, trigger: 0 }; + box.trigger; + return box.value; + `), + ).toEqual({ status: "literal", value: 1 }); +}); + +it.each([ + ["simple store", "box.trigger = 2;"], + ["destructuring store", "({ trigger: box.trigger } = { trigger: 2 });"], + ["delete", "delete box.trigger;"], + ["later data property", "box.trigger;", ", trigger: 0"], +])("does not invoke a getter for %s", (_name, operation, later = "") => { + expect( + returned(` + const box = { value: 1, get trigger() { this.value = 2; return 0; } ${later} }; + ${operation} + return box.value; + `), + ).toEqual({ status: "literal", value: 1 }); +}); + +it("preserves an inert getter and a primitive sibling outside the receiver", () => { + expect( + returned(` + const box = { value: 1, get trigger() { return this.value; } }; + box.trigger; + return box.value; + `), + ).toEqual({ status: "literal", value: 1 }); + expect( + returned(` + const outer = { kept: "stable", rows: [{ value: 1, get trigger() { this.value = 2; } }] }; + outer.rows[0].trigger; + return outer.kept; + `), + ).toEqual({ status: "literal", value: "stable" }); +}); + +it.each([ + [ + "array element", + "const rows = [{ value: 1, set trigger(value) { this.value = value; } }];", + "rows[0]", + ], + [ + "inherited setter", + "const box = { __proto__: { set trigger(value) { this.value = value; } }, value: 1 };", + "box", + ], + [ + "aliased nested receiver", + "const box = { value: 1, set trigger(value) { this.value = value; } }; const outer = { box };", + "outer.box", + ], +])( + "invalidates the selected %s receiver on assignment", + (_name, setup, receiver) => { + expect( + returned(`${setup} ${receiver}.trigger = 2; return ${receiver}.value;`), + ).toMatchObject({ status: "unknown" }); + }, +); + +it("does not inherit a setter through an own data property or copied descriptor", () => { + for (const box of [ + "{ __proto__: { set trigger(value) { this.value = value; } }, value: 1, trigger: 0 }", + "{ ...{ set trigger(value) { this.value = value; } }, value: 1 }", + ]) { + expect( + returned(`const box = ${box}; box.trigger = 2; return box.value;`), + ).toEqual({ status: "literal", value: 1 }); + } +}); + +it("follows getter-to-getter invocation effects with the selected receiver", () => { + expect( + returned(` + const box = { + value: 1, + get trigger() { return this.trigger2; }, + get trigger2() { this.value = 2; return 0; }, + }; + box.trigger; + return box.value; + `), + ).toMatchObject({ status: "unknown" }); +}); + +it.each([ + "const other = {}; other.x = 2; return this.value;", + "const unused = () => { this.value = 2; }; return 0;", + "const outer = this; const unused = () => outer.value++; return 0;", +])( + "keeps the receiver exact when getter effects do not reach it: %s", + (body) => { + expect( + returned(` + const box = { value: 1, get trigger() { ${body} } }; + box.trigger; + return box.value; + `), + ).toEqual({ status: "literal", value: 1 }); + }, +); + +it.each([ + "(() => { this.value = 2; })(); return 0;", + "const change = () => { this.value = 2; }; change(); return 0;", + "const outer = this; function change() { outer.value = 2; } change(); return 0;", + "const a = { box: this }; a.box.value = 2; return 0;", + "const outer = this; function Change() { outer.value = 2; } new Change(); return 0;", + "const tag = () => { this.value = 2; }; tag``; return 0;", +])( + "retains effects through invoked local callables and containers: %s", + (body) => { + expect( + returned(` + const box = { value: 1, get trigger() { ${body} } }; + box.trigger; + return box.value; + `), + ).toMatchObject({ status: "unknown" }); + }, +); + +it("retains references returned from tagged invocation through the ordinary callee owner", () => { + expect( + returned(` + const shared = { value: 1 }; + const tag = () => shared; + tag\`\`; + return shared.value; + `), + ).toMatchObject({ status: "unknown" }); + expect( + returned(` + const shared = { value: 1 }; + const tag = () => 0; + tag\`\`; + return shared.value; + `), + ).toEqual({ status: "literal", value: 1 }); +}); diff --git a/src/domain/javascript/javascriptSemanticGetterResults.ts b/src/domain/javascript/javascriptSemanticGetterResults.ts index d2327c480c..c0de099456 100644 --- a/src/domain/javascript/javascriptSemanticGetterResults.ts +++ b/src/domain/javascript/javascriptSemanticGetterResults.ts @@ -9,12 +9,17 @@ import { semanticReferenceSourcesInactive } from "./javascriptSemanticReferenceS import { resolveSemanticBindingState, type JavaScriptSemanticBindingState, + type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; -import type { JavaScriptSemanticObjectReference as Reference } from "./javascriptSemanticObjectReferences.js"; +import { + semanticObjectPropertyReferences, + semanticArrayPropertyReferences, + type JavaScriptSemanticObjectReference as Reference, +} from "./javascriptSemanticObjectReferences.js"; import type { JavaScriptSemanticPropertyPath as PropertyPath } from "./javascriptSemanticPropertyPaths.js"; -import type { - GetterQueryJob, +import { SemanticGetterQuery, + type GetterQueryJob, } from "./javascriptSemanticGetterQueries.js"; type ClassNode = t.ClassDeclaration | t.ClassExpression; @@ -55,6 +60,10 @@ export interface GetterResultResolvers { isStatic: boolean, ) => boolean; readonly bases: (node: t.Node) => readonly ClassNode[]; + readonly containerReferences?: ( + node: t.ObjectExpression | t.ArrayExpression, + path: PropertyPath, + ) => readonly Reference[]; } /** Branch syntax contributes alternatives without evaluating application code. */ @@ -83,10 +92,11 @@ class GetterResultWalker { readonly job: GetterQueryJob, readonly query: SemanticGetterQuery, readonly resolvers: GetterResultResolvers, + roots: readonly { + readonly node: t.Node | null; + }[] = semanticCallableResultExpressions(job.invocation.getter), ) { - for (const { node } of semanticCallableResultExpressions( - job.invocation.getter, - )) + for (const { node } of roots) if (node !== null) this.#enqueue(() => ({ node, path: job.path })); this.#scope = query.state.scopeByNode.get(job.invocation.getter)?.scopeId; } @@ -364,7 +374,21 @@ class GetterResultWalker { for (const node of branches) this.#enqueue(() => ({ ...current, node })); else if (current.privateRead !== undefined) this.#privateRead(current); - else this.#retain(current, { node: expression, path: current.path }); + else if ( + current.path.length > 0 && + (t.isObjectExpression(expression) || t.isArrayExpression(expression)) && + this.resolvers.containerReferences !== undefined + ) { + for (const reference of this.resolvers.containerReferences( + expression, + current.path, + )) + this.#enqueue(() => ({ + ...current, + node: reference.node, + path: reference.path, + })); + } else this.#retain(current, { node: expression, path: current.path }); } } } @@ -374,3 +398,39 @@ export const expandSemanticGetterResult = ( query: SemanticGetterQuery, resolvers: GetterResultResolvers, ): void => new GetterResultWalker(job, query, resolvers).run(); + +/** Project an effect operand through the same local aliases and `this` owner as getter results. */ +export const semanticGetterReceiverPaths = ( + getter: t.ObjectMethod, + operand: t.Node, + state: JavaScriptSemanticAnalysisState, +): readonly PropertyPath[] => { + const owner = state.parentsByNode.get(getter); + if (!t.isObjectExpression(owner)) return []; + const query = new SemanticGetterQuery(state); + const references = query.run( + () => { + query.schedule({ getter, owner }, []); + return []; + }, + (job) => + new GetterResultWalker( + job, + query, + { + objectReferences: (_receiver, path) => [{ node: getter, path }], + classReferences: () => [], + privateReceiver: () => false, + bases: () => [], + containerReferences: (node, path) => + t.isObjectExpression(node) + ? semanticObjectPropertyReferences(node, path, state) + : semanticArrayPropertyReferences(node, path), + }, + [{ node: operand }], + ).run(), + ); + return references + .filter(({ node }) => node === getter) + .map(({ path }) => path); +}; diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index fea8fc7bc4..066fcd64c4 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -33,15 +33,21 @@ import { semanticClassCandidates, semanticClassPropertyReferences, semanticObjectPropertyReferences, + semanticObjectAccessorReferences, type JavaScriptSemanticObjectReference, } from "./javascriptSemanticObjectReferences.js"; import { semanticIterationSources, type SemanticIterationSource, } from "./javascriptSemanticIterationSources.js"; +import { semanticGetterReceiverPaths } from "./javascriptSemanticGetterResults.js"; import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js"; import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js"; -import { semanticMutationInitializers } from "./javascriptSemanticMutationInitializers.js"; +import { + semanticMutationInitializers, + semanticBindingAtRead, +} from "./javascriptSemanticMutationInitializers.js"; +import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js"; import { semanticCopiedReferencePath, semanticKnownArrayIteration, @@ -55,10 +61,14 @@ import { type JavaScriptSemanticPropertyPath, } from "./javascriptSemanticPropertyPaths.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; -import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js"; +import { + traverseJavaScriptAstSteps, + childNodes, +} from "./javascriptSemanticTraversal.js"; import { semanticStaticPropertyKey, semanticWriteSiteTarget, + semanticWriteSiteReadsFirst, semanticWriteTargets, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; @@ -85,79 +95,169 @@ const arrayPrototypeIterationMayChange = (path: PropertyPath): boolean => { /** Preserve uncertainty from property writes and references exposed to calls, yielding during traversal and fixpoint iteration. */ -/** Setter keys of one object literal; a computed setter key may match any key. */ -interface SetterKeys { - readonly names: ReadonlySet; - readonly computed: boolean; -} - -// Writes to one literal repeat; scan its properties once, including the -// common result that it defines no setter. -const setterKeysByObject = new WeakMap(); - -const setterKeys = (object: t.ObjectExpression): SetterKeys | null => { - const cached = setterKeysByObject.get(object); - if (cached !== undefined) return cached; - const names = new Set(); - let computed = false; - // Follow definition order: a later data property or method with the same - // static name replaces the accessor, so its setter no longer runs. Spreads - // and unknown computed keys may or may not replace it and keep it. - for (const property of object.properties) { - if (t.isSpreadElement(property)) continue; - const name = semanticStaticPropertyKey(property.key, property.computed); - if (t.isObjectMethod(property) && property.kind === "set") { - if (name === null) computed = true; - else names.add(name); +/** Accessor selection uses the same slots, prototype shadowing and array indices as aliases. */ +const localAccessorMethods = ( + root: t.Node, + path: PropertyPath, + accessor: "get" | "set", + state: JavaScriptSemanticAnalysisState, + keyMayExist: ( + key: PropertyPath[number] | undefined, + kind: "get" | "set", + ) => boolean, +): readonly t.ObjectMethod[] | undefined => { + if (!keyMayExist(path.at(-1), accessor)) return []; + const methods = new Set(); + const pending = [{ node: root, path }]; + const examined = new WeakMap>(); + let selections = 0; + for (let cursor = 0; cursor < pending.length; cursor++) { + const current = pending[cursor]!; + const node = unwrapJavaScriptExpression(current.node).node; + const key = JSON.stringify(current.path); + const paths = examined.get(node) ?? new Set(); + if (paths.has(key)) continue; + paths.add(key); + examined.set(node, paths); + if (++selections > SEMANTIC_ALIAS_SELECTION_LIMIT) { + state.valueResourceLimits.add("alias-selections"); + return undefined; + } + if (t.isObjectMethod(node) && current.path.length === 0) { + methods.add(node); + continue; + } + if (t.isIdentifier(node)) { + const binding = resolveSemanticBindingState(state, node, node.name); + if (binding === undefined) continue; + const selected = semanticBindingAtRead( + binding, + node, + state.parentsByNode, + ).binding; + for (const source of [ + ...selected.initializers, + ...selected.referenceInitializers, + ]) + pending.push({ + node: source.node, + path: [...source.projection, ...current.path], + }); } else if ( - name !== null && - !(t.isObjectMethod(property) && property.kind === "get") - ) - names.delete(name); + t.isMemberExpression(node) || + t.isOptionalMemberExpression(node) + ) { + pending.push({ + node: node.object, + path: [ + semanticStaticPropertyKey(node.property, node.computed), + ...current.path, + ], + }); + } else if ( + current.path.length > 0 && + (t.isObjectExpression(node) || t.isArrayExpression(node)) + ) { + const [selected, ...remaining] = current.path; + const references = t.isArrayExpression(node) + ? semanticArrayPropertyReferences(node, [selected!]) + : remaining.length === 0 + ? semanticObjectAccessorReferences(node, selected!, state, accessor) + : semanticObjectPropertyReferences(node, [selected!], state); + for (const source of references) + pending.push({ + node: source.node, + path: [...source.path, ...remaining], + }); + } else { + for (const source of branchReferenceValues(node, current.path)) + pending.push(source); + } } - const keys = names.size === 0 && !computed ? null : { names, computed }; - setterKeysByObject.set(object, keys); - return keys; + return [...methods]; }; -/** - * The receiver path whose own object-literal setter an assignment to `path` - * runs, following nested literal members from the binding's initializer. - */ -const localSetterReceiver = ( - initializers: readonly { - readonly node: t.Node; - readonly projection: readonly unknown[]; - }[], - path: readonly unknown[], -): readonly string[] | undefined => { - const key = path.at(-1); - if (typeof key !== "string") return undefined; - const receiver = path.slice(0, -1); - if (!receiver.every((segment) => typeof segment === "string")) - return undefined; - for (const { node, projection } of initializers) { - if (projection.length !== 0) continue; - let object: t.Node | undefined = unwrapJavaScriptExpression(node).node; - for (const segment of receiver) { - if (!t.isObjectExpression(object)) break; - const member: t.ObjectExpression["properties"][number] | undefined = - object.properties.findLast( - (property) => - t.isObjectProperty(property) && - semanticStaticPropertyKey(property.key, property.computed) === - segment, - ); - object = t.isObjectProperty(member) - ? unwrapJavaScriptExpression(member.value).node - : undefined; +type AccessorEffectOperand = + | { readonly kind: "escape"; readonly node: t.Node } + | { + readonly kind: "read"; + readonly node: t.Node; + readonly key: PropertyPath[number]; } - if (!t.isObjectExpression(object)) continue; - const keys = setterKeys(object); - if (keys !== null && (keys.computed || keys.names.has(key))) - return receiver as readonly string[]; + | { + readonly kind: "invoke"; + readonly node: t.Node; + readonly invocation?: t.CallExpression | t.OptionalCallExpression; + }; + +const accessorOperands = new WeakMap< + t.Function, + readonly AccessorEffectOperand[] +>(); + +/** Collect invocation operands once; receiver interpretation belongs to the getter owner. */ +const accessorEffectOperands = ( + method: t.Function, +): readonly AccessorEffectOperand[] => { + const cached = accessorOperands.get(method); + if (cached !== undefined) return cached; + const operands: AccessorEffectOperand[] = []; + const pending: t.Node[] = [method.body]; + const writeOnly = new WeakSet(); + while (pending.length > 0) { + const node = pending.pop()!; + // Defining a nested callable does not invoke it. + if (t.isFunction(node) || t.isClass(node)) continue; + const write = semanticWriteSiteTarget(node); + if (write !== null) { + for (const target of semanticWriteTargets(write)) { + if (!semanticWriteSiteReadsFirst(node)) writeOnly.add(target); + if (!t.isIdentifier(target)) + operands.push({ kind: "escape", node: target.object }); + } + } + if ( + (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) && + !writeOnly.has(node) + ) + operands.push({ + kind: "read", + node: node.object, + key: semanticStaticPropertyKey(node.property, node.computed), + }); + if ( + t.isCallExpression(node) || + t.isOptionalCallExpression(node) || + t.isNewExpression(node) + ) { + const callee = unwrapJavaScriptExpression(node.callee).node; + operands.push({ + kind: "invoke", + node: node.callee, + ...(t.isNewExpression(node) ? {} : { invocation: node }), + }); + if ( + !t.isNewExpression(node) && + (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) + ) + operands.push({ kind: "escape", node: callee.object }); + for (const argument of node.arguments) + operands.push({ + kind: "escape", + node: t.isSpreadElement(argument) ? argument.argument : argument, + }); + } else if (t.isTaggedTemplateExpression(node)) { + operands.push({ kind: "invoke", node: node.tag }); + const tag = unwrapJavaScriptExpression(node.tag).node; + if (t.isMemberExpression(tag) || t.isOptionalMemberExpression(tag)) + operands.push({ kind: "escape", node: tag.object }); + for (const expression of node.quasi.expressions) + operands.push({ kind: "escape", node: expression }); + } + pending.push(...childNodes(node).reverse()); } - return undefined; + accessorOperands.set(method, operands); + return operands; }; export function* collectSemanticMemberMutationsSteps( @@ -168,9 +268,97 @@ export function* collectSemanticMemberMutationsSteps( const deferredMapReceivers = new Set< t.CallExpression | t.OptionalCallExpression >(); + // The binding pass already retained callable nodes. Ordinary data-only code + // must not pay an alias walk for every member read or assignment. + const accessorKeys = { + get: new Set(), + set: new Set(), + }; + for (const node of state.callableNodesById.values()) + if (t.isObjectMethod(node) && (node.kind === "get" || node.kind === "set")) + accessorKeys[node.kind].add( + semanticStaticPropertyKey(node.key, node.computed), + ); + const accessorKeyMayExist = ( + key: PropertyPath[number] | undefined, + kind: "get" | "set", + ): boolean => { + const keys = accessorKeys[kind]; + return ( + keys.has(null) || + (key === null || typeof key === "object" + ? keys.size > 0 + : keys.has(String(key))) + ); + }; + const returnedReferences = createSemanticReturnedReferences(state); + const getterEffectPaths = ( + root: t.Node, + path: PropertyPath, + ): readonly PropertyPath[] => { + if (!accessorKeyMayExist(path.at(-1), "get")) return []; + const pending = [path]; + const examined = new Set(); + const effects: PropertyPath[] = []; + for (let cursor = 0; cursor < pending.length; cursor++) { + const selected = pending[cursor]!; + const identity = JSON.stringify(selected); + if (examined.has(identity)) continue; + examined.add(identity); + if (examined.size > SEMANTIC_ALIAS_SELECTION_LIMIT) { + state.valueResourceLimits.add("alias-selections"); + return [[]]; + } + const methods = localAccessorMethods( + root, + selected, + "get", + state, + accessorKeyMayExist, + ); + const receiver = selected.slice(0, -1); + if (methods === undefined) { + effects.push(receiver); + continue; + } + for (const method of methods) { + const callables: t.Function[] = [method]; + const visitedCallables = new Set(); + for (let index = 0; index < callables.length; index++) { + const callable = callables[index]!; + if (visitedCallables.has(callable)) continue; + visitedCallables.add(callable); + for (const operand of accessorEffectOperands(callable)) { + if (operand.kind === "invoke") { + for (const target of returnedReferences( + operand.node, + operand.invocation, + ).callables) + if (!target.generator) callables.push(target); + continue; + } + if ( + operand.kind === "read" && + !accessorKeyMayExist(operand.key, "get") + ) + continue; + for (const suffix of semanticGetterReceiverPaths( + method, + operand.node, + state, + )) { + const target = [...receiver, ...suffix]; + if (operand.kind === "escape") effects.push(target); + else pending.push([...target, operand.key]); + } + } + } + } + } + return effects; + }; const parents = state.parentsByNode; const observablePaths = observableMutationPaths(state); - const returnedReferences = createSemanticReturnedReferences(state); const recordedRequiredEffects = new Set(); let recordedEffectCount = 0; const recordRequiredEffect = ( @@ -513,15 +701,27 @@ export function* collectSemanticMemberMutationsSteps( current.originAt, parents, ); - // A setter on the binding's own literal runs on assignment and may + // A selected setter runs on assignment and may // replace any member of its receiver, so the written key is unknown. // Deleting the accessor never calls it. if ( effect === "write" && !t.isUnaryExpression(mutation, { operator: "delete" }) ) { - const receiver = localSetterReceiver(origins.initializers, path); - if (receiver !== undefined) path = [...receiver, null]; + const selectedPath = path; + if ( + origins.initializers.some( + (source) => + (localAccessorMethods( + source.node, + [...source.projection, ...selectedPath], + "set", + state, + accessorKeyMayExist, + )?.length ?? 1) > 0, + ) + ) + path = [...selectedPath.slice(0, -1), null]; } // Keep an antichain of exact paths. Widening distinct member keys would // also invalidate unrelated siblings that never reach this effect. @@ -888,8 +1088,13 @@ export function* collectSemanticMemberMutationsSteps( sourcesByTarget.get(target) ?? [], ); }; + const writeOnlyMembers = new WeakSet(); yield* traverseJavaScriptAstSteps(program, { enter: (node, parent) => { + const writeTarget = semanticWriteSiteTarget(node); + if (writeTarget !== null && !semanticWriteSiteReadsFirst(node)) + for (const target of semanticWriteTargets(writeTarget)) + writeOnlyMembers.add(target); for (const source of semanticIterationSources(node, parent)) deferIterable({ ...source, @@ -900,7 +1105,14 @@ export function* collectSemanticMemberMutationsSteps( originAt: source.mutation, iterationOnly: source.kind === "iteration", }); - const writeTarget = semanticWriteSiteTarget(node); + if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { + // The write owner classifies direct and destructuring stores once. + if (!writeOnlyMembers.has(node)) + for (const path of getterEffectPaths(node.object, [ + semanticStaticPropertyKey(node.property, node.computed), + ])) + markEscaped(node.object, node, path); + } if (writeTarget !== null) markTarget(writeTarget, node); else if ( t.isCallExpression(node) || @@ -929,6 +1141,8 @@ export function* collectSemanticMemberMutationsSteps( else if (!mapLookup) markReceiver(node.callee, node); } } else if (t.isTaggedTemplateExpression(node)) { + for (const source of returnedReferences(node.tag).references) + markEscaped(source.node, node, source.projection); markReceiver(node.tag, node); for (const expression of node.quasi.expressions) markEscaped(expression, node); diff --git a/src/domain/javascript/javascriptSemanticObjectReferences.ts b/src/domain/javascript/javascriptSemanticObjectReferences.ts index 0decdd0d15..b6c7c08d91 100644 --- a/src/domain/javascript/javascriptSemanticObjectReferences.ts +++ b/src/domain/javascript/javascriptSemanticObjectReferences.ts @@ -611,6 +611,20 @@ export const semanticObjectPropertyReferences = ( ); }; +/** Select accessor descriptors without invoking them or following their return values. */ +export const semanticObjectAccessorReferences = ( + node: t.ObjectExpression, + key: PropertyPath[number], + state: JavaScriptSemanticAnalysisState, + accessor: "get" | "set", +): readonly JavaScriptSemanticObjectReference[] => + new ObjectReferenceWalk( + node, + [key], + new SemanticGetterQuery(state), + accessor, + ).run(); + class ObjectReferenceWalk { readonly #references: JavaScriptSemanticObjectReference[] = []; readonly #overwritten = new Set(); @@ -625,6 +639,7 @@ class ObjectReferenceWalk { readonly node: t.ObjectExpression, path: PropertyPath, readonly query: SemanticGetterQuery, + readonly accessor?: "get" | "set", ) { [this.#key, ...this.#remaining] = path; } @@ -648,12 +663,14 @@ class ObjectReferenceWalk { const selected = this.#key ?? null; if (typeof selected !== "object" && this.#overwritten.has(String(selected))) return; - this.#references.push({ - node: property.argument, - path: [this.#spreadKey(), ...this.#remaining], - capturedSelectionAt: property.argument, - ownEnumerable: true, - }); + // Spread creates data properties; the source descriptor is not installed. + if (this.accessor === undefined) + this.#references.push({ + node: property.argument, + path: [this.#spreadKey(), ...this.#remaining], + capturedSelectionAt: property.argument, + ownEnumerable: true, + }); if ( selected !== null && typeof selected !== "object" && @@ -703,9 +720,11 @@ class ObjectReferenceWalk { name === null && this.#key != null && typeof this.#key !== "object" && - (t.isObjectMethod(property, { kind: "get" }) + (t.isObjectMethod(property) && property.kind === (this.accessor ?? "get") ? this.#data.has(String(this.#key)) || - this.#getters.has(String(this.#key)) + (this.accessor === "set" ? this.#setters : this.#getters).has( + String(this.#key), + ) : this.#overwritten.has(String(this.#key))) ) return; @@ -714,6 +733,11 @@ class ObjectReferenceWalk { !semanticPropertyPathKeyMatches(this.#key ?? null, name) ) return; + if (this.accessor !== undefined) { + if (t.isObjectMethod(property) && property.kind === this.accessor) + this.#references.push({ node: property, path: [] }); + return; + } if (t.isObjectMethod(property)) { if (property.kind === "get") this.query.schedule( diff --git a/src/domain/javascript/javascriptSemanticReturnedReferences.ts b/src/domain/javascript/javascriptSemanticReturnedReferences.ts index 47e3f4c977..c905b61f76 100644 --- a/src/domain/javascript/javascriptSemanticReturnedReferences.ts +++ b/src/domain/javascript/javascriptSemanticReturnedReferences.ts @@ -64,6 +64,8 @@ interface Candidate { export interface SemanticReturnedReferenceResult { readonly references: readonly SemanticReturnedReference[]; + /** Source-local callable targets resolved by the same binding/projection owner. */ + readonly callables: ReadonlySet; /** A visible invocation-method mutation prevents a source-local result proof. */ readonly unresolvedInvocation?: boolean; /** Both states close the supported source-local may-target graph. Widened joins projections without dropping represented targets; it does not claim runtime completeness. */ @@ -144,9 +146,10 @@ export const createSemanticReturnedReferences = ( let contextsWidened = false; const result: { references: readonly SemanticReturnedReference[]; + callables: ReadonlySet; precision: SemanticReturnedReferenceResult["precision"]; unresolvedInvocation?: boolean; - } = { references: [], precision: "exact" }; + } = { references: [], callables: functions, precision: "exact" }; // The invocation owner retains the actual callable receiver. A bare alias // of fn.call has lost that receiver and cannot execute fn by itself. const nativeArrayIteration = (array: t.ArrayExpression) => diff --git a/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts b/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts index d1f5321a0a..2ff8bb2fae 100644 --- a/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts +++ b/tests/boundary/filesystem/javascriptExportShapeComparison.test.ts @@ -86,6 +86,41 @@ describe("JavaScript export return-shape comparison", () => { ); }); + it("preserves accessor-effect uncertainty through analyzed Evidence and export comparison", async () => { + const [left, right] = await analyzeSources({ + left: `export default function observe() { + const box = { value: 1, get trigger() { this.value = 2; return 0; } }; + box.trigger; + return { type: "event", value: box.value, stable: "kept" }; + }`, + right: `export default function observe() { + return { type: "event", value: 2, stable: "kept" }; + }`, + }); + const execution = compareJavaScriptExportShapesEvidence({ + left: left.evidence, + right: right.evidence, + left_module_path: "parser.mjs", + left_export_name: "default", + right_module_path: "parser.mjs", + right_export_name: "default", + }); + if (!execution.ok) throw execution.error; + expect(execution.value.result.changes).toEqual([ + expect.objectContaining({ + status: "unknown", + path: "/value", + left: expect.objectContaining({ availability: "unknown" }), + right: { availability: "literal", value: 2 }, + }), + ]); + expect(execution.value.sources).toContain(left.evidence); + expect(execution.value.result.coverage.status).toBe("partial"); + expect(inventoryProperties(execution.value.result, "left", 0)).toEqual( + expect.arrayContaining(["/stable", "/value", "/type"]), + ); + }); + it("keeps additions unknown when spread coverage is incomplete", async () => { const [left, right] = await analyzeSources({ left: `