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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822
2026-10-14 21:39:39 -07:00
committed by GitHub
parent 0323ce0a28
commit acca91d22b
2 changed files with 194 additions and 0 deletions
@@ -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 },
}),
]),
});
});