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:
parent
0374865836
commit
6caa38a0ca
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
Loading…
Reference in New Issue