diff --git a/src/application/javascript/JavaScriptSemanticGraphDestructuring.test.ts b/src/application/javascript/JavaScriptSemanticGraphDestructuring.test.ts new file mode 100644 index 0000000000..a793b686ee --- /dev/null +++ b/src/application/javascript/JavaScriptSemanticGraphDestructuring.test.ts @@ -0,0 +1,69 @@ +import { expect, it } from "vitest"; +import { graphForJavaScript } from "../../../tests/fixtures/javascriptSemanticGraph.js"; + +it.each( + [ + { pattern: "{ a: selected }", source: "{ a: 1 }", path: ["a"] }, + { pattern: "[selected]", source: "[1]", path: ["0"] }, + { + pattern: "{ a: [selected] = [] }", + source: "{ a: [1] }", + path: ["a", "0"], + }, + { + pattern: "{ a: { b: selected } = {} }", + source: "{ a: { b: 1 } }", + path: ["a", "b"], + }, + { pattern: "[[selected] = []]", source: "[[1]]", path: ["0", "0"] }, + ].flatMap((pattern) => + [false, true].map((assignment) => ({ ...pattern, assignment })), + ), +)( + "retains source-property flow for $pattern, assignment=$assignment", + ({ pattern, source, path, assignment }) => { + const graph = graphForJavaScript(` + const source = ${source}; + ${assignment ? `let selected; (${pattern} = source);` : `const ${pattern} = source;`} + `); + const target = graph.nodes.find( + (node) => node.kind === "binding" && node.label === "selected", + ); + expect(target).toBeDefined(); + const relations = graph.relations.filter( + (relation) => + relation.relation === "destructures" && + relation.target_node_id === target?.node_id, + ); + expect(relations).toHaveLength(1); + expect(relations[0]?.resolution).toBe("resolved"); + const slot = graph.nodes.find( + (node) => node.node_id === relations[0]?.source_node_id, + ); + expect(slot?.properties.property_path).toEqual(path); + }, +); + +it.each(["{}", "unknownSource"])( + "keeps nested default source flow conservative for %s", + (source) => { + const graph = graphForJavaScript( + `const source = ${source}; const { a: [selected] = [1] } = source;`, + ); + const target = graph.nodes.find( + (node) => node.kind === "binding" && node.label === "selected", + ); + const relation = graph.relations.find( + (item) => + item.relation === "destructures" && + item.target_node_id === target?.node_id, + ); + expect(relation).toMatchObject({ resolution: "candidate" }); + expect(graph.unknowns).toContainEqual( + expect.objectContaining({ + family: "object-flow", + relation_kinds: ["destructures"], + }), + ); + }, +); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts index c8cda54bf8..f74a32e4ee 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts @@ -265,7 +265,10 @@ describe("JavaScript semantic analysis: read-modify-write", () => { let value; [value = source.count] = []; `); - expect(ir.objectOperations.map(({ kind }) => kind)).toEqual(["read"]); + expect(ir.objectOperations.map(({ kind }) => kind)).toEqual([ + "destructure", + "read", + ]); }); it("does not model a compound binding write as a competing replacement", () => { diff --git a/src/domain/javascript/javascriptSemanticObjects.ts b/src/domain/javascript/javascriptSemanticObjects.ts index 6e4dbb0a1e..bce7e997fa 100644 --- a/src/domain/javascript/javascriptSemanticObjects.ts +++ b/src/domain/javascript/javascriptSemanticObjects.ts @@ -65,7 +65,24 @@ export const collectJavaScriptSemanticObjects = ( t.isVariableDeclarator(node) && (t.isObjectPattern(node.id) || t.isArrayPattern(node.id)) ) - collectDestructuring(node, ownerCallableId, state, output); + collectDestructuring( + node.id, + node.init, + ownerCallableId, + state, + output, + ); + else if ( + t.isAssignmentExpression(node, { operator: "=" }) && + (t.isObjectPattern(node.left) || t.isArrayPattern(node.left)) + ) + collectDestructuring( + node.left, + node.right, + ownerCallableId, + state, + output, + ); }, exit: (node) => callableStack.exit(node), }); @@ -126,46 +143,62 @@ const collectMember = ( }; const collectDestructuring = ( - node: t.VariableDeclarator, + root: t.Node, + initializer: t.Node | null | undefined, ownerCallableId: string | null, state: JavaScriptSemanticAnalysisState, output: JavaScriptSemanticObjectOperation[], ): void => { - const source = expressionObjectIdentity(node.init, state); - const visit = (pattern: t.Node, path: readonly string[]): void => { + const source = expressionObjectIdentity(initializer, state); + const pending: { + readonly pattern: t.Node; + readonly path: readonly string[]; + }[] = [{ pattern: root, path: [] }]; + while (pending.length > 0) { + const next = pending.pop(); + if (next === undefined) break; + const { path } = next; + const occurrence = unwrapJavaScriptExpression(next.pattern).node; + const pattern = t.isAssignmentPattern(occurrence) + ? unwrapJavaScriptExpression(occurrence.left).node + : occurrence; if (t.isObjectPattern(pattern)) { - for (const property of pattern.properties) { + for (const property of pattern.properties.toReversed()) { if (!t.isObjectProperty(property)) continue; const name = semanticStaticPropertyKey(property.key, property.computed); - if (name !== null) visit(property.value, [...path, name]); + if (name !== null) + pending.push({ pattern: property.value, path: [...path, name] }); } - return; + continue; } if (t.isArrayPattern(pattern)) { - pattern.elements.forEach((element, index) => { - if (element !== null && !t.isRestElement(element)) - visit(element, [...path, String(index)]); - }); - return; + for (let index = pattern.elements.length - 1; index >= 0; index -= 1) { + const element = pattern.elements[index]; + if ( + element !== null && + element !== undefined && + !t.isRestElement(element) + ) + pending.push({ pattern: element, path: [...path, String(index)] }); + } + continue; } - const target = bindingIdentifier(pattern); - if (target === null) return; + if (!t.isIdentifier(pattern)) continue; addObjectOperation( { - node: pattern, + node: occurrence, kind: "destructure", ownerCallableId, objectBindingId: source.bindingId, targetBindingId: - resolveSemanticBindingState(state, target, target.name)?.bindingId ?? - null, + resolveSemanticBindingState(state, pattern, pattern.name) + ?.bindingId ?? null, propertyPath: source.path === null ? null : [...source.path, ...path], }, state, output, ); - }; - visit(node.id, []); + } }; interface AddObjectOperationInput { @@ -240,10 +273,3 @@ const expressionObjectIdentity = ( path: staticPath ? path.reverse() : null, }; }; - -const bindingIdentifier = (node: t.Node): t.Identifier | null => { - if (t.isIdentifier(node)) return node; - if (t.isAssignmentPattern(node) && t.isIdentifier(node.left)) - return node.left; - return null; -}; diff --git a/tests/boundary/cli/javascriptDestructuringEvidence.test.ts b/tests/boundary/cli/javascriptDestructuringEvidence.test.ts new file mode 100644 index 0000000000..66e09074c9 --- /dev/null +++ b/tests/boundary/cli/javascriptDestructuringEvidence.test.ts @@ -0,0 +1,62 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { z } from "zod"; + +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "retains assignment and nested-default destructuring Evidence", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-js-destructuring-evidence-", + ); + await writeFile( + join(root, "main.js"), + ` + const src = { a: 1 }; + const { a: declared } = src; + let a; + ({ a } = src); + const nested = { a: [1], b: 2 }; + const { a: [plain] } = nested; + const { a: [fallback] = [] } = nested; + const { b: scalar = 0 } = nested; + `, + ); + const output = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(output.exitCode).toBe(0); + const { normalized_result: result } = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(output.json); + const graph = result.semantic_graph; + for (const [name, path] of [ + ["declared", ["a"]], + ["a", ["a"]], + ["plain", ["a", "0"]], + ["fallback", ["a", "0"]], + ["scalar", ["b"]], + ] as const) { + const binding = graph.nodes.find( + (node) => node.kind === "binding" && node.label === name, + ); + expect(binding).toBeDefined(); + const relations = graph.relations.filter( + (relation) => + relation.relation === "destructures" && + relation.target_node_id === binding?.node_id, + ); + expect(relations).toHaveLength(1); + expect(relations[0]?.resolution).toBe("resolved"); + expect( + graph.nodes.find( + (node) => node.node_id === relations[0]?.source_node_id, + )?.properties.property_path, + ).toEqual(path); + } + }, +);