diff --git a/crates/codegraph-core/src/extractors/c.rs b/crates/codegraph-core/src/extractors/c.rs index 0fc5310b..08a41d47 100644 --- a/crates/codegraph-core/src/extractors/c.rs +++ b/crates/codegraph-core/src/extractors/c.rs @@ -294,15 +294,62 @@ fn match_c_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: u "call_expression" => { if let Some(fn_node) = node.child_by_field_name("function") { + let call_line = start_line(node); match fn_node.kind() { "identifier" => { - symbols.calls.push(Call { - name: node_text(&fn_node, source).to_string(), - line: start_line(node), - dynamic: None, - receiver: None, - ..Default::default() - }); + let fn_name = node_text(&fn_node, source); + // dlsym(handle, "symbol") — dynamic symbol loading + if fn_name == "dlsym" || fn_name == "dlvsym" { + // Get second arg (index 1) — the symbol name + let args = node.child_by_field_name("arguments") + .or_else(|| find_child(node, "argument_list")); + let mut arg_idx = 0usize; + let mut second_arg: Option = None; + if let Some(args) = args { + for i in 0..args.child_count() { + if let Some(child) = args.child(i) { + match child.kind() { + "(" | ")" | "," => continue, + "string_literal" | "string_content" if arg_idx == 1 => { + second_arg = Some(node_text(&child, source) + .replace(&['"', '\''][..], "")); + break; + } + _ => { arg_idx += 1; } + } + } + } + } + match second_arg { + Some(sym) if !sym.is_empty() => { + symbols.calls.push(Call { + name: sym.clone(), + line: call_line, + dynamic: Some(true), + dynamic_kind: Some("reflection".to_string()), + key_expr: Some(sym), + ..Default::default() + }); + } + _ => { + symbols.calls.push(Call { + name: "".to_string(), + line: call_line, + dynamic: Some(true), + dynamic_kind: Some("unresolved-dynamic".to_string()), + ..Default::default() + }); + } + } + } else { + symbols.calls.push(Call { + name: fn_name.to_string(), + line: call_line, + dynamic: None, + receiver: None, + ..Default::default() + }); + } } "field_expression" => { let name = named_child_text(&fn_node, "field", source) @@ -312,21 +359,34 @@ fn match_c_node(node: &Node, source: &[u8], symbols: &mut FileSymbols, _depth: u .map(|s| s.to_string()); symbols.calls.push(Call { name, - line: start_line(node), + line: call_line, dynamic: None, receiver, ..Default::default() }); } - _ => { + // (*fp)(args) — function pointer call; unresolvable + "parenthesized_expression" | "pointer_expression" => { symbols.calls.push(Call { - name: node_text(&fn_node, source).to_string(), - line: start_line(node), - dynamic: None, - receiver: None, + name: "".to_string(), + line: call_line, + dynamic: Some(true), + dynamic_kind: Some("unresolved-dynamic".to_string()), ..Default::default() }); } + _ => { + let name = node_text(&fn_node, source); + if !name.is_empty() { + symbols.calls.push(Call { + name: name.to_string(), + line: call_line, + dynamic: None, + receiver: None, + ..Default::default() + }); + } + } } } } diff --git a/crates/codegraph-core/src/extractors/cpp.rs b/crates/codegraph-core/src/extractors/cpp.rs index cc693c80..1de58a93 100644 --- a/crates/codegraph-core/src/extractors/cpp.rs +++ b/crates/codegraph-core/src/extractors/cpp.rs @@ -323,9 +323,57 @@ fn handle_cpp_preproc_include(node: &Node, source: &[u8], symbols: &mut FileSymb fn handle_cpp_call_expression(node: &Node, source: &[u8], symbols: &mut FileSymbols) { if let Some(fn_node) = node.child_by_field_name("function") { + let call_line = start_line(node); match fn_node.kind() { "identifier" | "qualified_identifier" | "scoped_identifier" => { - push_simple_call(symbols, node, node_text(&fn_node, source).to_string()); + let fn_name = node_text(&fn_node, source); + // dlsym(handle, "symbol") — dynamic symbol loading via C ABI. + // String-literal argument: resolves as reflection (extern "C" symbols are not mangled). + // Variable argument: flagged as unresolved-dynamic. + if fn_name == "dlsym" || fn_name == "dlvsym" { + let args = node.child_by_field_name("arguments") + .or_else(|| find_child(node, "argument_list")); + let mut arg_idx = 0usize; + let mut second_arg: Option = None; + if let Some(args) = args { + for i in 0..args.child_count() { + if let Some(child) = args.child(i) { + match child.kind() { + "(" | ")" | "," => continue, + "string_literal" | "string_content" if arg_idx == 1 => { + second_arg = Some(node_text(&child, source) + .replace(&['"', '\''][..], "")); + break; + } + _ => { arg_idx += 1; } + } + } + } + } + match second_arg { + Some(sym) if !sym.is_empty() => { + symbols.calls.push(Call { + name: sym.clone(), + line: call_line, + dynamic: Some(true), + dynamic_kind: Some("reflection".to_string()), + key_expr: Some(sym), + ..Default::default() + }); + } + _ => { + symbols.calls.push(Call { + name: "".to_string(), + line: call_line, + dynamic: Some(true), + dynamic_kind: Some("unresolved-dynamic".to_string()), + ..Default::default() + }); + } + } + } else { + push_simple_call(symbols, node, fn_name.to_string()); + } } "field_expression" => { let name = named_child_text(&fn_node, "field", source) @@ -335,6 +383,16 @@ fn handle_cpp_call_expression(node: &Node, source: &[u8], symbols: &mut FileSymb .map(|s| s.to_string()); push_call(symbols, node, name, receiver, None); } + // (*fp)(args) — function pointer call through dereference; unresolvable statically + "parenthesized_expression" | "pointer_expression" => { + symbols.calls.push(Call { + name: "".to_string(), + line: call_line, + dynamic: Some(true), + dynamic_kind: Some("unresolved-dynamic".to_string()), + ..Default::default() + }); + } _ => { push_simple_call(symbols, node, node_text(&fn_node, source).to_string()); } diff --git a/crates/codegraph-core/src/extractors/go.rs b/crates/codegraph-core/src/extractors/go.rs index 794ba200..2235d32e 100644 --- a/crates/codegraph-core/src/extractors/go.rs +++ b/crates/codegraph-core/src/extractors/go.rs @@ -195,13 +195,34 @@ fn handle_import_decl(node: &Node, source: &[u8], symbols: &mut FileSymbols) { } } +/// Get the Nth non-punctuation argument from a Go call_expression. +fn get_go_arg(node: &Node, index: usize, source: &[u8]) -> Option<(String, String)> { + let args = node.child_by_field_name("arguments")?; + let mut count = 0usize; + for i in 0..args.child_count() { + let child = args.child(i)?; + match child.kind() { + "(" | ")" | "," => continue, + kind => { + if count == index { + return Some((node_text(&child, source).to_string(), kind.to_string())); + } + count += 1; + } + } + } + None +} + fn handle_call_expr(node: &Node, source: &[u8], symbols: &mut FileSymbols) { let Some(fn_node) = node.child_by_field_name("function") else { return }; + let call_line = start_line(node); + match fn_node.kind() { "identifier" => { symbols.calls.push(Call { name: node_text(&fn_node, source).to_string(), - line: start_line(node), + line: call_line, dynamic: None, receiver: None, ..Default::default() @@ -209,11 +230,48 @@ fn handle_call_expr(node: &Node, source: &[u8], symbols: &mut FileSymbols) { } "selector_expression" => { if let Some(field) = fn_node.child_by_field_name("field") { + let field_name = node_text(&field, source); let receiver = named_child_text(&fn_node, "operand", source) .map(|s| s.to_string()); + + // v.MethodByName("name") — reflect-based dynamic dispatch + if field_name == "MethodByName" { + if let Some((arg_text, arg_kind)) = get_go_arg(node, 0, source) { + match arg_kind.as_str() { + "interpreted_string_literal" | "raw_string_literal" => { + let method = arg_text.replace(&['"', '`'][..], ""); + if !method.is_empty() { + symbols.calls.push(Call { + name: method, + line: call_line, + dynamic: Some(true), + dynamic_kind: Some("reflection".to_string()), + key_expr: Some(arg_text), + receiver, + ..Default::default() + }); + return; + } + } + _ => { + symbols.calls.push(Call { + name: "".to_string(), + line: call_line, + dynamic: Some(true), + dynamic_kind: Some("computed-key".to_string()), + key_expr: Some(arg_text), + receiver, + ..Default::default() + }); + return; + } + } + } + } + symbols.calls.push(Call { - name: node_text(&field, source).to_string(), - line: start_line(node), + name: field_name.to_string(), + line: call_line, dynamic: None, receiver, ..Default::default() diff --git a/src/extractors/c.ts b/src/extractors/c.ts index b9c15a9b..e014b6d1 100644 --- a/src/extractors/c.ts +++ b/src/extractors/c.ts @@ -1,10 +1,4 @@ -import type { - Call, - ExtractorOutput, - SubDeclaration, - TreeSitterNode, - TreeSitterTree, -} from '../types.js'; +import type { ExtractorOutput, SubDeclaration, TreeSitterNode, TreeSitterTree } from '../types.js'; import { findChild, nodeEndLine } from './helpers.js'; /** @@ -142,19 +136,81 @@ function handleCInclude(node: TreeSitterNode, ctx: ExtractorOutput): void { }); } +/** Get the Nth non-punctuation argument from a C call_expression. */ +function getCArg(node: TreeSitterNode, index: number): TreeSitterNode | null { + const args = node.childForFieldName('arguments'); + if (!args) return null; + let count = 0; + for (let i = 0; i < args.childCount; i++) { + const child = args.child(i); + if (!child) continue; + const t = child.type; + if (t === '(' || t === ')' || t === ',' || t === 'comment') continue; + if (count === index) return child; + count++; + } + return null; +} + function handleCCallExpression(node: TreeSitterNode, ctx: ExtractorOutput): void { const funcNode = node.childForFieldName('function'); if (!funcNode) return; - const call: Call = { name: '', line: node.startPosition.row + 1 }; + const callLine = node.startPosition.row + 1; + if (funcNode.type === 'field_expression') { const field = funcNode.childForFieldName('field'); const argument = funcNode.childForFieldName('argument'); - if (field) call.name = field.text; - if (argument) call.receiver = argument.text; - } else { - call.name = funcNode.text; + if (field) { + ctx.calls.push({ + name: field.text, + line: callLine, + ...(argument ? { receiver: argument.text } : {}), + }); + } + return; + } + + // (*fp)(args) — function pointer call through dereference; unresolvable statically + if (funcNode.type === 'parenthesized_expression' || funcNode.type === 'pointer_expression') { + ctx.calls.push({ + name: '', + line: callLine, + dynamic: true, + dynamicKind: 'unresolved-dynamic', + }); + return; + } + + const fnName = funcNode.text; + + // dlsym(handle, "symbol") — dynamic symbol loading + if (fnName === 'dlsym' || fnName === 'dlvsym') { + const nameArg = getCArg(node, 1); // second arg is the symbol name + if (nameArg && (nameArg.type === 'string_literal' || nameArg.type === 'string_content')) { + const sym = nameArg.text.replace(/['"]/g, ''); + if (sym) { + ctx.calls.push({ + name: sym, + line: callLine, + dynamic: true, + dynamicKind: 'reflection', + keyExpr: nameArg.text, + }); + return; + } + } + ctx.calls.push({ + name: '', + line: callLine, + dynamic: true, + dynamicKind: 'unresolved-dynamic', + }); + return; + } + + if (fnName) { + ctx.calls.push({ name: fnName, line: callLine }); } - if (call.name) ctx.calls.push(call); } // ── Child extraction helpers ──────────────────────────────────────────────── diff --git a/src/extractors/cpp.ts b/src/extractors/cpp.ts index 4c76a729..e113a3d2 100644 --- a/src/extractors/cpp.ts +++ b/src/extractors/cpp.ts @@ -1,10 +1,4 @@ -import type { - Call, - ExtractorOutput, - SubDeclaration, - TreeSitterNode, - TreeSitterTree, -} from '../types.js'; +import type { ExtractorOutput, SubDeclaration, TreeSitterNode, TreeSitterTree } from '../types.js'; import { extractModifierVisibility, findChild, @@ -199,6 +193,22 @@ function handleCppTypedef(node: TreeSitterNode, ctx: ExtractorOutput): void { }); } +/** Get the nth non-punctuation argument from a call_expression argument list. */ +function getCppArg(node: TreeSitterNode, index: number): TreeSitterNode | null { + const args = node.childForFieldName('arguments'); + if (!args) return null; + let count = 0; + for (let i = 0; i < args.childCount; i++) { + const child = args.child(i); + if (!child) continue; + const t = child.type; + if (t === '(' || t === ')' || t === ',' || t === 'comment') continue; + if (count === index) return child; + count++; + } + return null; +} + function handleCppInclude(node: TreeSitterNode, ctx: ExtractorOutput): void { const pathNode = node.childForFieldName('path'); if (!pathNode) return; @@ -250,16 +260,61 @@ function handleCppDeclaration(node: TreeSitterNode, ctx: ExtractorOutput): void function handleCppCallExpression(node: TreeSitterNode, ctx: ExtractorOutput): void { const funcNode = node.childForFieldName('function'); if (!funcNode) return; - const call: Call = { name: '', line: node.startPosition.row + 1 }; + const callLine = node.startPosition.row + 1; + if (funcNode.type === 'field_expression') { const field = funcNode.childForFieldName('field'); const argument = funcNode.childForFieldName('argument'); - if (field) call.name = field.text; - if (argument) call.receiver = argument.text; - } else { - call.name = funcNode.text; + if (field) { + ctx.calls.push({ + name: field.text, + line: callLine, + ...(argument ? { receiver: argument.text } : {}), + }); + } + return; } - if (call.name) ctx.calls.push(call); + + // (*fp)(args) — function pointer dereference call; unresolvable statically + if (funcNode.type === 'parenthesized_expression' || funcNode.type === 'pointer_expression') { + ctx.calls.push({ + name: '', + line: callLine, + dynamic: true, + dynamicKind: 'unresolved-dynamic', + }); + return; + } + + const fnName = funcNode.text; + // dlsym(handle, "symbol") — dynamic symbol loading via C ABI. + // String-literal argument: resolves as reflection (extern "C" symbols are not mangled). + // Variable argument: flagged as unresolved-dynamic. + if (fnName === 'dlsym' || fnName === 'dlvsym') { + const nameArg = getCppArg(node, 1); // second arg is the symbol name + if (nameArg && (nameArg.type === 'string_literal' || nameArg.type === 'string_content')) { + const sym = nameArg.text.replace(/['"]/g, ''); + if (sym) { + ctx.calls.push({ + name: sym, + line: callLine, + dynamic: true, + dynamicKind: 'reflection', + keyExpr: nameArg.text, + }); + return; + } + } + ctx.calls.push({ + name: '', + line: callLine, + dynamic: true, + dynamicKind: 'unresolved-dynamic', + }); + return; + } + + if (fnName) ctx.calls.push({ name: fnName, line: callLine }); } // ── Utility helpers ───────────────────────────────────────────────────────── diff --git a/src/extractors/go.ts b/src/extractors/go.ts index 21549134..71016698 100644 --- a/src/extractors/go.ts +++ b/src/extractors/go.ts @@ -226,19 +226,73 @@ function handleGoConstDecl(node: TreeSitterNode, ctx: ExtractorOutput): void { } } +/** Get the Nth non-punctuation argument from a Go call_expression node. */ +function getGoArg(node: TreeSitterNode, index: number): TreeSitterNode | null { + const args = node.childForFieldName('arguments'); + if (!args) return null; + let count = 0; + for (let i = 0; i < args.childCount; i++) { + const child = args.child(i); + if (!child) continue; + const t = child.type; + if (t === '(' || t === ')' || t === ',') continue; + if (count === index) return child; + count++; + } + return null; +} + function handleGoCallExpr(node: TreeSitterNode, ctx: ExtractorOutput): void { const fn = node.childForFieldName('function'); if (!fn) return; + const callLine = node.startPosition.row + 1; + if (fn.type === 'identifier') { - ctx.calls.push({ name: fn.text, line: node.startPosition.row + 1 }); - } else if (fn.type === 'selector_expression') { + ctx.calls.push({ name: fn.text, line: callLine }); + return; + } + + if (fn.type === 'selector_expression') { const field = fn.childForFieldName('field'); - if (field) { - const operand = fn.childForFieldName('operand'); - const call: Call = { name: field.text, line: node.startPosition.row + 1 }; - if (operand) call.receiver = operand.text; - ctx.calls.push(call); + if (!field) return; + const fieldName = field.text; + const operand = fn.childForFieldName('operand'); + + // v.MethodByName("name") — reflect-based dynamic dispatch + if (fieldName === 'MethodByName') { + const nameArg = getGoArg(node, 0); + if ( + nameArg && + (nameArg.type === 'interpreted_string_literal' || nameArg.type === 'raw_string_literal') + ) { + const methodName = nameArg.text.replace(/["`]/g, ''); + if (methodName) { + ctx.calls.push({ + name: methodName, + line: callLine, + dynamic: true, + dynamicKind: 'reflection', + keyExpr: nameArg.text, + receiver: operand?.text, + }); + return; + } + } + // Variable name arg — computed-key + ctx.calls.push({ + name: '', + line: callLine, + dynamic: true, + dynamicKind: 'computed-key', + keyExpr: nameArg?.text, + receiver: operand?.text, + }); + return; } + + const call: Call = { name: fieldName, line: callLine }; + if (operand) call.receiver = operand.text; + ctx.calls.push(call); } } diff --git a/tests/benchmarks/resolution/fixtures/dynamic-c/dispatch.c b/tests/benchmarks/resolution/fixtures/dynamic-c/dispatch.c new file mode 100644 index 00000000..b43a1c95 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/dynamic-c/dispatch.c @@ -0,0 +1,25 @@ +/* Fixture: C dynamic dispatch patterns + * (*fp)(args) → flagged as unresolved-dynamic (function pointer; target unknown) + * dlsym(handle, "symbol") → resolved as reflection (string literal matches symbol in DB) + */ +#include +#include + +void greet(const char *name) { + printf("Hello, %s\n", name); +} + +void farewell(const char *name) { + printf("Goodbye, %s\n", name); +} + +/* (*fp)(args) — function pointer dereference; unresolvable statically */ +void runFunctionPointer(void (*fp)(const char *)) { + (*fp)("world"); +} + +/* dlsym(handle, "symbol") — dynamic symbol loading; flagged */ +void runDlsym(void *handle) { + void (*fn)(const char *) = dlsym(handle, "greet"); + if (fn) fn("world"); +} diff --git a/tests/benchmarks/resolution/fixtures/dynamic-c/expected-edges.json b/tests/benchmarks/resolution/fixtures/dynamic-c/expected-edges.json new file mode 100644 index 00000000..56fb2d3d --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/dynamic-c/expected-edges.json @@ -0,0 +1,14 @@ +{ + "$schema": "../../expected-edges.schema.json", + "language": "c", + "description": "Phase 5 C fixture: (*fp)() flagged unresolved-dynamic; dlsym('greet') resolved as reflection.", + "edges": [ + { + "source": { "name": "runDlsym", "file": "dispatch.c" }, + "target": { "name": "greet", "file": "dispatch.c" }, + "kind": "calls", + "mode": "dynamic", + "notes": "dlsym(handle, 'greet') — reflection kind, resolves to greet() in same unit" + } + ] +} diff --git a/tests/benchmarks/resolution/fixtures/dynamic-go/expected-edges.json b/tests/benchmarks/resolution/fixtures/dynamic-go/expected-edges.json new file mode 100644 index 00000000..caecb300 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/dynamic-go/expected-edges.json @@ -0,0 +1,14 @@ +{ + "$schema": "../../expected-edges.schema.json", + "language": "go", + "description": "Phase 5 Go fixture: reflect.Value.MethodByName resolved; variable name flagged.", + "edges": [ + { + "source": { "name": "runMethodByNameLiteral", "file": "reflect.go" }, + "target": { "name": "Greet", "file": "reflect.go" }, + "kind": "calls", + "mode": "dynamic", + "notes": "v.MethodByName('Greet') — reflection kind, resolves to top-level Greet()" + } + ] +} diff --git a/tests/benchmarks/resolution/fixtures/dynamic-go/reflect.go b/tests/benchmarks/resolution/fixtures/dynamic-go/reflect.go new file mode 100644 index 00000000..e32518cc --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/dynamic-go/reflect.go @@ -0,0 +1,24 @@ +// Fixture: Go reflection patterns +// MethodByName("name") → resolved as reflection kind +// MethodByName(variable) → flagged as computed-key +package main + +import "reflect" + +func Greet(name string) string { + return "Hello, " + name +} + +func Farewell(name string) string { + return "Goodbye, " + name +} + +// v.MethodByName("Greet") — reflection kind, resolved to Greet() +func runMethodByNameLiteral(v reflect.Value) reflect.Value { + return v.MethodByName("Greet") +} + +// v.MethodByName(name) — computed-key kind, flagged as sink edge +func runMethodByNameVariable(v reflect.Value, name string) reflect.Value { + return v.MethodByName(name) +} diff --git a/tests/benchmarks/resolution/resolution-benchmark.test.ts b/tests/benchmarks/resolution/resolution-benchmark.test.ts index ee47787e..4af93c93 100644 --- a/tests/benchmarks/resolution/resolution-benchmark.test.ts +++ b/tests/benchmarks/resolution/resolution-benchmark.test.ts @@ -147,6 +147,9 @@ const THRESHOLDS: Record = { // Phase 4 scripting fixtures: Ruby send/public_send + PHP call_user_func/variable calls. 'dynamic-ruby': { precision: 1.0, recall: 1.0 }, 'dynamic-php': { precision: 1.0, recall: 1.0 }, + // Phase 5: Go + C/C++ dynamic dispatch patterns. + 'dynamic-go': { precision: 1.0, recall: 1.0 }, + 'dynamic-c': { precision: 1.0, recall: 1.0 }, // Phase 2 JVM fixtures — reflection and dynamic dispatch patterns. // Java/Scala/Groovy: detection works but method names are class-qualified in the DB, // so lookup-by-name fails for getMethod("foo") → 0% recall until RES-3 adds type-aware lookup. diff --git a/tests/engines/dynamic-call-ffi.test.ts b/tests/engines/dynamic-call-ffi.test.ts index b500999a..c3431b0b 100644 --- a/tests/engines/dynamic-call-ffi.test.ts +++ b/tests/engines/dynamic-call-ffi.test.ts @@ -10,6 +10,8 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { createParsers, + extractCSymbols, + extractGoSymbols, extractPHPSymbols, extractPythonSymbols, extractRubySymbols, @@ -374,3 +376,69 @@ function test($fn) { expect(c?.dynamicKind).toBe('unresolved-dynamic'); }); }); + +describe('Phase 5: Go MethodByName detection', () => { + let parsers: Awaited>; + beforeAll(async () => { + parsers = await createParsers(); + }, 30_000); + + function parseGo(code: string) { + const parser = getParser(parsers, 'test.go'); + if (!parser) throw new Error('Go parser not available'); + return extractGoSymbols(parser.parse(code), 'test.go'); + } + + it('v.MethodByName("Greet") extracts Greet as reflection kind', () => { + const out = parseGo(`package main +func test(v interface{}) { v.MethodByName("Greet") } +`); + const c = out.calls.find((c) => c.name === 'Greet'); + expect(c).toBeDefined(); + expect(c?.dynamicKind).toBe('reflection'); + expect(c?.dynamic).toBe(true); + }); + + it('v.MethodByName(name) with variable extracts as computed-key', () => { + const out = parseGo(`package main +func test(v interface{}, name string) { v.MethodByName(name) } +`); + const c = out.calls.find((c) => c.name === ''); + expect(c).toBeDefined(); + expect(c?.dynamicKind).toBe('computed-key'); + }); +}); + +describe('Phase 5: C function pointer / dlsym detection', () => { + let parsers: Awaited>; + beforeAll(async () => { + parsers = await createParsers(); + }, 30_000); + + function parseC(code: string) { + const parser = getParser(parsers, 'test.c'); + if (!parser) throw new Error('C parser not available'); + return extractCSymbols(parser.parse(code), 'test.c'); + } + + it('(*fp)(args) function pointer call tags as unresolved-dynamic', () => { + const out = parseC(` +void test(void (*fp)(int)) { (*fp)(42); } +`); + const c = out.calls.find((c) => c.name === ''); + expect(c).toBeDefined(); + expect(c?.dynamicKind).toBe('unresolved-dynamic'); + expect(c?.dynamic).toBe(true); + }); + + it('dlsym(handle, "greet") extracts greet as reflection kind', () => { + const out = parseC(` +#include +void test(void *handle) { dlsym(handle, "greet"); } +`); + const c = out.calls.find((c) => c.name === 'greet'); + expect(c).toBeDefined(); + expect(c?.dynamicKind).toBe('reflection'); + expect(c?.keyExpr).toContain('greet'); + }); +});