diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts index c8a63e944..af74de616 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts @@ -838,6 +838,85 @@ it("retains array length presence without inventing an exact length", () => { ).toBe(false); }); +it.each([ + ["object spread", 'const root = { known: "value", ...dynamic };'], + ["array spread", 'const root = ["value", ...dynamic];'], +])( + "keeps omitted initializer coverage on the owning binding: %s", + (_label, source) => { + const graph = graphFor(source); + const root = graph.nodes.find( + ({ kind, label }) => kind === "binding" && label === "root", + ); + if (root === undefined) throw new Error("Expected root binding"); + const result = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "semantic-node", node_id: root.node_id }, + direction: "forward-influence", + }); + + expect(result.coverage.status).toBe("partial"); + expect(result.unknowns).toContainEqual( + expect.objectContaining({ + node_id: root.node_id, + family: "object-flow", + reason: "ambiguous-target", + detail: expect.stringContaining("Initializer container has unknown"), + }), + ); + }, +); + +it.each([ + ["bare if", 'if (flag) alias = { mode: "other" }; alias.mode = "updated";'], + [ + "logical expression", + 'flag && (alias = { mode: "other" }); alias.mode = "updated";', + ], + [ + "logical assignment", + 'flag ||= (alias = { mode: "other" }); alias.mode = "updated";', + ], + ["or assignment", 'alias ||= { mode: "other" }; alias.mode = "updated";'], + [ + "nullish assignment", + 'alias ??= { mode: "other" }; alias.mode = "updated";', + ], + [ + "conditional mutation", + 'alias = { mode: "other" }; if (flag) alias.mode = "updated";', + ], + [ + "short-circuit mutation", + 'alias = { mode: "other" }; flag && (alias.mode = "updated");', + ], +])( + "preserves possible alias mutation uncertainty in queries: %s", + (_label, body) => { + const graph = graphFor(` + const shared = { mode: "initial" }; + let alias = shared; + ${body} + `); + const sharedMode = graph.nodes.find( + ({ kind, identity, properties }) => + kind === "property-slot" && + properties.property_pointer === "/mode" && + identity.role_key.includes("binding:shared"), + ); + if (sharedMode === undefined) throw new Error("Expected shared mode slot"); + const result = queryJavaScriptSemanticGraph(graph, { + seed: { kind: "semantic-node", node_id: sharedMode.node_id }, + direction: "backward-provenance", + }); + + expect(sharedMode.properties.presence).toBe("unknown-coverage"); + expect(result.nodes).toContainEqual( + expect.objectContaining({ node_id: sharedMode.node_id }), + ); + expect(result.coverage.status).toBe("partial"); + }, +); + it.each(["{}", "unknownRoot", "{ a: null }"])( "preserves the requested deep leaf name after a blocked prefix: %s", (initializer) => { diff --git a/src/application/javascript/JavaScriptSemanticGraphEvidence.ts b/src/application/javascript/JavaScriptSemanticGraphEvidence.ts index 4b339ec06..d80201593 100644 --- a/src/application/javascript/JavaScriptSemanticGraphEvidence.ts +++ b/src/application/javascript/JavaScriptSemanticGraphEvidence.ts @@ -1,5 +1,5 @@ import type { ApplicationGraphEvidence } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js"; -import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraph.js"; +import type { JavaScriptSemanticGraphNode } from "../../domain/javascript/javascriptSemanticGraphSchemas.js"; import type { JavaScriptSourceRange } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; @@ -101,7 +101,7 @@ export const unavailableSemanticRootEvidence = ( /** Explicit unknown evidence at one unresolved dynamic syntax location. */ export const unknownSemanticEvidence = ( file: JavaScriptArtifactFile, - location: JavaScriptSourceRange, + location: JavaScriptSourceRange | null, ): ApplicationGraphEvidence => ({ ...observedSemanticEvidence(file, location), authority: "unknown", diff --git a/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts b/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts index dc3c72d5f..bb2bbd520 100644 --- a/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts +++ b/src/application/javascript/JavaScriptSemanticGraphValueProjection.ts @@ -3,10 +3,8 @@ import { semanticPropertyPointer, type JavaScriptSemanticSlot, } from "../../domain/javascript/javascriptSemanticSlots.js"; -import type { - JavaScriptSemanticBinding, - JavaScriptSemanticValue, -} from "../../domain/javascript/javascriptSemanticIr.js"; +import type { JavaScriptSemanticBinding } from "../../domain/javascript/javascriptSemanticIr.js"; +import type { JavaScriptSemanticValue } from "../../domain/javascript/javascriptSemanticValueTypes.js"; import { createJavaScriptSemanticGraphUnknown } from "../../domain/javascript/javascriptSemanticGraph.js"; import { retainSemanticGraphNode, @@ -14,7 +12,10 @@ import { addSemanticGraphUnknown, } from "./JavaScriptSemanticGraphConstruction.js"; import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js"; -import { observedSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js"; +import { + observedSemanticEvidence, + unknownSemanticEvidence, +} from "./JavaScriptSemanticGraphEvidence.js"; /** Project bounded literal values and object slots for exact query seeds. */ export const projectSemanticValues = ( @@ -90,6 +91,24 @@ const projectValue = (input: ValueProjectionInput): void => { path: propertyPath, }); } + if (container.coverage.status === "partial") + addSemanticGraphUnknown( + context.state, + createJavaScriptSemanticGraphUnknown({ + node_id: target.node_id, + family: "object-flow", + relation_kinds: ["writes-property"], + reason: "ambiguous-target", + detail: `Initializer container has unknown ${ + value.status === "object" ? "properties" : "items" + }${container.coverage.omitted === null ? " with an unknown omitted count" : `; ${container.coverage.omitted} omitted`}.`, + candidate_node_ids: [target.node_id], + evidence: unknownSemanticEvidence( + context.file, + binding.definitions[0]?.location ?? null, + ), + }), + ); } else if (value.status === "unknown" && value.resourceLimit !== undefined) { const location = binding.definitions[0]?.location ?? null; const evidence = observedSemanticEvidence(context.file, location); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts index 55ce3705a..15738d5b4 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts @@ -209,6 +209,17 @@ describe("JavaScript semantic analysis: read-modify-write", () => { "read", ]); }); + + it("does not model a compound binding write as a competing replacement", () => { + const ir = analyzeJavaScriptSemantics(` + let count = 1; + count += 2; + `); + + expect(topLevelBinding(ir, "count").value).toMatchObject({ + status: "unknown", + }); + }); }); describe("JavaScript semantic analysis: dataflow 2", () => { @@ -448,21 +459,36 @@ describe("nonfinite static values", () => { describe("binding write invalidation", () => { it.each([ - "let value = 1; value++; const observed = value;", - "let value = 1; --value; const observed = value;", - 'let value = "old"; [value] = ["new"]; const observed = value;', - 'let value = "old"; ({key: value} = {key: "new"}); const observed = value;', - 'let value = "old"; for (value of ["new"]) {} const observed = value;', - 'let value = "old"; for (value in {new: true}) {} const observed = value;', - ])("does not retain an exact initializer across %s", (source) => { - const ir = analyzeJavaScriptSemantics(source); - expect(topLevelBinding(ir, "observed").value.status).toBe("ambiguous"); - expect( - topLevelBinding(ir, "value").definitions.some( - ({ kind }) => kind === "assignment", - ), - ).toBe(true); - }); + ["let value = 1; value++; const observed = value;", "unknown"], + ["let value = 1; --value; const observed = value;", "unknown"], + [ + 'let value = "old"; [value] = ["new"]; const observed = value;', + "ambiguous", + ], + [ + 'let value = "old"; ({key: value} = {key: "new"}); const observed = value;', + "ambiguous", + ], + [ + 'let value = "old"; for (value of ["new"]) {} const observed = value;', + "ambiguous", + ], + [ + 'let value = "old"; for (value in {new: true}) {} const observed = value;', + "ambiguous", + ], + ] as const)( + "does not retain an exact initializer across %s", + (source, status) => { + const ir = analyzeJavaScriptSemantics(source); + expect(topLevelBinding(ir, "observed").value.status).toBe(status); + expect( + topLevelBinding(ir, "value").definitions.some( + ({ kind }) => kind === "assignment", + ), + ).toBe(true); + }, + ); it("does not treat destructuring keys or member owners as assigned bindings", () => { const ir = analyzeJavaScriptSemantics(` diff --git a/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts index c0d3066a3..ac9d0de8a 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.member_mutations.test.ts @@ -76,6 +76,85 @@ describe("JavaScript semantic values after explicit property mutations", () => { ).toEqual({ status: "literal", value: "initial" }); }); + it("does not propagate writes through an alias after an unconditional rebind", () => { + expect( + resultValue(` + const shared = { mode: "initial" }; + let alias = shared; + alias = { mode: "other" }; + alias.mode = "updated"; + return shared.mode; + `), + ).toEqual({ status: "literal", value: "initial" }); + }); + + it.each([ + 'if (flag) alias = { mode: "other" }; alias.mode = "updated";', + 'flag && (alias = { mode: "other" }); alias.mode = "updated";', + 'flag ||= (alias = { mode: "other" }); alias.mode = "updated";', + 'alias ||= { mode: "other" }; alias.mode = "updated";', + 'alias ??= { mode: "other" }; alias.mode = "updated";', + ])( + "keeps possible source aliases unknown across conditional rebinds", + (body) => { + expect( + resultValue(` + const shared = { mode: "initial" }; + let alias = shared; + ${body} + return shared.mode; + `)?.status, + ).toBe("unknown"); + }, + ); + + it("keeps mutation under a bare conditional unknown across a rebind", () => { + expect( + resultValue(` + const shared = { mode: "initial" }; + let alias = shared; + alias = { mode: "other" }; + if (flag) alias.mode = "updated"; + return shared.mode; + `)?.status, + ).toBe("unknown"); + }); + + it("does not treat a short-circuit assignment RHS as an unconditional value", () => { + expect( + resultValue(` + let value; + value &&= 2; + return value; + `)?.status, + ).toBe("unknown"); + }); + + it("keeps possible alias mutations conservative across short-circuit assignment", () => { + expect( + resultValue(` + const shared = { mode: "initial" }; + let alias = shared; + alias &&= { mode: "other" }; + alias.mode = "updated"; + return shared.mode; + `)?.status, + ).toBe("unknown"); + }); + + it("propagates short-circuit assignment mutations to the RHS alias candidate", () => { + expect( + resultValue(` + const shared = { mode: "initial" }; + const other = { mode: "other" }; + let alias = shared; + alias &&= other; + alias.mode = "updated"; + return other.mode; + `)?.status, + ).toBe("unknown"); + }); + it("retains ordinary immutable object and array projection", () => { expect( resultValue( diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index f2f54e0eb..f922fa6e6 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -13,8 +13,8 @@ import { collectSemanticReferences, immutableSemanticBindings, immutableSemanticScopes, - semanticStaticPropertyKey, } from "./javascriptSemanticProjection.js"; +import { semanticStaticPropertyKey } from "./javascriptAstValues.js"; import type { JavaScriptSemanticAnalysisState, JavaScriptSemanticBindingState, @@ -35,7 +35,8 @@ import { resolveSemanticModuleCallables, } from "./javascriptSemanticReturns.js"; import { collectJavaScriptDerivedSemantics } from "./javascriptSemanticDerivedAnalysis.js"; -import { propertyName, range } from "./javascriptStaticAnalysisHelpers.js"; +import { range } from "./javascriptStaticAnalysisHelpers.js"; +import { propertyName } from "./javascriptAstValues.js"; import { semanticCoverage } from "./javascriptSemanticCoverage.js"; import { semanticResourceLimitsIn } from "./javascriptSemanticResourceLimits.js"; import { @@ -409,7 +410,16 @@ const bindInnerDeclaration = ( }); else if (t.isAssignmentExpression(node)) { if (t.isIdentifier(node.left)) - addAssignment(node.left, node.right, scope, state); + addAssignment( + node.left, + node.operator === "=" || + node.operator === "||=" || + node.operator === "??=" + ? node.right + : node, + scope, + state, + ); else for (const identifier of assignedPatternIdentifiers(node.left)) addAssignment(identifier, node, scope, state); diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 2c6ebe9e8..622d9f630 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -3,6 +3,7 @@ import * as t from "@babel/types"; import { resolveSemanticBindingState, type JavaScriptSemanticAnalysisState, + type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; import { evaluateSemanticBinding } from "./javascriptSemanticValues.js"; import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; @@ -15,10 +16,17 @@ export const collectSemanticMemberMutations = ( program: t.Program, state: JavaScriptSemanticAnalysisState, ): void => { + const parents = new WeakMap(); + traverseJavaScriptAst(program, { + enter: (node, parent) => { + if (parent !== null) parents.set(node, parent); + }, + }); const markValue = ( node: t.Node, path: PropertyPath, bindings: ReadonlySet, + mutation: t.Node, ): void => { if (t.isIdentifier(node)) { const binding = resolveSemanticBindingState(state, node, node.name); @@ -27,11 +35,16 @@ export const collectSemanticMemberMutations = ( 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) + for (const initializer of mutationInitializers( + binding, + mutation, + parents, + )) markValue( initializer.node, [...initializer.projection, ...path], nested, + mutation, ); return; } @@ -47,43 +60,130 @@ export const collectSemanticMemberMutations = ( ); current = current.object; } - markValue(current, [...members.reverse(), ...path], bindings); + markValue(current, [...members.reverse(), ...path], bindings, mutation); return; } for (const value of referencedValues(node, path)) - markValue(value.node, value.path, bindings); + markValue(value.node, value.path, bindings, mutation); }; - const markTarget = (node: t.Node): void => { + const markTarget = (node: t.Node, mutation: t.Node): void => { if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) markValue( node.object, [semanticStaticPropertyKey(node.property, node.computed)], new Set(), + mutation, ); - else if (t.isRestElement(node)) markTarget(node.argument); - else if (t.isAssignmentPattern(node)) markTarget(node.left); + else if (t.isRestElement(node)) markTarget(node.argument, mutation); + else if (t.isAssignmentPattern(node)) markTarget(node.left, mutation); else if (t.isArrayPattern(node)) { for (const element of node.elements) - if (element !== null) markTarget(element); + if (element !== null) markTarget(element, mutation); } else if (t.isObjectPattern(node)) { for (const property of node.properties) markTarget( t.isRestElement(property) ? property.argument : property.value, + mutation, ); } }; traverseJavaScriptAst(program, { enter: (node) => { - if (t.isAssignmentExpression(node)) markTarget(node.left); - else if (t.isUpdateExpression(node)) markTarget(node.argument); + if (t.isAssignmentExpression(node)) markTarget(node.left, node); + else if (t.isUpdateExpression(node)) markTarget(node.argument, node); else if (t.isUnaryExpression(node, { operator: "delete" })) - markTarget(node.argument); + markTarget(node.argument, node); else if (t.isForOfStatement(node) || t.isForInStatement(node)) - markTarget(node.left); + markTarget(node.left, node); }, }); }; +interface StatementPosition { + readonly body: readonly t.Statement[]; + readonly index: number; +} + +const statementPosition = ( + statement: t.Statement, + parents: WeakMap, +): StatementPosition | null => { + const parent = parents.get(statement); + if (!t.isProgram(parent) && !t.isBlockStatement(parent)) return null; + const index = parent.body.indexOf(statement); + return index < 0 ? null : { body: parent.body, index }; +}; + +const directInitializerPosition = ( + node: t.Node, + parents: WeakMap, +): StatementPosition | null => { + const parent = parents.get(node); + if (t.isVariableDeclarator(parent) && parent.init === node) { + const declaration = parents.get(parent); + return t.isVariableDeclaration(declaration) + ? statementPosition(declaration, parents) + : null; + } + if ( + t.isAssignmentExpression(parent) && + parent.operator === "=" && + parent.right === node && + t.isIdentifier(parent.left) + ) { + const statement = parents.get(parent); + return t.isExpressionStatement(statement) && statement.expression === parent + ? statementPosition(statement, parents) + : null; + } + return null; +}; + +const directMutationPosition = ( + node: t.Node, + parents: WeakMap, +): StatementPosition | null => { + if ( + !t.isAssignmentExpression(node) && + !t.isUpdateExpression(node) && + !t.isUnaryExpression(node, { operator: "delete" }) + ) + return null; + const statement = parents.get(node); + return t.isExpressionStatement(statement) && statement.expression === node + ? statementPosition(statement, parents) + : null; +}; + +const mutationInitializers = ( + binding: JavaScriptSemanticBindingState, + mutation: t.Node, + parents: WeakMap, +): JavaScriptSemanticBindingState["initializers"] => { + if (binding.initializers.length < 2) return binding.initializers; + const mutationPosition = directMutationPosition(mutation, parents); + if (mutationPosition === null) return binding.initializers; + const positions = binding.initializers.map(({ node }) => + directInitializerPosition(node, parents), + ); + if ( + positions.some( + (position) => + position === null || + position.body !== mutationPosition.body || + position.index >= mutationPosition.index, + ) + ) + return binding.initializers; + const latestIndex = positions.reduce( + (latest, position) => Math.max(latest, position?.index ?? -1), + -1, + ); + return binding.initializers.filter( + (_, index) => positions[index]?.index === latestIndex, + ); +}; + interface ReferencedValue { readonly node: t.Node; readonly path: PropertyPath; @@ -141,5 +241,7 @@ const referencedValues = ( { node: node.left, path }, { node: node.right, path }, ]; + if (t.isAssignmentExpression(node, { operator: "&&=" })) + return [{ node: node.right, path }]; return []; }; diff --git a/src/domain/javascript/javascriptSemanticValues.ts b/src/domain/javascript/javascriptSemanticValues.ts index 84db35553..8fac48e8e 100644 --- a/src/domain/javascript/javascriptSemanticValues.ts +++ b/src/domain/javascript/javascriptSemanticValues.ts @@ -4,12 +4,12 @@ import { semanticSlotAtPath } from "./javascriptSemanticSlots.js"; import { invalidateSemanticMutationPath } from "./javascriptSemanticMutationValues.js"; +import type { JavaScriptBindingProvenance } from "./javascriptSemanticIr.js"; import type { - JavaScriptBindingProvenance, JavaScriptSemanticProperty, JavaScriptSemanticResourceLimit, JavaScriptSemanticValue, -} from "./javascriptSemanticIr.js"; +} from "./javascriptSemanticValueTypes.js"; import { resolveSemanticBindingState, type JavaScriptSemanticAnalysisState, @@ -17,7 +17,10 @@ import { } from "./javascriptSemanticState.js"; import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js"; import { compareCodePoints } from "../canonicalOrdering.js"; -import { semanticStaticPropertyKey } from "./javascriptAstValues.js"; +import { + readExactJavaScriptLiteral, + semanticStaticPropertyKey, +} from "./javascriptAstValues.js"; import { semanticAmbiguousProvenance, semanticLocalProvenance, @@ -26,10 +29,8 @@ import { uniqueSemanticOrigins, } from "./javascriptSemanticProvenance.js"; import { - MAX_SEMANTIC_PRIMITIVE_CANDIDATES, semanticPrimitiveCandidates as primitiveCandidates, semanticPrimitiveSet as primitiveSet, - semanticPrimitiveValue as primitiveValue, } from "./javascriptSemanticPrimitives.js"; import { SEMANTIC_EXPRESSION_DEPTH_LIMIT, @@ -120,6 +121,21 @@ const evaluateBinding = ( status: "unknown", reason: `Binding ${binding.name} has no constant initializer.`, }; + if ( + binding.initializers.some( + ({ node }) => + t.isUpdateExpression(node) || + (t.isAssignmentExpression(node) && + node.operator !== "=" && + node.operator !== "&&=" && + node.operator !== "||=" && + node.operator !== "??="), + ) + ) + return { + status: "unknown", + reason: `Binding ${binding.name} has a compound or update write.`, + }; if (binding.initializers.length > 1) return { status: "ambiguous", @@ -154,7 +170,7 @@ const evaluateExpression = ( ): JavaScriptSemanticValue => { if (context.expressionDepth > SEMANTIC_EXPRESSION_DEPTH_LIMIT) return semanticResourceLimitUnknown("expression-depth"); - const literal = primitiveValue(node); + const literal = readExactJavaScriptLiteral(node); if (literal.found) return { status: "literal", value: literal.value }; if (t.isIdentifier(node)) { const binding = resolveSemanticBindingState(context.state, node, node.name); @@ -408,7 +424,7 @@ const addPrimitiveValues = ( if (isSemanticResourceLimit(rightValue)) return rightValue; return { status: "unknown", reason: "Non-primitive addition." }; } - if (left.length > MAX_SEMANTIC_PRIMITIVE_CANDIDATES / right.length) + if (left.length > SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT / right.length) return semanticResourceLimitUnknown("primitive-candidates"); if (exceedsSemanticPrimitiveAdditionByteBudget(left, right)) return semanticResourceLimitUnknown("primitive-bytes");