diff --git a/src/domain/javascript/javascriptSemanticObjectReferences.test.ts b/src/domain/javascript/javascriptSemanticObjectReferences.test.ts new file mode 100644 index 0000000000..f9867ed197 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticObjectReferences.test.ts @@ -0,0 +1,73 @@ +import * as t from "@babel/types"; +import { expect, it } from "vitest"; + +import { indexJavaScriptBindingsSteps } from "./javascriptSemanticAnalysis.js"; +import { semanticObjectPropertyReferences } from "./javascriptSemanticObjectReferences.js"; +import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; + +const objectAndState = (source: string) => { + const parsed = parseJavaScriptSource(source, "object.js"); + if (parsed === null) throw new Error("Expected a parsed fixture"); + const declaration = parsed.program.body[0]; + if (!t.isVariableDeclaration(declaration)) + throw new Error("Expected a declaration"); + const init = declaration.declarations[0]?.init; + if (!t.isObjectExpression(init)) throw new Error("Expected an object"); + return { + object: init, + source, + state: completeSemanticSteps(indexJavaScriptBindingsSteps(parsed.program)), + }; +}; + +const spelled = ( + fixture: ReturnType, + key: string | number | null, +) => + semanticObjectPropertyReferences(fixture.object, [key], fixture.state).map( + ({ node, path }) => [ + fixture.source.slice(node.start ?? 0, node.end ?? 0), + JSON.stringify(path), + ], + ); + +it("resolves exact keys with source-order overrides, spreads and accessors", () => { + const fixture = objectAndState( + `const o = { a: 1, ...base, b: 2, a: 3, [dynamic]: 4, __proto__: proto, ...{ b: 5 }, get c() { return 6; }, c: 7 };`, + ); + // Expectations match the full source-order scan these exact keys replace. + expect(spelled(fixture, "a")).toEqual([ + ["{ b: 5 }", '["a"]'], + ["4", "[]"], + ["3", "[]"], + ]); + expect(spelled(fixture, "b")).toEqual([ + ["{ b: 5 }", '["b"]'], + ["4", "[]"], + ]); + expect(spelled(fixture, "c")).toEqual([ + ["7", "[]"], + ["4", "[]"], + ]); + expect(spelled(fixture, "missing")).toEqual([ + ["{ b: 5 }", '["missing"]'], + ["proto", '["missing"]'], + ["4", "[]"], + ["base", '["missing"]'], + ]); + // A wildcard key still scans every property. + expect(spelled(fixture, null)).toEqual([ + ["7", "[]"], + ["{ b: 5 }", '[{"excludedKeys":["c"]}]'], + ["proto", "[null]"], + ["4", "[]"], + ["3", "[]"], + ["2", "[]"], + ["base", '[{"excludedKeys":["a","b","c"]}]'], + ]); + // Numeric keys use the same exact spelling as string keys. + expect( + spelled(objectAndState(`const o = { 1: "one", 2: "two" };`), 1), + ).toEqual([['"one"', "[]"]]); +}); diff --git a/src/domain/javascript/javascriptSemanticObjectReferences.ts b/src/domain/javascript/javascriptSemanticObjectReferences.ts index 2e83131675..111417b5a7 100644 --- a/src/domain/javascript/javascriptSemanticObjectReferences.ts +++ b/src/domain/javascript/javascriptSemanticObjectReferences.ts @@ -739,6 +739,75 @@ const getterResultReferences = ( return references; }; +type ObjectLiteralMember = t.ObjectExpression["properties"][number]; + +/** Property positions grouped by whether an exact key can reach them. */ +interface ObjectPropertyIndex { + /** Spreads, computed keys and `__proto__` data entries reach every key. */ + readonly keyIndependent: readonly number[]; + readonly byName: ReadonlyMap; +} + +// Mutation tracking resolves one key of the same literal per write, so the +// index removes the per-write scan of every property. +const propertyIndexes = new WeakMap(); + +const objectPropertyIndex = (node: t.ObjectExpression): ObjectPropertyIndex => { + const cached = propertyIndexes.get(node); + if (cached !== undefined) return cached; + const keyIndependent: number[] = []; + const byName = new Map(); + node.properties.forEach((property, index) => { + const name = t.isSpreadElement(property) + ? null + : semanticStaticPropertyKey(property.key, property.computed); + if ( + name === null || + (t.isObjectProperty(property) && + !property.computed && + !property.shorthand && + name === "__proto__") + ) { + keyIndependent.push(index); + return; + } + const positions = byName.get(name); + if (positions === undefined) byName.set(name, [index]); + else positions.push(index); + }); + const index = { keyIndependent, byName }; + propertyIndexes.set(node, index); + return index; +}; + +/** + * Properties in reverse source order that can affect `key`. For an exact key, + * another static name only updates bookkeeping for that other name, which + * this key never reads, so only same-name and key-independent entries remain. + */ +const relevantPropertiesInReverse = ( + node: t.ObjectExpression, + key: PropertyPath[number] | undefined, +): readonly ObjectLiteralMember[] => { + if (typeof key !== "string" && typeof key !== "number") + return [...node.properties].reverse(); + const { keyIndependent, byName } = objectPropertyIndex(node); + const named = byName.get(String(key)) ?? []; + const relevant: ObjectLiteralMember[] = []; + let left = keyIndependent.length - 1; + let right = named.length - 1; + while (left >= 0 || right >= 0) { + const fromLeft = + right < 0 || + (left >= 0 && (keyIndependent[left] ?? -1) > (named[right] ?? -1)); + const position = fromLeft ? keyIndependent[left--] : named[right--]; + const property = + position === undefined ? undefined : node.properties[position]; + if (property !== undefined) relevant.push(property); + } + return relevant; +}; + export const semanticObjectPropertyReferences = ( node: t.ObjectExpression, path: PropertyPath, @@ -751,7 +820,7 @@ export const semanticObjectPropertyReferences = ( const accessorNames = new Set(); const getterNames = new Set(); const setterNames = new Set(); - for (const property of [...node.properties].reverse()) { + for (const property of relevantPropertiesInReverse(node, key)) { if (t.isSpreadElement(property)) { const selected = key ?? null; if (typeof selected !== "object" && overwritten.has(String(selected)))