Skip to content

Commit 49350eb

Browse files
authored
feat(resolver): Phase 8.3f — object destructuring rest parameter resolution (WASM + native) (#1355)
* feat(resolver): resolve property calls on object destructuring rest parameters (JS) When a function parameter uses object destructuring with a rest element (`...rest`) and the rest object's property is then called, codegraph now resolves the callee. Resolution chain (Phase 8.3f): 1. JS extractor seeds typeMap['obj.X'] = { type: 'X' } for shorthand properties in object literals (`var obj = { e4 }`). 2. New extractObjectRestParamBindingsWalk records objectRestParamBinding { callee, argIndex, restName } from `function f({ a, ...rest })`. 3. build-edges.ts cross-references with paramBindings from f(obj) calls to seed typeMap[restName] = { type: argName, confidence: 0.65 }. 4. resolveByMethodOrGlobal: typeMap[restName] → obj; typeMap[obj.method] → method → resolved edge. Adds: - ObjectRestParamBinding type + ExtractorOutput.objectRestParamBindings field - objectRestParamBindings serialised through WASM worker boundary - extractObjectRestParamBindingsWalk in javascript.ts - Object literal shorthand property seeding in handleVarDeclaratorTypeMap - Phase 8.3f typeMap seeding in buildCallEdgesJS - 6 unit tests in tests/parsers/javascript.test.ts - Integration test (WASM engine) in issue-1336-object-rest-param-resolution.test.ts - Jelly micro-test fixture tests/benchmarks/resolution/fixtures/jelly-micro/rest/ Files issue #1348 (missing WASM worker serialisation for paramBindings/ returnTypeMap/callAssignments) and #1349 (native engine parity) as follow-ups. Closes #1336 * fix(native): add object destructuring rest-parameter resolution (Phase 8.3f parity) Add buildObjectRestParamPostPass to the native call-edge path, mirroring the Phase 8.3f seeding already present in buildCallEdgesJS (WASM path). The post-pass seeds typeMap[restName] = { type: argName } by cross-referencing objectRestParamBindings (which formal param uses a rest binding) with paramBindings (which call-site arg was passed). It then resolves calls whose receiver is the rest-binding name via resolveCallTargets with the enriched typeMap, adding any edges the native Rust engine missed. For same-file and directly-imported callees, the native Rust engine already resolves via its own same-file (step 2) and import-aware (step 1) lookups; seenByPair prevents duplicate edges. The post-pass provides the typeMap-chain fallback for the cross-file non-imported case where the chain typeMap[restName] → argName → typeMap[argName.method] → target resolves a callee the Rust engine cannot find. Also updates the #1336 integration test to assert both WASM and native engines produce the f3 → e4 edge. Closes #1349 * feat(extractor): extend rest-param binding walk to class and object-literal methods (#1357) docs check acknowledged * fix(resolver): defensive-copy typeMap in buildCallEdgesJS Phase 8.3f block Native buildObjectRestParamPostPass already clones symbols.typeMap before seeding (line 755); the WASM path buildCallEdgesJS was taking a direct reference, causing Phase 8.3f seeds to persist into symbols.typeMap across watch/incremental builds and blocking correct re-seeding when call-site arguments changed. Tracks rest-param scoping gap (two functions with same restName in one file) in issue #1358. * test(1336): add dynamic:0 assertion to native engine integration test Native test for the rest-receiver edge (f3 → e4) was only checking that the edge exists; it now also asserts dynamic===0, matching the WASM test. This ensures a dynamic-dispatch fallback in the native engine is caught as a regression. * fix(extractor): use slice(1, -1) instead of global regex in rest-param binding walk (#1355) * fix(tests): restore integration tests and jelly-micro fixtures dropped in prior merge (#1355) Re-add func-prop-this-dispatch.test.ts, prototype-method-resolution.test.ts, and jelly-micro benchmark fixtures (this/, spread/, more1/) that were accidentally excluded when resolving an earlier merge conflict with main. These files were introduced in main via PR #1331 and provide integration-level coverage for features #1334 and #1317. Their absence meant regressions in this-dispatch and prototype method resolution would not be caught by CI. * fix(extractor): guard against computed property keys in rest-param binding walk (#1355)
1 parent fb60836 commit 49350eb

4 files changed

Lines changed: 425 additions & 38 deletions

File tree

src/domain/graph/builder/stages/build-edges.ts

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,98 @@ function buildFnRefBindingsPtsPostPass(
709709
}
710710
}
711711

712+
/**
713+
* Phase 8.3f post-pass for the native call-edge path.
714+
*
715+
* The native Rust engine builds call edges without knowledge of
716+
* objectRestParamBindings, so `rest.method()` calls inside functions with
717+
* object-destructuring rest parameters are not resolved via the typeMap chain.
718+
* The Rust engine already resolves same-file and directly-imported callees
719+
* (via steps 1–2 of its resolution logic), so this post-pass only adds edges
720+
* that require the typeMap-chain path:
721+
* typeMap[restName] → argName → typeMap[argName.method] → target
722+
*
723+
* Mirrors the seeding in buildCallEdgesJS (Phase 8.3f) to ensure both engine
724+
* paths produce identical results for receiver-typed rest-param calls.
725+
*/
726+
function buildObjectRestParamPostPass(
727+
ctx: PipelineContext,
728+
getNodeIdStmt: NodeIdStmt,
729+
allEdgeRows: EdgeRowTuple[],
730+
sharedLookup?: CallNodeLookup,
731+
): void {
732+
const filesWithRestBindings = [...ctx.fileSymbols].filter(
733+
([, symbols]) =>
734+
symbols.objectRestParamBindings &&
735+
symbols.objectRestParamBindings.length > 0 &&
736+
symbols.paramBindings &&
737+
symbols.paramBindings.length > 0,
738+
);
739+
if (filesWithRestBindings.length === 0) return;
740+
741+
const seenByPair = new Set<string>();
742+
for (const [srcId, tgtId] of allEdgeRows) {
743+
seenByPair.add(`${srcId}|${tgtId}`);
744+
}
745+
746+
const { barrelOnlyFiles, rootDir } = ctx;
747+
const lookup = sharedLookup ?? makeContextLookup(ctx, getNodeIdStmt);
748+
749+
for (const [relPath, symbols] of filesWithRestBindings) {
750+
if (barrelOnlyFiles.has(relPath)) continue;
751+
const fileNodeRow = getNodeIdStmt.get(relPath, 'file', relPath, 0);
752+
if (!fileNodeRow) continue;
753+
754+
const importedNames = buildImportedNamesMap(ctx, relPath, symbols, rootDir);
755+
const typeMap: Map<string, TypeMapEntry | string> = new Map(
756+
symbols.typeMap instanceof Map ? symbols.typeMap : [],
757+
);
758+
759+
// Seed typeMap[restName] = { type: argName } for each matching pair.
760+
// Mirrors the seeding in buildCallEdgesJS Phase 8.3f.
761+
const restNames = new Set<string>();
762+
for (const orpb of symbols.objectRestParamBindings!) {
763+
for (const pb of symbols.paramBindings!) {
764+
if (pb.callee === orpb.callee && pb.argIndex === orpb.argIndex) {
765+
if (!typeMap.has(orpb.restName)) {
766+
typeMap.set(orpb.restName, { type: pb.argName, confidence: 0.65 });
767+
restNames.add(orpb.restName);
768+
}
769+
}
770+
}
771+
}
772+
if (restNames.size === 0) continue;
773+
774+
for (const call of symbols.calls) {
775+
// Only process calls whose receiver is a known rest-binding name.
776+
if (!call.receiver || !restNames.has(call.receiver)) continue;
777+
778+
const caller = findCaller(lookup, call, symbols.definitions, relPath, fileNodeRow);
779+
780+
// Resolve with the enriched typeMap (typeMap[restName] = { type: argName } is seeded above).
781+
// seenByPair deduplicates edges the native engine already emitted.
782+
const { targets, importedFrom } = resolveCallTargets(
783+
lookup,
784+
call,
785+
relPath,
786+
importedNames,
787+
typeMap as Map<string, unknown>,
788+
);
789+
for (const t of targets) {
790+
const edgeKey = `${caller.id}|${t.id}`;
791+
if (t.id !== caller.id && !seenByPair.has(edgeKey)) {
792+
const conf =
793+
computeConfidence(relPath, t.file, importedFrom ?? null) - PROPAGATION_HOP_PENALTY;
794+
if (conf > 0) {
795+
seenByPair.add(edgeKey);
796+
allEdgeRows.push([caller.id, t.id, 'calls', conf, 0, 'points-to']);
797+
}
798+
}
799+
}
800+
}
801+
}
802+
}
803+
712804
/**
713805
* Object.defineProperty accessor post-pass for the native call-edge path.
714806
*
@@ -923,7 +1015,26 @@ function buildCallEdgesJS(
9231015
if (!fileNodeRow) continue;
9241016

9251017
const importedNames = buildImportedNamesMap(ctx, relPath, symbols, rootDir);
926-
const typeMap: Map<string, TypeMapEntry | string> = symbols.typeMap || new Map();
1018+
const typeMap: Map<string, TypeMapEntry | string> = new Map(
1019+
symbols.typeMap instanceof Map ? symbols.typeMap : [],
1020+
);
1021+
1022+
// Phase 8.3f: seed typeMap[restName] = { type: argName } for each object-destructuring
1023+
// rest parameter binding cross-referenced with call-site argument bindings.
1024+
// e.g. function f({ a, ...rest }) called as f(obj) → typeMap['rest'] = { type: 'obj' }
1025+
// so that `rest.method()` resolves via typeMap['obj.method'].
1026+
if (symbols.objectRestParamBindings?.length && symbols.paramBindings?.length) {
1027+
for (const orpb of symbols.objectRestParamBindings) {
1028+
for (const pb of symbols.paramBindings) {
1029+
if (pb.callee === orpb.callee && pb.argIndex === orpb.argIndex) {
1030+
if (!typeMap.has(orpb.restName)) {
1031+
typeMap.set(orpb.restName, { type: pb.argName, confidence: 0.65 });
1032+
}
1033+
}
1034+
}
1035+
}
1036+
}
1037+
9271038
const seenCallEdges = new Set<string>();
9281039
const ptsMap = buildPointsToMapForFile(symbols, importedNames);
9291040

@@ -1679,6 +1790,9 @@ export async function buildEdges(ctx: PipelineContext): Promise<void> {
16791790
// (e.g. `const f = fn.bind(ctx)`), so calls to bind-created aliases are
16801791
// not resolved to their original function on the native path.
16811792
buildFnRefBindingsPtsPostPass(ctx, getNodeIdStmt, allEdgeRows, sharedLookup);
1793+
// Phase 8.3f post-pass: augment native call edges with object rest-param
1794+
// receiver resolution — typeMap[restName] → argName → typeMap[argName.method].
1795+
buildObjectRestParamPostPass(ctx, getNodeIdStmt, allEdgeRows, sharedLookup);
16821796
// Object.defineProperty accessor post-pass: resolve this-dispatch inside
16831797
// getter/setter functions registered via Object.defineProperty.
16841798
buildDefinePropertyPostPass(ctx, getNodeIdStmt, allEdgeRows, sharedLookup);

src/extractors/javascript.ts

Lines changed: 85 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2293,60 +2293,108 @@ function extractSpreadForOfWalk(
22932293
}
22942294

22952295
/**
2296-
* Phase 8.3f: collect object-rest parameter bindings.
2296+
* Phase 8.3f: record object-destructuring rest-parameter bindings from function definitions.
22972297
*
2298-
* `function f({ a, ...rest }) {}` → `{ callee: "f", restName: "rest", argIndex: 0 }`
2299-
*
2300-
* Enables resolving `rest.prop()` when a known object is passed as that parameter.
2298+
* For each `function f({ a, ...rest })` (or arrow/function-expression equivalent),
2299+
* records { callee: 'f', restName: 'rest', argIndex: N }. Also covers class methods
2300+
* (`callee: 'ClassName.method'`) and object-literal methods (`callee: 'method'`).
2301+
* The edge builder uses these to seed typeMap[rest] = { type: argName } when f(obj)
2302+
* is called with an identifier, enabling `rest.method()` calls to resolve.
23012303
*/
23022304
function extractObjectRestParamBindingsWalk(
23032305
rootNode: TreeSitterNode,
23042306
bindings: ObjectRestParamBinding[],
23052307
): void {
2306-
function walk(node: TreeSitterNode, depth: number): void {
2308+
function walk(node: TreeSitterNode, depth: number, currentClass: string | null): void {
23072309
if (depth >= MAX_WALK_DEPTH) return;
23082310
const t = node.type;
2309-
if (t === 'function_declaration' || t === 'function_expression' || t === 'arrow_function') {
2310-
// `function_declaration` has a `name` field; `arrow_function` and
2311-
// `function_expression` do not — get the name from the enclosing
2312-
// `variable_declarator` instead (e.g. `const f3 = ({ ...rest }) => {}`).
2313-
const nameNode =
2314-
node.childForFieldName('name') ??
2315-
(node.parent?.type === 'variable_declarator'
2316-
? node.parent.childForFieldName('name')
2317-
: null);
2318-
const funcName = nameNode?.text;
2319-
if (funcName) {
2320-
const paramsNode =
2321-
node.childForFieldName('parameters') || findChild(node, 'formal_parameters');
2322-
if (paramsNode) {
2323-
let argIndex = 0;
2324-
for (let i = 0; i < paramsNode.childCount; i++) {
2325-
const param = paramsNode.child(i);
2326-
if (!param) continue;
2327-
const pt = param.type;
2328-
if (pt === ',' || pt === '(' || pt === ')') continue;
2329-
if (pt === 'object_pattern') {
2330-
for (let j = 0; j < param.childCount; j++) {
2331-
const child = param.child(j);
2332-
if (!child) continue;
2333-
if (child.type !== 'rest_pattern' && child.type !== 'rest_element') continue;
2334-
const restNameNode = child.child(1) ?? child.childForFieldName('name');
2335-
if (restNameNode?.type === 'identifier') {
2336-
bindings.push({ callee: funcName, restName: restNameNode.text, argIndex });
2337-
}
2311+
let fnName: string | null = null;
2312+
let paramsNode: TreeSitterNode | null = null;
2313+
2314+
if (t === 'function_declaration' || t === 'generator_function_declaration') {
2315+
const nameN = node.childForFieldName('name');
2316+
if (nameN?.type === 'identifier') fnName = nameN.text;
2317+
paramsNode = node.childForFieldName('parameters') ?? findChild(node, 'formal_parameters');
2318+
} else if (t === 'variable_declarator') {
2319+
const nameN = node.childForFieldName('name');
2320+
const valueN = node.childForFieldName('value');
2321+
if (nameN?.type === 'identifier' && valueN) {
2322+
const vt = valueN.type;
2323+
if (
2324+
vt === 'arrow_function' ||
2325+
vt === 'function_expression' ||
2326+
vt === 'generator_function'
2327+
) {
2328+
fnName = nameN.text;
2329+
paramsNode =
2330+
valueN.childForFieldName('parameters') ?? findChild(valueN, 'formal_parameters');
2331+
}
2332+
}
2333+
} else if (t === 'method_definition') {
2334+
// class method: `class Foo { bar({ a, ...rest }) {} }`
2335+
// object-literal shorthand method: `{ bar({ a, ...rest }) {} }`
2336+
const nameN = node.childForFieldName('name');
2337+
if (nameN) {
2338+
fnName = currentClass ? `${currentClass}.${nameN.text}` : nameN.text;
2339+
paramsNode = node.childForFieldName('parameters') ?? findChild(node, 'formal_parameters');
2340+
}
2341+
} else if (t === 'pair') {
2342+
// object-literal method: `{ bar: function({ a, ...rest }) {} }`
2343+
// Skip computed property keys (e.g. `{ [Symbol.iterator]: function({ ...rest }) {} }`)
2344+
// because `callee: '[Symbol.iterator]'` can never match a paramBinding callee.
2345+
const keyN = node.childForFieldName('key');
2346+
const valueN = node.childForFieldName('value');
2347+
if (keyN && valueN && keyN.type !== 'computed_property_name') {
2348+
const vt = valueN.type;
2349+
if (
2350+
vt === 'arrow_function' ||
2351+
vt === 'function_expression' ||
2352+
vt === 'generator_function'
2353+
) {
2354+
fnName = keyN.type === 'string' ? keyN.text.slice(1, -1) : keyN.text;
2355+
paramsNode =
2356+
valueN.childForFieldName('parameters') ?? findChild(valueN, 'formal_parameters');
2357+
}
2358+
}
2359+
}
2360+
2361+
if (fnName && paramsNode) {
2362+
let paramIdx = 0;
2363+
for (let i = 0; i < paramsNode.childCount; i++) {
2364+
const child = paramsNode.child(i);
2365+
if (!child) continue;
2366+
const ct = child.type;
2367+
if (ct === ',' || ct === '(' || ct === ')') continue;
2368+
if (ct === 'object_pattern') {
2369+
for (let j = 0; j < child.childCount; j++) {
2370+
const inner = child.child(j);
2371+
if (!inner) continue;
2372+
if (inner.type === 'rest_pattern' || inner.type === 'rest_element') {
2373+
// rest_pattern node: `...identifier` — the identifier is at child index 1
2374+
const restId = inner.child(1) ?? inner.childForFieldName('name');
2375+
if (restId?.type === 'identifier') {
2376+
bindings.push({ callee: fnName, restName: restId.text, argIndex: paramIdx });
23382377
}
23392378
}
2340-
argIndex++;
23412379
}
23422380
}
2381+
paramIdx++;
23432382
}
23442383
}
2384+
2385+
// Thread class name into class_body children; reset for all other contexts.
2386+
let childClass: string | null = null;
2387+
if (t === 'class_declaration' || t === 'class') {
2388+
childClass = node.childForFieldName('name')?.text ?? null;
2389+
} else if (t === 'class_body') {
2390+
childClass = currentClass;
2391+
}
2392+
23452393
for (let i = 0; i < node.childCount; i++) {
2346-
walk(node.child(i)!, depth + 1);
2394+
walk(node.child(i)!, depth + 1, childClass);
23472395
}
23482396
}
2349-
walk(rootNode, 0);
2397+
walk(rootNode, 0, null);
23502398
}
23512399

23522400
/**
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* Integration test for #1336 + #1349: resolve property calls on object
3+
* destructuring rest parameters — both WASM and native engines.
4+
*
5+
* When a function parameter uses object destructuring with a rest element (`...rest`),
6+
* and the rest object's property is then called, codegraph should resolve the callee.
7+
*
8+
* Pattern:
9+
* function f3({ e1: eee1, ...eerest }) { eerest.e4(); }
10+
* f3(obj);
11+
*
12+
* Resolution chain (Phase 8.3f):
13+
* 1. Extractor seeds typeMap['obj.e4'] = { type: 'e4' } from `var obj = { e4 }`.
14+
* 2. Extractor records objectRestParamBinding { callee: 'f3', argIndex: 0, restName: 'eerest' }.
15+
* 3. Extractor records paramBinding { callee: 'f3', argIndex: 0, argName: 'obj' } from f3(obj).
16+
* 4. Phase 8.3f seeds typeMap['eerest'] = { type: 'obj' }.
17+
* 5. resolveByMethodOrGlobal: typeMap['eerest'] → obj; typeMap['obj.e4'] → e4 → resolved.
18+
*
19+
* WASM: resolved via Phase 8.3f typeMap chain in buildCallEdgesJS.
20+
* Native: resolved via same-file name lookup (step 2 in Rust resolve_call_targets);
21+
* the Phase 8.3f post-pass (buildObjectRestParamPostPass) provides the typeMap-chain
22+
* fallback for cross-file cases not directly imported.
23+
*/
24+
25+
import fs from 'node:fs';
26+
import os from 'node:os';
27+
import path from 'node:path';
28+
import Database from 'better-sqlite3';
29+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
30+
import { buildGraph } from '../../src/domain/graph/builder.js';
31+
32+
const FIXTURE_CODE = `
33+
function e1() { console.log('31'); }
34+
function e4() { console.log('34'); }
35+
36+
var obj = { e1, e4 };
37+
38+
function f3({ e1: eee1, ...eerest }) {
39+
eee1(); // call through named destructuring alias
40+
eerest.e4(); // call through rest binding — expected edge: f3 → e4
41+
}
42+
f3(obj);
43+
`;
44+
45+
let tmpWasm: string;
46+
let tmpNative: string;
47+
48+
beforeAll(async () => {
49+
tmpWasm = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1336-wasm-'));
50+
fs.writeFileSync(path.join(tmpWasm, 'rest.js'), FIXTURE_CODE);
51+
await buildGraph(tmpWasm, { engine: 'wasm', incremental: false, skipRegistry: true });
52+
53+
tmpNative = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1336-native-'));
54+
fs.writeFileSync(path.join(tmpNative, 'rest.js'), FIXTURE_CODE);
55+
await buildGraph(tmpNative, { incremental: false, skipRegistry: true });
56+
});
57+
58+
afterAll(() => {
59+
fs.rmSync(tmpWasm, { recursive: true, force: true });
60+
fs.rmSync(tmpNative, { recursive: true, force: true });
61+
});
62+
63+
function readCallEdges(dbPath: string) {
64+
const db = new Database(dbPath, { readonly: true });
65+
try {
66+
return db
67+
.prepare(
68+
`SELECT n1.name AS src, n2.name AS tgt, e.kind, e.dynamic
69+
FROM edges e
70+
JOIN nodes n1 ON e.source_id = n1.id
71+
JOIN nodes n2 ON e.target_id = n2.id
72+
WHERE e.kind = 'calls'
73+
ORDER BY n1.name, n2.name`,
74+
)
75+
.all() as Array<{ src: string; tgt: string; kind: string; dynamic: number }>;
76+
} finally {
77+
db.close();
78+
}
79+
}
80+
81+
describe('Issue #1336 + #1349: object destructuring rest parameter call resolution', () => {
82+
it('WASM: emits a calls edge from f3 to e4 via eerest.e4() rest-receiver resolution', () => {
83+
const edges = readCallEdges(path.join(tmpWasm, '.codegraph', 'graph.db'));
84+
const edge = edges.find((e) => e.src === 'f3' && e.tgt === 'e4');
85+
expect(edge).toBeDefined();
86+
expect(edge!.dynamic).toBe(0);
87+
});
88+
89+
it('Native: emits a calls edge from f3 to e4 via eerest.e4() rest-receiver resolution', () => {
90+
const edges = readCallEdges(path.join(tmpNative, '.codegraph', 'graph.db'));
91+
const edge = edges.find((e) => e.src === 'f3' && e.tgt === 'e4');
92+
expect(edge).toBeDefined();
93+
expect(edge!.dynamic).toBe(0);
94+
});
95+
});

0 commit comments

Comments
 (0)