fix(javascript): preserve getter results on direct property writes

Replacing or deleting a class getter property does not mutate the object it would return. Apply the existing object-accessor write boundary to instance and static getters.

Validation: source mutation regressions and compiled CLI/stdio MCP acceptance preserve literal 1 for all four assignment/deletion cases; check:fast passes.
This commit is contained in:
morluto
2026-10-11 08:15:47 +08:00
parent bb074d84cb
commit 639b73097b
3 changed files with 35 additions and 0 deletions
@@ -304,6 +304,24 @@ describe("JavaScript semantic mutations through getter results (#1494)", () => {
),
).toEqual({ status: "literal", value: 1 });
});
it("keeps a shared value known after an optional getter read or ordinary method read", () => {
expect(
resultValue("const shared = { value: 1 }; const box = { get value() { return shared; } }; const alias = box?.value; return shared.value;"),
).toEqual({ status: "literal", value: 1 });
expect(
resultValue("const shared = { value: 1 }; const box = { value() { return shared; } }; const alias = box.value; return shared.value;"),
).toEqual({ status: "literal", value: 1 });
});
});
it.each([
["instance getter assignment", "const shared = { value: 1 }; class Box { get value() { return shared; } } const box = new Box(); box.value = {}; return shared.value;"],
["instance getter deletion", "const shared = { value: 1 }; class Box { get value() { return shared; } } const box = new Box(); delete box.value; return shared.value;"],
["static getter assignment", "const shared = { value: 1 }; class Box { static get value() { return shared; } } Box.value = {}; return shared.value;"],
["static getter deletion", "const shared = { value: 1 }; class Box { static get value() { return shared; } } delete Box.value; return shared.value;"],
])("does not follow a class getter for direct property replacement or deletion (%s)", (_name, body) => {
expect(resultValue(body)).toEqual({ status: "literal", value: 1 });
});
describe("JavaScript semantic values after mutable references escape", () => {
@@ -635,6 +635,9 @@ const referencedValues = (
if (t.isClass(node)) {
const [key, ...remaining] = path;
if (key === undefined) return [];
// 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),
@@ -646,6 +649,7 @@ const referencedValues = (
if (t.isNewExpression(node)) {
const [key, ...remaining] = path;
if (key === undefined) return [];
if (effect === "write" && remaining.length === 0) return [];
return classCandidates(node.callee, state).flatMap((klass) =>
classGetterResults(
klass,
@@ -77,6 +77,10 @@ const source = [
"export function computedClassGetterOverride(){ const shared = {x: 1}; const key = getKey(); class Box { get value(){return {x: 2};} get [key](){return shared;} } new Box().value.x = 3; return shared.x; }",
"export function aliasedClassGetter(){ const shared = {x: 1}; class Box { get value(){return shared;} } const Alias = Box; new Alias().value.x = 2; return shared.x; }",
"export function inlineClassGetter(){ const shared = {x: 1}; new (class { get value(){return shared;} })().value.x = 2; return shared.x; }",
"export function instanceGetterAssignment(){ const shared = {x: 1}; class Box { get value(){return shared;} } const box = new Box(); try { box.value = {}; } catch {} return shared.x; }",
"export function instanceGetterDeletion(){ const shared = {x: 1}; class Box { get value(){return shared;} } const box = new Box(); delete box.value; return shared.x; }",
"export function staticGetterAssignment(){ const shared = {x: 1}; class Box { static get value(){return shared;} } try { Box.value = {}; } catch {} return shared.x; }",
"export function staticGetterDeletion(){ const shared = {x: 1}; class Box { static get value(){return shared;} } delete Box.value; return shared.x; }",
"export function freshGetterRead(){ const box = {get value(){return {x: 1};}}; const first = box.value; first.x = 2; return box.value.x; }",
"export function savedFreshGetter(){ const box = {get value(){return {x: 1};}}; const saved = box.value; saved.x = 2; return saved.x; }",
"export function directSibling(){ const shared = {x: 1}; const parent = {shared, keep: 7}; const get = () => parent.shared; get().x = 2; return {x: shared.x, keep: parent.keep}; }",
@@ -114,6 +118,15 @@ const assertReturns = (fields: JavaScriptReturnFields): void => {
expect(fields(name)).toContainEqual(
expect.objectContaining({ path: "", state: "literal", value: 1 }),
);
for (const name of [
"instanceGetterAssignment",
"instanceGetterDeletion",
"staticGetterAssignment",
"staticGetterDeletion",
])
expect(fields(name), name).toContainEqual(
expect.objectContaining({ path: "", state: "literal", value: 1 }),
);
for (const name of [
"inheritedGetter",
"aliasedClassGetter",