fix(javascript): retain assignment and nested-default destructuring

Share source identity and one iterative pattern walker across declarations and assignments. Traverse nested default targets without losing dependency paths, preserving existing scalar-default occurrence identities and conservative unresolved slots.

Validation: 405 focused tests across five files, including public CLI dependency Evidence. Refs #4609, #4604
This commit is contained in:
morluto
2026-10-17 08:32:26 +08:00
parent c3e42385e7
commit bfb7ed0bd4
4 changed files with 187 additions and 27 deletions
@@ -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"],
}),
);
},
);
@@ -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", () => {
@@ -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;
};
@@ -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);
}
},
);