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
@@ -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<string>;
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<t.ObjectExpression, SetterKeys | null>();
const setterKeys = (object: t.ObjectExpression): SetterKeys | null => {
const cached = setterKeysByObject.get(object);
if (cached !== undefined) return cached;
const names = new Set<string>();
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