Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 115 additions & 1 deletion src/domain/graph/builder/stages/build-edges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,98 @@ function buildFnRefBindingsPtsPostPass(
}
}

/**
* Phase 8.3f post-pass for the native call-edge path.
*
* The native Rust engine builds call edges without knowledge of
* objectRestParamBindings, so `rest.method()` calls inside functions with
* object-destructuring rest parameters are not resolved via the typeMap chain.
* The Rust engine already resolves same-file and directly-imported callees
* (via steps 1–2 of its resolution logic), so this post-pass only adds edges
* that require the typeMap-chain path:
* typeMap[restName] → argName → typeMap[argName.method] → target
*
* Mirrors the seeding in buildCallEdgesJS (Phase 8.3f) to ensure both engine
* paths produce identical results for receiver-typed rest-param calls.
*/
function buildObjectRestParamPostPass(
ctx: PipelineContext,
getNodeIdStmt: NodeIdStmt,
allEdgeRows: EdgeRowTuple[],
sharedLookup?: CallNodeLookup,
): void {
const filesWithRestBindings = [...ctx.fileSymbols].filter(
([, symbols]) =>
symbols.objectRestParamBindings &&
symbols.objectRestParamBindings.length > 0 &&
symbols.paramBindings &&
symbols.paramBindings.length > 0,
);
if (filesWithRestBindings.length === 0) return;

const seenByPair = new Set<string>();
for (const [srcId, tgtId] of allEdgeRows) {
seenByPair.add(`${srcId}|${tgtId}`);
}

const { barrelOnlyFiles, rootDir } = ctx;
const lookup = sharedLookup ?? makeContextLookup(ctx, getNodeIdStmt);

for (const [relPath, symbols] of filesWithRestBindings) {
if (barrelOnlyFiles.has(relPath)) continue;
const fileNodeRow = getNodeIdStmt.get(relPath, 'file', relPath, 0);
if (!fileNodeRow) continue;

const importedNames = buildImportedNamesMap(ctx, relPath, symbols, rootDir);
const typeMap: Map<string, TypeMapEntry | string> = new Map(
symbols.typeMap instanceof Map ? symbols.typeMap : [],
);

// Seed typeMap[restName] = { type: argName } for each matching pair.
// Mirrors the seeding in buildCallEdgesJS Phase 8.3f.
const restNames = new Set<string>();
for (const orpb of symbols.objectRestParamBindings!) {
for (const pb of symbols.paramBindings!) {
if (pb.callee === orpb.callee && pb.argIndex === orpb.argIndex) {
if (!typeMap.has(orpb.restName)) {
typeMap.set(orpb.restName, { type: pb.argName, confidence: 0.65 });
restNames.add(orpb.restName);
}
}
}
}
Comment on lines +761 to +771

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Rest-param name collision when two functions share the same rest-binding identifier

The typeMap key is orpb.restName (e.g., "rest", "opts", "props") with no function-scope qualifier. If a file contains two functions that each destructure-rest with the same name — a common pattern — the !typeMap.has(orpb.restName) guard silently discards the second seeding. Any rest.method() call in the second function will then be resolved using the first function's argument type, producing incorrect call edges. The same issue is present in the WASM path's Phase 8.3f seeding block in buildCallEdgesJS.

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This scoping collision was filed as issue #1358 and deferred — fixing it requires keying typeMap entries by function scope rather than just restName, which coordinates changes in seeding + resolution lookup. The current PR addresses the primary pattern documented in the fixture test. The gap is tracked and not lost.

if (restNames.size === 0) continue;

for (const call of symbols.calls) {
// Only process calls whose receiver is a known rest-binding name.
if (!call.receiver || !restNames.has(call.receiver)) continue;

const caller = findCaller(lookup, call, symbols.definitions, relPath, fileNodeRow);

// Resolve with the enriched typeMap (typeMap[restName] = { type: argName } is seeded above).
// seenByPair deduplicates edges the native engine already emitted.
const { targets, importedFrom } = resolveCallTargets(
lookup,
call,
relPath,
importedNames,
typeMap as Map<string, unknown>,
);
for (const t of targets) {
const edgeKey = `${caller.id}|${t.id}`;
if (t.id !== caller.id && !seenByPair.has(edgeKey)) {
const conf =
computeConfidence(relPath, t.file, importedFrom ?? null) - PROPAGATION_HOP_PENALTY;
if (conf > 0) {
seenByPair.add(edgeKey);
allEdgeRows.push([caller.id, t.id, 'calls', conf, 0, 'points-to']);
}
}
}
}
}
}

/**
* Object.defineProperty accessor post-pass for the native call-edge path.
*
Expand Down Expand Up @@ -923,7 +1015,26 @@ function buildCallEdgesJS(
if (!fileNodeRow) continue;

const importedNames = buildImportedNamesMap(ctx, relPath, symbols, rootDir);
const typeMap: Map<string, TypeMapEntry | string> = symbols.typeMap || new Map();
const typeMap: Map<string, TypeMapEntry | string> = new Map(
symbols.typeMap instanceof Map ? symbols.typeMap : [],
);

// Phase 8.3f: seed typeMap[restName] = { type: argName } for each object-destructuring
// rest parameter binding cross-referenced with call-site argument bindings.
// e.g. function f({ a, ...rest }) called as f(obj) → typeMap['rest'] = { type: 'obj' }
// so that `rest.method()` resolves via typeMap['obj.method'].
if (symbols.objectRestParamBindings?.length && symbols.paramBindings?.length) {
for (const orpb of symbols.objectRestParamBindings) {
for (const pb of symbols.paramBindings) {
if (pb.callee === orpb.callee && pb.argIndex === orpb.argIndex) {
if (!typeMap.has(orpb.restName)) {
typeMap.set(orpb.restName, { type: pb.argName, confidence: 0.65 });
}
}
}
}
}

const seenCallEdges = new Set<string>();
const ptsMap = buildPointsToMapForFile(symbols, importedNames);

Expand Down Expand Up @@ -1679,6 +1790,9 @@ export async function buildEdges(ctx: PipelineContext): Promise<void> {
// (e.g. `const f = fn.bind(ctx)`), so calls to bind-created aliases are
// not resolved to their original function on the native path.
buildFnRefBindingsPtsPostPass(ctx, getNodeIdStmt, allEdgeRows, sharedLookup);
// Phase 8.3f post-pass: augment native call edges with object rest-param
// receiver resolution — typeMap[restName] → argName → typeMap[argName.method].
buildObjectRestParamPostPass(ctx, getNodeIdStmt, allEdgeRows, sharedLookup);
// Object.defineProperty accessor post-pass: resolve this-dispatch inside
// getter/setter functions registered via Object.defineProperty.
buildDefinePropertyPostPass(ctx, getNodeIdStmt, allEdgeRows, sharedLookup);
Expand Down
122 changes: 85 additions & 37 deletions src/extractors/javascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2293,60 +2293,108 @@ function extractSpreadForOfWalk(
}

/**
* Phase 8.3f: collect object-rest parameter bindings.
* Phase 8.3f: record object-destructuring rest-parameter bindings from function definitions.
*
* `function f({ a, ...rest }) {}` → `{ callee: "f", restName: "rest", argIndex: 0 }`
*
* Enables resolving `rest.prop()` when a known object is passed as that parameter.
* For each `function f({ a, ...rest })` (or arrow/function-expression equivalent),
* records { callee: 'f', restName: 'rest', argIndex: N }. Also covers class methods
* (`callee: 'ClassName.method'`) and object-literal methods (`callee: 'method'`).
* The edge builder uses these to seed typeMap[rest] = { type: argName } when f(obj)
* is called with an identifier, enabling `rest.method()` calls to resolve.
*/
function extractObjectRestParamBindingsWalk(
rootNode: TreeSitterNode,
bindings: ObjectRestParamBinding[],
): void {
function walk(node: TreeSitterNode, depth: number): void {
function walk(node: TreeSitterNode, depth: number, currentClass: string | null): void {
if (depth >= MAX_WALK_DEPTH) return;
const t = node.type;
if (t === 'function_declaration' || t === 'function_expression' || t === 'arrow_function') {
// `function_declaration` has a `name` field; `arrow_function` and
// `function_expression` do not — get the name from the enclosing
// `variable_declarator` instead (e.g. `const f3 = ({ ...rest }) => {}`).
const nameNode =
node.childForFieldName('name') ??
(node.parent?.type === 'variable_declarator'
? node.parent.childForFieldName('name')
: null);
const funcName = nameNode?.text;
if (funcName) {
const paramsNode =
node.childForFieldName('parameters') || findChild(node, 'formal_parameters');
if (paramsNode) {
let argIndex = 0;
for (let i = 0; i < paramsNode.childCount; i++) {
const param = paramsNode.child(i);
if (!param) continue;
const pt = param.type;
if (pt === ',' || pt === '(' || pt === ')') continue;
if (pt === 'object_pattern') {
for (let j = 0; j < param.childCount; j++) {
const child = param.child(j);
if (!child) continue;
if (child.type !== 'rest_pattern' && child.type !== 'rest_element') continue;
const restNameNode = child.child(1) ?? child.childForFieldName('name');
if (restNameNode?.type === 'identifier') {
bindings.push({ callee: funcName, restName: restNameNode.text, argIndex });
}
let fnName: string | null = null;
let paramsNode: TreeSitterNode | null = null;

if (t === 'function_declaration' || t === 'generator_function_declaration') {
const nameN = node.childForFieldName('name');
if (nameN?.type === 'identifier') fnName = nameN.text;
paramsNode = node.childForFieldName('parameters') ?? findChild(node, 'formal_parameters');
} else if (t === 'variable_declarator') {
const nameN = node.childForFieldName('name');
const valueN = node.childForFieldName('value');
if (nameN?.type === 'identifier' && valueN) {
const vt = valueN.type;
if (
vt === 'arrow_function' ||
vt === 'function_expression' ||
vt === 'generator_function'
) {
fnName = nameN.text;
paramsNode =
valueN.childForFieldName('parameters') ?? findChild(valueN, 'formal_parameters');
}
}
} else if (t === 'method_definition') {
// class method: `class Foo { bar({ a, ...rest }) {} }`
// object-literal shorthand method: `{ bar({ a, ...rest }) {} }`
const nameN = node.childForFieldName('name');
if (nameN) {
fnName = currentClass ? `${currentClass}.${nameN.text}` : nameN.text;
paramsNode = node.childForFieldName('parameters') ?? findChild(node, 'formal_parameters');
}
} else if (t === 'pair') {
// object-literal method: `{ bar: function({ a, ...rest }) {} }`
// Skip computed property keys (e.g. `{ [Symbol.iterator]: function({ ...rest }) {} }`)
// because `callee: '[Symbol.iterator]'` can never match a paramBinding callee.
const keyN = node.childForFieldName('key');
const valueN = node.childForFieldName('value');
if (keyN && valueN && keyN.type !== 'computed_property_name') {
const vt = valueN.type;
if (
vt === 'arrow_function' ||
vt === 'function_expression' ||
vt === 'generator_function'
) {
fnName = keyN.type === 'string' ? keyN.text.slice(1, -1) : keyN.text;
paramsNode =
valueN.childForFieldName('parameters') ?? findChild(valueN, 'formal_parameters');
}
}
}

if (fnName && paramsNode) {
let paramIdx = 0;
for (let i = 0; i < paramsNode.childCount; i++) {
const child = paramsNode.child(i);
if (!child) continue;
const ct = child.type;
if (ct === ',' || ct === '(' || ct === ')') continue;
if (ct === 'object_pattern') {
for (let j = 0; j < child.childCount; j++) {
const inner = child.child(j);
if (!inner) continue;
if (inner.type === 'rest_pattern' || inner.type === 'rest_element') {
// rest_pattern node: `...identifier` — the identifier is at child index 1
const restId = inner.child(1) ?? inner.childForFieldName('name');
if (restId?.type === 'identifier') {
bindings.push({ callee: fnName, restName: restId.text, argIndex: paramIdx });
}
}
argIndex++;
}
}
paramIdx++;
}
}

// Thread class name into class_body children; reset for all other contexts.
let childClass: string | null = null;
if (t === 'class_declaration' || t === 'class') {
childClass = node.childForFieldName('name')?.text ?? null;
} else if (t === 'class_body') {
childClass = currentClass;
}

for (let i = 0; i < node.childCount; i++) {
walk(node.child(i)!, depth + 1);
walk(node.child(i)!, depth + 1, childClass);
}
}
walk(rootNode, 0);
walk(rootNode, 0, null);
}

/**
Expand Down
95 changes: 95 additions & 0 deletions tests/integration/issue-1336-object-rest-param-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* Integration test for #1336 + #1349: resolve property calls on object
* destructuring rest parameters — both WASM and native engines.
*
* When a function parameter uses object destructuring with a rest element (`...rest`),
* and the rest object's property is then called, codegraph should resolve the callee.
*
* Pattern:
* function f3({ e1: eee1, ...eerest }) { eerest.e4(); }
* f3(obj);
*
* Resolution chain (Phase 8.3f):
* 1. Extractor seeds typeMap['obj.e4'] = { type: 'e4' } from `var obj = { e4 }`.
* 2. Extractor records objectRestParamBinding { callee: 'f3', argIndex: 0, restName: 'eerest' }.
* 3. Extractor records paramBinding { callee: 'f3', argIndex: 0, argName: 'obj' } from f3(obj).
* 4. Phase 8.3f seeds typeMap['eerest'] = { type: 'obj' }.
* 5. resolveByMethodOrGlobal: typeMap['eerest'] → obj; typeMap['obj.e4'] → e4 → resolved.
*
* WASM: resolved via Phase 8.3f typeMap chain in buildCallEdgesJS.
* Native: resolved via same-file name lookup (step 2 in Rust resolve_call_targets);
* the Phase 8.3f post-pass (buildObjectRestParamPostPass) provides the typeMap-chain
* fallback for cross-file cases not directly imported.
*/

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import Database from 'better-sqlite3';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { buildGraph } from '../../src/domain/graph/builder.js';

const FIXTURE_CODE = `
function e1() { console.log('31'); }
function e4() { console.log('34'); }

var obj = { e1, e4 };

function f3({ e1: eee1, ...eerest }) {
eee1(); // call through named destructuring alias
eerest.e4(); // call through rest binding — expected edge: f3 → e4
}
f3(obj);
`;

let tmpWasm: string;
let tmpNative: string;

beforeAll(async () => {
tmpWasm = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1336-wasm-'));
fs.writeFileSync(path.join(tmpWasm, 'rest.js'), FIXTURE_CODE);
await buildGraph(tmpWasm, { engine: 'wasm', incremental: false, skipRegistry: true });

tmpNative = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1336-native-'));
fs.writeFileSync(path.join(tmpNative, 'rest.js'), FIXTURE_CODE);
await buildGraph(tmpNative, { incremental: false, skipRegistry: true });
});

afterAll(() => {
fs.rmSync(tmpWasm, { recursive: true, force: true });
fs.rmSync(tmpNative, { recursive: true, force: true });
});

function readCallEdges(dbPath: string) {
const db = new Database(dbPath, { readonly: true });
try {
return db
.prepare(
`SELECT n1.name AS src, n2.name AS tgt, e.kind, e.dynamic
FROM edges e
JOIN nodes n1 ON e.source_id = n1.id
JOIN nodes n2 ON e.target_id = n2.id
WHERE e.kind = 'calls'
ORDER BY n1.name, n2.name`,
)
.all() as Array<{ src: string; tgt: string; kind: string; dynamic: number }>;
} finally {
db.close();
}
}

describe('Issue #1336 + #1349: object destructuring rest parameter call resolution', () => {
it('WASM: emits a calls edge from f3 to e4 via eerest.e4() rest-receiver resolution', () => {
const edges = readCallEdges(path.join(tmpWasm, '.codegraph', 'graph.db'));
const edge = edges.find((e) => e.src === 'f3' && e.tgt === 'e4');
expect(edge).toBeDefined();
expect(edge!.dynamic).toBe(0);
});

it('Native: emits a calls edge from f3 to e4 via eerest.e4() rest-receiver resolution', () => {
const edges = readCallEdges(path.join(tmpNative, '.codegraph', 'graph.db'));
const edge = edges.find((e) => e.src === 'f3' && e.tgt === 'e4');
expect(edge).toBeDefined();
expect(edge!.dynamic).toBe(0);
});
Comment on lines +89 to +94

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The native engine test only asserts edge is defined, omitting the dynamic: 0 check that the WASM test includes. This means a dynamic edge (which would indicate incorrect resolution) would silently pass on the native path.

Suggested change
it('Native: emits a calls edge from f3 to e4 via eerest.e4() rest-receiver resolution', () => {
const edges = readCallEdges(path.join(tmpNative, '.codegraph', 'graph.db'));
const edge = edges.find((e) => e.src === 'f3' && e.tgt === 'e4');
expect(edge).toBeDefined();
});
it('Native: emits a calls edge from f3 to e4 via eerest.e4() rest-receiver resolution', () => {
const edges = readCallEdges(path.join(tmpNative, '.codegraph', 'graph.db'));
const edge = edges.find((e) => e.src === 'f3' && e.tgt === 'e4');
expect(edge).toBeDefined();
expect(edge!.dynamic).toBe(0);
});

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added expect(edge!.dynamic).toBe(0) to the native engine test at line 93 (commit 054c953). The assertion now matches the WASM test, so a dynamic-dispatch fallback in the native engine will be caught as a regression.

});
Loading
Loading