From acca91d22b3406ee87a2aed17cb9f02de0f83185 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 21:39:39 -0700 Subject: [PATCH] fix(javascript): keep members unknown after a write through a local setter (#3831) * fix(javascript): keep members unknown after a write through a local setter Assigning to a key that a setter of the binding's own object literal defines runs that setter, which may replace any member of its receiver. The member-mutation collector recorded it as an ordinary write to that one key, so sibling literals such as value: 1 stayed known and export-shape comparisons reported complete, unchanged shapes. Record such a write with an unknown key, which invalidates the object's known members. A computed setter key may match any written key. Fixes #3312 Co-Authored-By: Claude Opus 5.5 * fix(javascript): follow nested setters, skip deletes and cache setter keys Resolve the written receiver along nested object-literal members so a write through a nested setter widens that receiver's members. A delete never calls a setter, so it keeps its exact path. Setter keys are computed once per object literal, including the no-setter result. Co-Authored-By: Claude Opus 5.5 * fix(javascript): ignore setters replaced by a later data property Build cached setter keys from the effective definitions: a later data property or method with the same static name replaces the accessor, so assigning that key no longer runs a setter. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- ...ptSemanticAnalysis.accessor_writes.test.ts | 108 ++++++++++++++++++ .../javascriptSemanticMemberMutations.ts | 86 ++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 src/domain/javascript/javascriptSemanticAnalysis.accessor_writes.test.ts diff --git a/src/domain/javascript/javascriptSemanticAnalysis.accessor_writes.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.accessor_writes.test.ts new file mode 100644 index 0000000000..1b52c87fa7 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAnalysis.accessor_writes.test.ts @@ -0,0 +1,108 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +const returned = (source: string) => { + const ir = analyzeJavaScriptSemantics(source); + const callable = ir.callables.find(({ name }) => name === "observe"); + return callable?.returnSites[0]?.value; +}; + +it("keeps members unknown after a write through a local setter", () => { + // The setter runs on assignment and may replace any member of `this`. + expect( + returned(` + export function observe() { + const state = { value: 1, set update(next) { this.value = next; } }; + state.update = 2; + return state; + } + `), + ).toMatchObject({ status: "unknown" }); +}); + +it("keeps sibling literals when the written key is an ordinary property", () => { + expect( + returned(` + export function observe() { + const state = { value: 1, update: 0 }; + state.update = 2; + return state; + } + `), + ).toMatchObject({ + status: "object", + properties: expect.arrayContaining([ + expect.objectContaining({ + name: "value", + value: { status: "literal", value: 1 }, + }), + ]), + }); +}); + +it("keeps nested members unknown after a write through a nested setter", () => { + const value = returned(` + export function observe() { + const state = { + label: "kept", + nested: { value: 1, set update(next) { this.value = next; } }, + }; + state.nested.update = 2; + return state; + } + `); + expect(value).toMatchObject({ + status: "object", + properties: expect.arrayContaining([ + expect.objectContaining({ + name: "label", + value: { status: "literal", value: "kept" }, + }), + expect.objectContaining({ + name: "nested", + value: expect.objectContaining({ status: "unknown" }), + }), + ]), + }); +}); + +it("keeps sibling literals when an accessor is deleted rather than assigned", () => { + expect( + returned(` + export function observe() { + const state = { value: 1, set update(next) {} }; + delete state.update; + return state; + } + `), + ).toMatchObject({ + status: "object", + properties: expect.arrayContaining([ + expect.objectContaining({ + name: "value", + value: { status: "literal", value: 1 }, + }), + ]), + }); +}); + +it("keeps sibling literals when a later data property replaces the setter", () => { + expect( + returned(` + export function observe() { + const state = { value: 1, set update(next) {}, update: 0 }; + state.update = 2; + return state; + } + `), + ).toMatchObject({ + status: "object", + properties: expect.arrayContaining([ + expect.objectContaining({ + name: "value", + value: { status: "literal", value: 1 }, + }), + ]), + }); +}); diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 24535f91eb..ce7310e367 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -53,6 +53,82 @@ type PropertyPath = JavaScriptSemanticPropertyPath; type ValueEffect = "write" | "escape"; /** 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); + } else if ( + name !== null && + !(t.isObjectMethod(property) && property.kind === "get") + ) + names.delete(name); + } + const keys = names.size === 0 && !computed ? null : { names, computed }; + setterKeysByObject.set(object, keys); + return keys; +}; + +/** + * 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; + } + if (!t.isObjectExpression(object)) continue; + const keys = setterKeys(object); + if (keys !== null && (keys.computed || keys.names.has(key))) + return receiver as readonly string[]; + } + return undefined; +}; + export function* collectSemanticMemberMutationsSteps( program: t.Program, state: JavaScriptSemanticAnalysisState, @@ -377,6 +453,16 @@ export function* collectSemanticMemberMutationsSteps( current.originAt, parents, ); + // A setter on the binding's own literal 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]; + } // Keep an antichain of exact paths. Widening distinct member keys would // also invalidate unrelated siblings that never reach this effect. // This map is local to the file scan. Escaping the binding id keeps a