From 5637d9a445884dbfc7cbf9c825852b22ad8cef37 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:30:34 -0600 Subject: [PATCH 1/3] 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. --- src/ast-analysis/rules/javascript.ts | 1 + src/ast-analysis/visitors/ast-store-visitor.ts | 13 +++++++++++-- tests/integration/build-parity.test.ts | 3 +-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/ast-analysis/rules/javascript.ts b/src/ast-analysis/rules/javascript.ts index 8140abc4..b4cec274 100644 --- a/src/ast-analysis/rules/javascript.ts +++ b/src/ast-analysis/rules/javascript.ts @@ -237,6 +237,7 @@ export const dataflow: DataflowRulesConfig = makeDataflowRules({ // ─── AST Node Types ─────────────────────────────────────────────────────── export const astTypes: Record | null = { + call_expression: 'call', new_expression: 'new', throw_statement: 'throw', await_expression: 'await', diff --git a/src/ast-analysis/visitors/ast-store-visitor.ts b/src/ast-analysis/visitors/ast-store-visitor.ts index 08c54a4e..604bc45a 100644 --- a/src/ast-analysis/visitors/ast-store-visitor.ts +++ b/src/ast-analysis/visitors/ast-store-visitor.ts @@ -44,6 +44,12 @@ function extractExpressionText(node: TreeSitterNode): string | null { return truncate(node.text); } +function extractCallName(node: TreeSitterNode): string { + const fn = node.childForFieldName('function'); + if (fn) return fn.text; + return node.text?.split('(')[0] || '?'; +} + function extractName(kind: string, node: TreeSitterNode): string | null { if (kind === 'throw') { for (let i = 0; i < node.childCount; i++) { @@ -115,7 +121,10 @@ export function createAstStoreVisitor( let name: string | null | undefined; let text: string | null = null; - if (kind === 'new') { + if (kind === 'call') { + name = extractCallName(node); + text = truncate(node.text); + } else if (kind === 'new') { name = extractNewName(node); text = truncate(node.text); } else if (kind === 'throw') { @@ -146,7 +155,7 @@ export function createAstStoreVisitor( matched.add(node.id); - if (kind !== 'string' && kind !== 'regex') { + if (kind !== 'string' && kind !== 'regex' && kind !== 'call') { return { skipChildren: true }; } }, diff --git a/tests/integration/build-parity.test.ts b/tests/integration/build-parity.test.ts index 94f46cfe..0d1168b9 100644 --- a/tests/integration/build-parity.test.ts +++ b/tests/integration/build-parity.test.ts @@ -120,8 +120,7 @@ describeOrSkip('Build parity: native vs WASM', () => { expect(nativeGraph.roles).toEqual(wasmGraph.roles); }); - // Skip: WASM ast-store-visitor does not extract call-site AST nodes (#674) - it.skip('produces identical ast_nodes', () => { + it('produces identical ast_nodes', () => { const wasmGraph = readGraph(path.join(wasmDir, '.codegraph', 'graph.db')); const nativeGraph = readGraph(path.join(nativeDir, '.codegraph', 'graph.db')); expect(nativeGraph.astNodes).toEqual(wasmGraph.astNodes); From a67cb6440de7740ec5e0e7412880db3d0d08ab37 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:58:24 -0600 Subject: [PATCH 2/3] 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 --- .../visitors/ast-store-visitor.ts | 134 +++++++++++++----- 1 file changed, 97 insertions(+), 37 deletions(-) diff --git a/src/ast-analysis/visitors/ast-store-visitor.ts b/src/ast-analysis/visitors/ast-store-visitor.ts index 604bc45a..a7cdcddd 100644 --- a/src/ast-analysis/visitors/ast-store-visitor.ts +++ b/src/ast-analysis/visitors/ast-store-visitor.ts @@ -45,8 +45,10 @@ function extractExpressionText(node: TreeSitterNode): string | null { } function extractCallName(node: TreeSitterNode): string { - const fn = node.childForFieldName('function'); - if (fn) return fn.text; + for (const field of ['function', 'method', 'name']) { + const fn = node.childForFieldName(field); + if (fn) return fn.text; + } return node.text?.split('(')[0] || '?'; } @@ -108,6 +110,93 @@ export function createAstStoreVisitor( return nodeIdMap.get(`${parentDef.name}|${parentDef.kind}|${parentDef.line}`) || null; } + /** Recursively walk a subtree collecting AST nodes — used for arguments-only traversal. */ + function walkSubtree(node: TreeSitterNode | null): void { + if (!node) return; + if (matched.has(node.id)) return; + + const kind = astTypeMap[node.type]; + if (kind === 'call') { + // Capture this call and recurse only into its arguments + collectNode(node, kind); + walkCallArguments(node); + return; + } + if (kind) { + collectNode(node, kind); + if (kind !== 'string' && kind !== 'regex') return; // skipChildren for non-leaf kinds + } + for (let i = 0; i < node.childCount; i++) { + walkSubtree(node.child(i)); + } + } + + /** + * Recurse into only the arguments of a call node — mirrors the native engine's + * strategy that prevents double-counting nested calls in the function field + * (e.g. chained calls like `a().b()`). + */ + function walkCallArguments(callNode: TreeSitterNode): void { + // Try field-based lookup first, fall back to kind-based matching + const argsNode = + callNode.childForFieldName('arguments') ?? + findChildByKind(callNode, ['arguments', 'argument_list', 'method_arguments']); + if (!argsNode) return; + for (let i = 0; i < argsNode.childCount; i++) { + walkSubtree(argsNode.child(i)); + } + } + + function findChildByKind(node: TreeSitterNode, kinds: string[]): TreeSitterNode | null { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child && kinds.includes(child.type)) return child; + } + return null; + } + + function collectNode(node: TreeSitterNode, kind: string): void { + if (matched.has(node.id)) return; + + const line = node.startPosition.row + 1; + let name: string | null | undefined; + let text: string | null = null; + + if (kind === 'call') { + name = extractCallName(node); + text = truncate(node.text); + } else if (kind === 'new') { + name = extractNewName(node); + text = truncate(node.text); + } else if (kind === 'throw') { + name = extractName('throw', node); + text = extractExpressionText(node); + } else if (kind === 'await') { + name = extractName('await', node); + text = extractExpressionText(node); + } else if (kind === 'string') { + const content = node.text?.replace(/^['"`]|['"`]$/g, '') || ''; + if (content.length < 2) return; + name = truncate(content, 100); + text = truncate(node.text); + } else if (kind === 'regex') { + name = node.text || '?'; + text = truncate(node.text); + } + + rows.push({ + file: relPath, + line, + kind, + name, + text, + receiver: null, + parentNodeId: resolveParentNodeId(line), + }); + + matched.add(node.id); + } + return { name: 'ast-store', @@ -117,45 +206,16 @@ export function createAstStoreVisitor( const kind = astTypeMap[node.type]; if (!kind) return; - const line = node.startPosition.row + 1; - let name: string | null | undefined; - let text: string | null = null; + collectNode(node, kind); if (kind === 'call') { - name = extractCallName(node); - text = truncate(node.text); - } else if (kind === 'new') { - name = extractNewName(node); - text = truncate(node.text); - } else if (kind === 'throw') { - name = extractName('throw', node); - text = extractExpressionText(node); - } else if (kind === 'await') { - name = extractName('await', node); - text = extractExpressionText(node); - } else if (kind === 'string') { - const content = node.text?.replace(/^['"`]|['"`]$/g, '') || ''; - if (content.length < 2) return; - name = truncate(content, 100); - text = truncate(node.text); - } else if (kind === 'regex') { - name = node.text || '?'; - text = truncate(node.text); + // Mirror native: skip full subtree, recurse only into arguments. + // Prevents double-counting chained calls like service.getUser().getName(). + walkCallArguments(node); + return { skipChildren: true }; } - rows.push({ - file: relPath, - line, - kind, - name, - text, - receiver: null, - parentNodeId: resolveParentNodeId(line), - }); - - matched.add(node.id); - - if (kind !== 'string' && kind !== 'regex' && kind !== 'call') { + if (kind !== 'string' && kind !== 'regex') { return { skipChildren: true }; } }, From e412edf1638ee04a495fdb640cffda5d82daf96e Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:58:56 -0600 Subject: [PATCH 3/3] 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. --- tests/fixtures/sample-project/utils.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/fixtures/sample-project/utils.js b/tests/fixtures/sample-project/utils.js index 3db1da08..ccc4226d 100644 --- a/tests/fixtures/sample-project/utils.js +++ b/tests/fixtures/sample-project/utils.js @@ -10,4 +10,9 @@ class Calculator { } } -module.exports = { sumOfSquares, Calculator }; +// Chained call — exercises call-in-function-field (a().b()) parity +function formatResults(items) { + return items.filter(Boolean).map(String); +} + +module.exports = { sumOfSquares, Calculator, formatResults };