Skip to content

Commit bf4b417

Browse files
authored
fix(wasm): extract call-site AST nodes in ast-store-visitor (#678)
* fix(wasm): extract call-site AST nodes in ast-store-visitor (#674) Add `call_expression: 'call'` to the WASM `astTypes` map and implement `extractCallName` in the ast-store visitor to match the native engine's call-site extraction. Un-skip the ast_nodes parity test now that both engines produce identical results. * fix(wasm): match native arguments-only recursion and multi-field call name extraction (#678) Mirror the native engine's call-site handling in ast-store-visitor: - Return skipChildren for call nodes, recurse only into arguments subtree to prevent double-counting chained calls like a().b() - Check function/method/name fields in extractCallName to match native extract_call_name fallback order * test(fixture): add chained-call pattern to exercise call-in-function-field parity (#678) Add formatResults() with items.filter(Boolean).map(String) to the sample-project fixture. This ensures the parity test catches divergence in chained call handling between WASM and native engines.
1 parent a1d8d4b commit bf4b417

4 files changed

Lines changed: 110 additions & 36 deletions

File tree

src/ast-analysis/rules/javascript.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ export const dataflow: DataflowRulesConfig = makeDataflowRules({
237237
// ─── AST Node Types ───────────────────────────────────────────────────────
238238

239239
export const astTypes: Record<string, string> | null = {
240+
call_expression: 'call',
240241
new_expression: 'new',
241242
throw_statement: 'throw',
242243
await_expression: 'await',

src/ast-analysis/visitors/ast-store-visitor.ts

Lines changed: 102 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ function extractExpressionText(node: TreeSitterNode): string | null {
4444
return truncate(node.text);
4545
}
4646

47+
function extractCallName(node: TreeSitterNode): string {
48+
for (const field of ['function', 'method', 'name']) {
49+
const fn = node.childForFieldName(field);
50+
if (fn) return fn.text;
51+
}
52+
return node.text?.split('(')[0] || '?';
53+
}
54+
4755
function extractName(kind: string, node: TreeSitterNode): string | null {
4856
if (kind === 'throw') {
4957
for (let i = 0; i < node.childCount; i++) {
@@ -102,6 +110,93 @@ export function createAstStoreVisitor(
102110
return nodeIdMap.get(`${parentDef.name}|${parentDef.kind}|${parentDef.line}`) || null;
103111
}
104112

113+
/** Recursively walk a subtree collecting AST nodes — used for arguments-only traversal. */
114+
function walkSubtree(node: TreeSitterNode | null): void {
115+
if (!node) return;
116+
if (matched.has(node.id)) return;
117+
118+
const kind = astTypeMap[node.type];
119+
if (kind === 'call') {
120+
// Capture this call and recurse only into its arguments
121+
collectNode(node, kind);
122+
walkCallArguments(node);
123+
return;
124+
}
125+
if (kind) {
126+
collectNode(node, kind);
127+
if (kind !== 'string' && kind !== 'regex') return; // skipChildren for non-leaf kinds
128+
}
129+
for (let i = 0; i < node.childCount; i++) {
130+
walkSubtree(node.child(i));
131+
}
132+
}
133+
134+
/**
135+
* Recurse into only the arguments of a call node — mirrors the native engine's
136+
* strategy that prevents double-counting nested calls in the function field
137+
* (e.g. chained calls like `a().b()`).
138+
*/
139+
function walkCallArguments(callNode: TreeSitterNode): void {
140+
// Try field-based lookup first, fall back to kind-based matching
141+
const argsNode =
142+
callNode.childForFieldName('arguments') ??
143+
findChildByKind(callNode, ['arguments', 'argument_list', 'method_arguments']);
144+
if (!argsNode) return;
145+
for (let i = 0; i < argsNode.childCount; i++) {
146+
walkSubtree(argsNode.child(i));
147+
}
148+
}
149+
150+
function findChildByKind(node: TreeSitterNode, kinds: string[]): TreeSitterNode | null {
151+
for (let i = 0; i < node.childCount; i++) {
152+
const child = node.child(i);
153+
if (child && kinds.includes(child.type)) return child;
154+
}
155+
return null;
156+
}
157+
158+
function collectNode(node: TreeSitterNode, kind: string): void {
159+
if (matched.has(node.id)) return;
160+
161+
const line = node.startPosition.row + 1;
162+
let name: string | null | undefined;
163+
let text: string | null = null;
164+
165+
if (kind === 'call') {
166+
name = extractCallName(node);
167+
text = truncate(node.text);
168+
} else if (kind === 'new') {
169+
name = extractNewName(node);
170+
text = truncate(node.text);
171+
} else if (kind === 'throw') {
172+
name = extractName('throw', node);
173+
text = extractExpressionText(node);
174+
} else if (kind === 'await') {
175+
name = extractName('await', node);
176+
text = extractExpressionText(node);
177+
} else if (kind === 'string') {
178+
const content = node.text?.replace(/^['"`]|['"`]$/g, '') || '';
179+
if (content.length < 2) return;
180+
name = truncate(content, 100);
181+
text = truncate(node.text);
182+
} else if (kind === 'regex') {
183+
name = node.text || '?';
184+
text = truncate(node.text);
185+
}
186+
187+
rows.push({
188+
file: relPath,
189+
line,
190+
kind,
191+
name,
192+
text,
193+
receiver: null,
194+
parentNodeId: resolveParentNodeId(line),
195+
});
196+
197+
matched.add(node.id);
198+
}
199+
105200
return {
106201
name: 'ast-store',
107202

@@ -111,40 +206,14 @@ export function createAstStoreVisitor(
111206
const kind = astTypeMap[node.type];
112207
if (!kind) return;
113208

114-
const line = node.startPosition.row + 1;
115-
let name: string | null | undefined;
116-
let text: string | null = null;
117-
118-
if (kind === 'new') {
119-
name = extractNewName(node);
120-
text = truncate(node.text);
121-
} else if (kind === 'throw') {
122-
name = extractName('throw', node);
123-
text = extractExpressionText(node);
124-
} else if (kind === 'await') {
125-
name = extractName('await', node);
126-
text = extractExpressionText(node);
127-
} else if (kind === 'string') {
128-
const content = node.text?.replace(/^['"`]|['"`]$/g, '') || '';
129-
if (content.length < 2) return;
130-
name = truncate(content, 100);
131-
text = truncate(node.text);
132-
} else if (kind === 'regex') {
133-
name = node.text || '?';
134-
text = truncate(node.text);
135-
}
209+
collectNode(node, kind);
136210

137-
rows.push({
138-
file: relPath,
139-
line,
140-
kind,
141-
name,
142-
text,
143-
receiver: null,
144-
parentNodeId: resolveParentNodeId(line),
145-
});
146-
147-
matched.add(node.id);
211+
if (kind === 'call') {
212+
// Mirror native: skip full subtree, recurse only into arguments.
213+
// Prevents double-counting chained calls like service.getUser().getName().
214+
walkCallArguments(node);
215+
return { skipChildren: true };
216+
}
148217

149218
if (kind !== 'string' && kind !== 'regex') {
150219
return { skipChildren: true };

tests/fixtures/sample-project/utils.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,9 @@ class Calculator {
1010
}
1111
}
1212

13-
module.exports = { sumOfSquares, Calculator };
13+
// Chained call — exercises call-in-function-field (a().b()) parity
14+
function formatResults(items) {
15+
return items.filter(Boolean).map(String);
16+
}
17+
18+
module.exports = { sumOfSquares, Calculator, formatResults };

tests/integration/build-parity.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,7 @@ describeOrSkip('Build parity: native vs WASM', () => {
120120
expect(nativeGraph.roles).toEqual(wasmGraph.roles);
121121
});
122122

123-
// Skip: WASM ast-store-visitor does not extract call-site AST nodes (#674)
124-
it.skip('produces identical ast_nodes', () => {
123+
it('produces identical ast_nodes', () => {
125124
const wasmGraph = readGraph(path.join(wasmDir, '.codegraph', 'graph.db'));
126125
const nativeGraph = readGraph(path.join(nativeDir, '.codegraph', 'graph.db'));
127126
expect(nativeGraph.astNodes).toEqual(wasmGraph.astNodes);

0 commit comments

Comments
 (0)