fix(javascript): keep mutated object initializer values unknown (#752)

* fix(javascript): keep mutated object initializer values unknown

Signed-off-by: Rudy Celekli <rudy@gradiahq.com>

* fix(javascript): retain copied slots while following nested mutations

Signed-off-by: Rudy Celekli <rudy@gradiahq.com>

* fix(javascript): retain facts for unchanged object properties

Signed-off-by: Rudy Celekli <rudy@gradiahq.com>

* docs(javascript): state untracked mutation channels

Signed-off-by: Rudy Celekli <rudy@gradiahq.com>

---------

Signed-off-by: Rudy Celekli <rudy@gradiahq.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
Rudy Mizrahi Celekli 2026-10-06 21:51:44 -05:00 committed by GitHub
parent 0374865836
commit 6caa38a0ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 340 additions and 1 deletions

View File

@ -55,6 +55,16 @@ registrations/removals and dispatch, Node timers and cancellation handles, async
defaults, request construction and response consumers, parse/coercion/
validation boundaries, and built-in resource acquisition/release.
Mutation tracking covers explicit member assignments, updates, deletion, and
loop assignment targets, including the supported local aliases and shared
nested references. Calls such as `Object.assign`, `Reflect.set`, and
`Reflect.deleteProperty`, and writes reaching a caller's object through another
function's parameter, are not tracked by this mutation pass. These channels
can leave an initializer-derived literal in the graph even after the runtime
value changes. Treat such a result as an uncovered mutation channel, not as
proof of the current runtime value; a follow-up needs to model that channel
and verify its caller/alias behavior.
Function fingerprints commit normalized syntax, control-flow shape, relation
shape, literal sets, arity, and detected effects without using local names or
source offsets. Equal duplicate fingerprints remain ambiguous. Dynamic

View File

@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js";
const resultValue = (body: string) =>
onlyCallable(
analyzeJavaScriptSemantics(`export function result() { ${body} }`),
"result",
).returnSites[0]?.value;
const mutations = [
'const shared = { mode: "initial" }; const options = { [key]: shared }; options.foo.mode = "updated"; return shared.mode;',
'const shared = { mode: "initial" }; const original = [shared]; const copy = [...original]; copy[0].mode = "updated"; return shared.mode;',
'const original = { nested: { mode: "initial" } }; const copy = { ...original }; copy.nested.mode = "updated"; return original.nested.mode;',
'const shared = { mode: "initial" }; const options = flag ? shared : { mode: "other" }; options.mode = "updated"; return shared.mode;',
'const child = { mode: "initial" }; const options = { layer: { child } }; options.layer.child.mode = "updated"; return child.mode;',
'const options = { mode: "initial" }; options.mode = "updated"; return options.mode;',
"const options = { count: 1 }; options.count += 1; return options.count;",
"const options = { count: 1 }; options.count++; return options.count;",
'const options = { mode: "initial" }; delete options.mode; return options.mode;',
'const options = ["initial"]; options[0] = "updated"; return options[0];',
'const options = { nested: { mode: "initial" } }; options.nested.mode = "updated"; return options.nested.mode;',
'const options = { mode: "initial" }; const alias = options; alias.mode = "updated"; return options.mode;',
'const options = { nested: { mode: "initial" } }; const { nested } = options; nested.mode = "updated"; return options.nested.mode;',
'const nested = { mode: "initial" }; const options = { nested }; options.nested.mode = "updated"; return nested.mode;',
'const options = { mode: "initial" }; [options.mode] = ["updated"]; return options.mode;',
'const options = { mode: "initial" }; for (options.mode of ["updated"]) {} return options.mode;',
'const options = { mode: "initial" }; const key = getKey(); options[key] = "updated"; return options.mode;',
];
describe("JavaScript semantic values after explicit property mutations", () => {
it.each(mutations)("keeps the mutated value unknown: %s", (body) => {
expect(resultValue(body)?.status).toBe("unknown");
});
it("keeps an unrelated shadowed object literal known", () => {
expect(
resultValue(`
const options = { mode: "initial" };
{ const options = { mode: "inner" }; options.mode = "updated"; }
return options.mode;
`),
).toEqual({ status: "literal", value: "initial" });
});
it("keeps scalar slots in a spread source known after a copy slot write", () => {
expect(
resultValue(`
const original = { mode: "initial" };
const copy = { ...original };
copy.mode = "updated";
return original.mode;
`),
).toEqual({ status: "literal", value: "initial" });
});
it("keeps conditional object branches unknown after a property write", () => {
expect(
resultValue(`
const options = flag ? { mode: "initial" } : { mode: "other" };
options.mode = "updated";
return options.mode;
`)?.status,
).toBe("unknown");
});
it("keeps a copied primitive initializer known", () => {
expect(
resultValue(`
const mode = "initial";
const options = { mode };
options.mode = "updated";
return mode;
`),
).toEqual({ status: "literal", value: "initial" });
});
it("retains ordinary immutable object and array projection", () => {
expect(
resultValue(
'const options = { mode: ["initial"] }; return options.mode[0];',
),
).toEqual({ status: "literal", value: "initial" });
});
});
const unchangedProperties = [
'const source = { token: "TOKEN", count: 1 }; source.count = 2; return source.token;',
'const source = { token: "TOKEN", count: 1 }; const alias = source; alias.count++; return source.token;',
'const source = { token: "TOKEN", count: 1 }; delete source.count; return source.token;',
'const source = { nested: { token: "TOKEN", count: 1 } }; source.nested.count = 2; return source.nested.token;',
'const source = { nested: { token: "TOKEN", count: 1 } }; const { nested } = source; nested.count = 2; return source.nested.token;',
'const child = { token: "TOKEN", count: 1 }; const source = { child }; source.child.count = 2; return child.token;',
'const source = ["TOKEN", 1]; source[1] = 2; return source[0];',
'const child = { token: "TOKEN", count: 1 }; const source = [child]; source[0].count = 2; return child.token;',
'const source = { token: "TOKEN" }; source.added = 2; return source.token;',
'const source = { token: "TOKEN", count: 1, other: 1 }; source.count = 2; source.other = 2; return source.token;',
'const left = { token: "TOKEN", count: 1 }; const right = { count: 1 }; const source = { left, right }; source.right.count = 2; return left.count + ":" + left.token;',
];
describe("JavaScript semantic values for properties unaffected by a mutation", () => {
it.each(unchangedProperties)("retains an unaffected property: %s", (body) => {
expect(resultValue(body)).toEqual({
status: "literal",
value: body.includes("left.count") ? "1:TOKEN" : "TOKEN",
});
});
it("keeps the changed slot unknown alongside an unchanged literal slot", () => {
expect(
resultValue(
'const source = { token: "TOKEN", count: 1 }; source.count = 2; return source.count;',
)?.status,
).toBe("unknown");
});
});

View File

@ -26,6 +26,7 @@ import {
semanticVariableScope,
} from "./javascriptSemanticState.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { collectSemanticMemberMutations } from "./javascriptSemanticMemberMutations.js";
import {
collectSemanticReturns,
resolveSemanticModuleCallables,
@ -76,6 +77,7 @@ export const analyzeParsedJavaScriptSemantics = (
): JavaScriptSemanticIr => {
const state = createState(file.program);
collectDefinitions(file.program, state);
collectSemanticMemberMutations(file.program, state);
traverseJavaScriptAst(file.program, {
enter: (node) => collectSemanticModuleLink(node, state),
});
@ -537,6 +539,7 @@ const createBinding = (
name,
kind,
mutable,
mutatedPaths: [],
definitions: [],
initializers: [],
directOrigins: [],

View File

@ -0,0 +1,140 @@
import * as t from "@babel/types";
import {
resolveSemanticBindingState,
type JavaScriptSemanticAnalysisState,
} from "./javascriptSemanticState.js";
import { evaluateSemanticBinding } from "./javascriptSemanticValues.js";
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
import { semanticStaticPropertyName } from "./javascriptAstValues.js";
type PropertyPath = readonly (string | number | null)[];
/** Keep explicit property mutations outside the initializer-only value lattice. */
export const collectSemanticMemberMutations = (
program: t.Program,
state: JavaScriptSemanticAnalysisState,
): void => {
const markValue = (
node: t.Node,
path: PropertyPath,
bindings: ReadonlySet<string>,
): void => {
if (t.isIdentifier(node)) {
const binding = resolveSemanticBindingState(state, node, node.name);
if (binding === undefined || bindings.has(binding.bindingId)) return;
const value = evaluateSemanticBinding(binding, state);
if (value.status === "literal" || value.status === "union") return;
binding.mutatedPaths.push(path);
const nested = new Set([...bindings, binding.bindingId]);
for (const initializer of binding.initializers)
markValue(
initializer.node,
[...initializer.projection, ...path],
nested,
);
return;
}
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
markValue(node.object, [propertyKey(node), ...path], bindings);
return;
}
for (const value of referencedValues(node, path))
markValue(value.node, value.path, bindings);
};
const markTarget = (node: t.Node): void => {
if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node))
markValue(node.object, [propertyKey(node)], new Set());
else if (t.isRestElement(node)) markTarget(node.argument);
else if (t.isAssignmentPattern(node)) markTarget(node.left);
else if (t.isArrayPattern(node)) {
for (const element of node.elements)
if (element !== null) markTarget(element);
} else if (t.isObjectPattern(node)) {
for (const property of node.properties)
markTarget(
t.isRestElement(property) ? property.argument : property.value,
);
}
};
traverseJavaScriptAst(program, {
enter: (node) => {
if (t.isAssignmentExpression(node)) markTarget(node.left);
else if (t.isUpdateExpression(node)) markTarget(node.argument);
else if (t.isUnaryExpression(node, { operator: "delete" }))
markTarget(node.argument);
else if (t.isForOfStatement(node) || t.isForInStatement(node))
markTarget(node.left);
},
});
};
interface ReferencedValue {
readonly node: t.Node;
readonly path: PropertyPath;
}
const referencedValues = (
node: t.Node,
path: PropertyPath,
): readonly ReferencedValue[] => {
const [key, ...remaining] = path;
// An initializer owns its slots; only deeper writes can affect shared children.
if (t.isObjectExpression(node))
return path.length < 2
? []
: node.properties.flatMap<ReferencedValue>((property) => {
if (t.isSpreadElement(property))
return [{ node: property.argument, path }];
if (!t.isObjectProperty(property)) return [];
const name = semanticStaticPropertyName(
property.key,
property.computed,
);
return key === null ||
(name === "" && !t.isStringLiteral(property.key, { value: "" })) ||
String(key) === name
? [{ node: property.value, path: remaining }]
: [];
});
if (t.isArrayExpression(node)) {
if (path.length < 2) return [];
let uncertainIndex = false;
return node.elements.flatMap<ReferencedValue>((element, index) => {
if (element === null) return [];
if (t.isSpreadElement(element)) {
uncertainIndex = true;
return [{ node: element.argument, path: [null, ...remaining] }];
}
return uncertainIndex || key === null || String(key) === String(index)
? [{ node: element, path: remaining }]
: [];
});
}
if (
t.isTSAsExpression(node) ||
t.isTSTypeAssertion(node) ||
t.isTSNonNullExpression(node)
)
return [{ node: node.expression, path }];
if (t.isConditionalExpression(node))
return [
{ node: node.consequent, path },
{ node: node.alternate, path },
];
if (t.isLogicalExpression(node))
return [
{ node: node.left, path },
{ node: node.right, path },
];
return [];
};
const propertyKey = (
node: t.MemberExpression | t.OptionalMemberExpression,
): string | null => {
const name = semanticStaticPropertyName(node.property, node.computed);
return name !== "" || t.isStringLiteral(node.property, { value: "" })
? name
: null;
};

View File

@ -0,0 +1,65 @@
import type { JavaScriptSemanticValue } from "./javascriptSemanticIr.js";
/** Invalidate only slots that an explicit mutation can affect. */
export const invalidateSemanticMutationPath = (
value: JavaScriptSemanticValue,
path: readonly (string | number | null)[],
): JavaScriptSemanticValue => {
const [key, ...remaining] = path;
const unknown: JavaScriptSemanticValue = {
status: "unknown",
reason: "This property may have been mutated.",
};
if (key === undefined || key === null) return unknown;
if (value.status === "object") {
const name = String(key);
const observed = value.properties.some(
(property) => property.name === name,
);
const properties = value.properties.map((property) =>
property.name === name
? {
...property,
value: invalidateSemanticMutationPath(property.value, remaining),
}
: property,
);
return value.unknownProperties || !observed
? {
status: "object",
properties,
unknownProperties: true,
omittedProperties: observed ? value.omittedProperties : null,
}
: {
status: "object",
properties,
unknownProperties: false,
omittedProperties: 0,
};
}
if (value.status === "array") {
const index = typeof key === "number" ? key : Number(key);
if (
!Number.isSafeInteger(index) ||
index < 0 ||
String(index) !== String(key)
)
return unknown;
const observed = value.items[index] !== undefined;
const items = value.items.map((item, position) =>
position === index
? invalidateSemanticMutationPath(item, remaining)
: item,
);
return value.unknownItems || !observed
? {
status: "array",
items,
unknownItems: true,
omittedItems: observed ? value.omittedItems : null,
}
: { status: "array", items, unknownItems: false, omittedItems: 0 };
}
return unknown;
};

View File

@ -21,6 +21,7 @@ export interface JavaScriptSemanticBindingState {
readonly name: string;
kind: JavaScriptSemanticDefinition["kind"];
mutable: boolean;
readonly mutatedPaths: (readonly (string | number | null)[])[];
readonly definitions: JavaScriptSemanticDefinition[];
readonly initializers: JavaScriptSemanticInitializer[];
readonly directOrigins: JavaScriptModuleOrigin[];

View File

@ -1,5 +1,7 @@
import * as t from "@babel/types";
import { invalidateSemanticMutationPath } from "./javascriptSemanticMutationValues.js";
import type {
JavaScriptBindingProvenance,
JavaScriptModuleOrigin,
@ -74,10 +76,11 @@ const evaluateBinding = (
if (initializer === undefined)
return { status: "unknown", reason: "Missing binding initializer." };
const nested = nestedContext(context, binding.bindingId);
return projectValue(
const value = projectValue(
evaluateExpression(initializer.node, nested),
initializer.projection,
);
return binding.mutatedPaths.reduce(invalidateSemanticMutationPath, value);
};
const evaluateExpression = (