diff --git a/src/domain/javascript/javascriptSemanticCalls.ts b/src/domain/javascript/javascriptSemanticCalls.ts index 47544f5208..1879fa47b6 100644 --- a/src/domain/javascript/javascriptSemanticCalls.ts +++ b/src/domain/javascript/javascriptSemanticCalls.ts @@ -335,7 +335,8 @@ const collectCapture = ( owner.bodyScopeId === null ) return; - if (semanticIdentifierRole(node, parent, context.ancestors) === null) return; + if (semanticIdentifierRole(node, parent, () => context.ancestors) === null) + return; const binding = resolveSemanticBindingState(state, node, node.name); if ( binding === undefined || diff --git a/src/domain/javascript/javascriptSemanticProjection.test.ts b/src/domain/javascript/javascriptSemanticProjection.test.ts new file mode 100644 index 0000000000..e9d42d776a --- /dev/null +++ b/src/domain/javascript/javascriptSemanticProjection.test.ts @@ -0,0 +1,75 @@ +import * as t from "@babel/types"; +import { expect, it } from "vitest"; + +import { semanticIdentifierRole } from "./javascriptSemanticProjection.js"; +import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; + +const rolesFor = (source: string) => { + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected parsed source"); + const references: { name: string; role: string }[] = []; + let snapshots = 0; + traverseJavaScriptAst(parsed.program, { + enter(node, parent, readAncestors) { + if (!t.isIdentifier(node) || parent === null) return; + const role = semanticIdentifierRole(node, parent, () => { + snapshots++; + return readAncestors(); + }); + if (role !== null) references.push({ name: node.name, role }); + }, + }); + return { references, snapshots }; +}; + +it.each([256, 512])( + "classifies a %s-member chain without copying or scanning its ancestry", + (depth) => { + expect(rolesFor(`object${".property".repeat(depth)};`)).toEqual({ + references: [{ name: "object", role: "read" }], + snapshots: 0, + }); + }, +); + +it("retains optional and computed member reads without ancestor snapshots", () => { + expect( + rolesFor("object.property; object[key]; object?.property; object?.[key];"), + ).toEqual({ + references: [ + { name: "object", role: "read" }, + { name: "object", role: "read" }, + { name: "key", role: "read" }, + { name: "object", role: "read" }, + { name: "object", role: "read" }, + { name: "key", role: "read" }, + ], + snapshots: 0, + }); +}); + +it("still reads ancestry to distinguish bound patterns from default-value reads", () => { + const result = rolesFor( + "const {key: value = compute()} = object; ({key: target} = source);", + ); + expect(result.references).toEqual([ + { name: "compute", role: "read" }, + { name: "object", role: "read" }, + { name: "target", role: "write" }, + { name: "source", role: "read" }, + ]); + expect(result.snapshots).toBeGreaterThan(0); +}); + +it("keeps member receivers and computed keys as reads in assignment patterns and updates", () => { + expect( + rolesFor("({field: object[key]} = source); object[key]++;").references, + ).toEqual([ + { name: "object", role: "read" }, + { name: "key", role: "read" }, + { name: "source", role: "read" }, + { name: "object", role: "read" }, + { name: "key", role: "read" }, + ]); +}); diff --git a/src/domain/javascript/javascriptSemanticProjection.ts b/src/domain/javascript/javascriptSemanticProjection.ts index b30de20651..da6264f77c 100644 --- a/src/domain/javascript/javascriptSemanticProjection.ts +++ b/src/domain/javascript/javascriptSemanticProjection.ts @@ -180,8 +180,9 @@ export function* collectSemanticReferencesSteps( (name !== undefined && node.name !== name) ) return; - const ancestors = readAncestors(); - const role = semanticIdentifierRole(node, parent, ancestors); + let ancestors: readonly t.Node[] | undefined; + const ancestry = () => (ancestors ??= readAncestors()); + const role = semanticIdentifierRole(node, parent, ancestry); if (role === null) return; // A compound assignment or update reads before it writes; emit both so // reference consumers see the read they actually execute. @@ -189,7 +190,7 @@ export function* collectSemanticReferencesSteps( role === "write" && semanticReadsBeforeWrite( node, - semanticWrappedWriteSite(node, parent, ancestors) ?? parent, + semanticWrappedWriteSite(node, parent, ancestry()) ?? parent, ) ? (["read", "write"] as const) : ([role] as const); @@ -631,8 +632,12 @@ export const semanticReadsBeforeWrite = ( export const semanticIdentifierRole = ( node: t.Identifier, parent: t.Node, - ancestors: readonly t.Node[], + readAncestors: () => readonly t.Node[], ): JavaScriptSemanticReference["role"] | null => { + // Member identifiers are either object/computed-key reads or fixed property + // names. None is a bound destructuring slot, so no ancestor snapshot is needed. + if (t.isMemberExpression(parent) || t.isOptionalMemberExpression(parent)) + return semanticReferenceRole(node, parent); if ( t.isObjectProperty(parent) && parent.key === node && @@ -640,6 +645,7 @@ export const semanticIdentifierRole = ( parent.value !== node ) return null; + const ancestors = readAncestors(); const patternAncestor = enclosingPatternAncestor(node, parent, ancestors); if (patternAncestor !== null) { if (isPatternOwnerDeclaration(patternAncestor)) return null;