From 6caa38a0cad369ddff52b08f8d7d44c1b92ecac0 Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:51:44 -0500 Subject: [PATCH] fix(javascript): keep mutated object initializer values unknown (#752) * fix(javascript): keep mutated object initializer values unknown Signed-off-by: Rudy Celekli * fix(javascript): retain copied slots while following nested mutations Signed-off-by: Rudy Celekli * fix(javascript): retain facts for unchanged object properties Signed-off-by: Rudy Celekli * docs(javascript): state untracked mutation channels Signed-off-by: Rudy Celekli --------- Signed-off-by: Rudy Celekli Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/javascript-application-workflows.md | 10 ++ ...tSemanticAnalysis.member_mutations.test.ts | 117 +++++++++++++++ src/domain/javascriptSemanticAnalysis.ts | 3 + .../javascriptSemanticMemberMutations.ts | 140 ++++++++++++++++++ .../javascriptSemanticMutationValues.ts | 65 ++++++++ src/domain/javascriptSemanticState.ts | 1 + src/domain/javascriptSemanticValues.ts | 5 +- 7 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 src/domain/javascriptSemanticAnalysis.member_mutations.test.ts create mode 100644 src/domain/javascriptSemanticMemberMutations.ts create mode 100644 src/domain/javascriptSemanticMutationValues.ts diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 8bcd6ce1b..3821ccbee 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -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 diff --git a/src/domain/javascriptSemanticAnalysis.member_mutations.test.ts b/src/domain/javascriptSemanticAnalysis.member_mutations.test.ts new file mode 100644 index 000000000..8dda46cf9 --- /dev/null +++ b/src/domain/javascriptSemanticAnalysis.member_mutations.test.ts @@ -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"); + }); +}); diff --git a/src/domain/javascriptSemanticAnalysis.ts b/src/domain/javascriptSemanticAnalysis.ts index 5370ac051..d73355433 100644 --- a/src/domain/javascriptSemanticAnalysis.ts +++ b/src/domain/javascriptSemanticAnalysis.ts @@ -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: [], diff --git a/src/domain/javascriptSemanticMemberMutations.ts b/src/domain/javascriptSemanticMemberMutations.ts new file mode 100644 index 000000000..495b779f9 --- /dev/null +++ b/src/domain/javascriptSemanticMemberMutations.ts @@ -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, + ): 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((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((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; +}; diff --git a/src/domain/javascriptSemanticMutationValues.ts b/src/domain/javascriptSemanticMutationValues.ts new file mode 100644 index 000000000..f4876658d --- /dev/null +++ b/src/domain/javascriptSemanticMutationValues.ts @@ -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; +}; diff --git a/src/domain/javascriptSemanticState.ts b/src/domain/javascriptSemanticState.ts index 4ed7ead40..7f17308b2 100644 --- a/src/domain/javascriptSemanticState.ts +++ b/src/domain/javascriptSemanticState.ts @@ -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[]; diff --git a/src/domain/javascriptSemanticValues.ts b/src/domain/javascriptSemanticValues.ts index 05c1f66cd..41085aab3 100644 --- a/src/domain/javascriptSemanticValues.ts +++ b/src/domain/javascriptSemanticValues.ts @@ -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 = (