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
4 changes: 2 additions & 2 deletions crates/codegraph-core/src/extractors/cpp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,8 +328,8 @@ fn handle_cpp_call_expression(node: &Node, source: &[u8], symbols: &mut FileSymb
"identifier" | "qualified_identifier" | "scoped_identifier" => {
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.
// String-literal second arg: resolves as reflection (C symbols are unmangled, name-match works).
// Variable second arg: 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"));
Expand Down
1 change: 1 addition & 0 deletions crates/codegraph-core/src/extractors/ruby.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ fn handle_singleton_method(node: &Node, source: &[u8], symbols: &mut FileSymbols
}

/// Get the first non-punctuation argument from a Ruby call node's argument list.
/// Tries `child_by_field_name("arguments")` first, then falls back to `argument_list`.
fn get_first_ruby_arg<'a>(node: &Node<'a>) -> Option<Node<'a>> {
let args = node.child_by_field_name("arguments")
.or_else(|| find_child(node, "argument_list"))?;
Expand Down
1 change: 1 addition & 0 deletions src/domain/analysis/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function dynamicCallsData(customDbPath: string): DynamicCallCount[] {
`SELECT dynamic_kind, COUNT(*) AS count
FROM edges
WHERE dynamic_kind IS NOT NULL
AND confidence = 0
GROUP BY dynamic_kind
ORDER BY count DESC`,
)
Expand Down
86 changes: 76 additions & 10 deletions src/extractors/csharp.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type {
Call,
ClassRelation,
ExtractorOutput,
SubDeclaration,
Expand Down Expand Up @@ -227,22 +226,89 @@ function handleCsUsingDirective(node: TreeSitterNode, ctx: ExtractorOutput): voi
});
}

/** Get the first string-literal argument text from a C# invocation node. */
function getCsFirstStringArg(node: TreeSitterNode): string | null {
const args = node.childForFieldName('argument_list') || findChild(node, 'argument_list');
if (!args) return null;
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;
// argument node may wrap the literal
const target = t === 'argument' ? (child.child(0) ?? child) : child;
if (target?.type === 'string_literal' || target?.type === 'verbatim_string_literal') {
return target.text.replace(/^["@]+|"$/g, '');
}
break;
}
return null;
}

function handleCsInvocationExpr(node: TreeSitterNode, ctx: ExtractorOutput): void {
const fn = node.childForFieldName('function') || node.child(0);
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 === 'member_access_expression') {
ctx.calls.push({ name: fn.text, line: callLine });
return;
}

if (fn.type === 'member_access_expression') {
const name = fn.childForFieldName('name');
if (name) {
const expr = fn.childForFieldName('expression');
const call: Call = { name: name.text, line: node.startPosition.row + 1 };
if (expr) call.receiver = expr.text;
ctx.calls.push(call);
if (!name) return;
const methodName = name.text;
const expr = fn.childForFieldName('expression');
const receiver = expr?.text;

// method.Invoke(target, args) — runtime reflection; target unknown
if (methodName === 'Invoke') {
ctx.calls.push({
name: '<dynamic:unresolved>',
line: callLine,
dynamic: true,
dynamicKind: 'unresolved-dynamic',
receiver,
});
return;
}
} else if (fn.type === 'generic_name' || fn.type === 'member_binding_expression') {

// type.GetMethod("name") / GetRuntimeMethod("name") — resolvable if literal
if (
methodName === 'GetMethod' ||
methodName === 'GetRuntimeMethod' ||
methodName === 'GetDeclaredMethod'
) {
const literal = getCsFirstStringArg(node);
if (literal) {
ctx.calls.push({
name: literal,
line: callLine,
dynamic: true,
dynamicKind: 'reflection',
keyExpr: literal,
receiver,
});
} else {
ctx.calls.push({
name: '<dynamic:computed-key>',
line: callLine,
dynamic: true,
dynamicKind: 'computed-key',
receiver,
});
}
return;
}

ctx.calls.push({ name: methodName, line: callLine, ...(receiver ? { receiver } : {}) });
return;
}

if (fn.type === 'generic_name' || fn.type === 'member_binding_expression') {
const name = fn.childForFieldName('name') || fn.child(0);
if (name) ctx.calls.push({ name: name.text, line: node.startPosition.row + 1 });
if (name) ctx.calls.push({ name: name.text, line: callLine });
}
}

Expand Down
44 changes: 44 additions & 0 deletions src/extractors/elixir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,50 @@ function handleElixirCall(
case 'alias':
handleElixirImport(node, ctx, keyword);
return;
case 'apply': {
// apply(module, :function, args) — Elixir dynamic dispatch
const applyArgs = node.childForFieldName('arguments');
if (applyArgs) {
let argIdx = 0;
let secondArg: TreeSitterNode | null = null;
for (let i = 0; i < applyArgs.childCount; i++) {
const child = applyArgs.child(i);
if (!child) continue;
const t = child.type;
if (t === '(' || t === ')' || t === ',') continue;
if (argIdx === 1) {
secondArg = child;
break;
}
argIdx++;
}
if (secondArg) {
// :atom — strip leading colon to get function name
if (secondArg.type === 'atom' || secondArg.type === 'atom_literal') {
const fnName = secondArg.text.replace(/^:/, '');
ctx.calls.push({
name: fnName,
line: node.startPosition.row + 1,
dynamic: true,
dynamicKind: 'reflection',
keyExpr: secondArg.text,
});
return;
}
// Variable function name — computed-key
ctx.calls.push({
name: '<dynamic:computed-key>',
line: node.startPosition.row + 1,
dynamic: true,
dynamicKind: 'computed-key',
keyExpr: secondArg.text,
});
return;
}
}
ctx.calls.push({ name: 'apply', line: node.startPosition.row + 1 });
return;
}
default:
// Regular function call
ctx.calls.push({ name: keyword, line: node.startPosition.row + 1 });
Expand Down
45 changes: 45 additions & 0 deletions src/extractors/lua.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,20 @@ function handleLuaFunctionCall(node: TreeSitterNode, ctx: ExtractorOutput): void
const nameNode = node.childForFieldName('name');
if (!nameNode) return;

// load(chunk) / loadstring(chunk) — dynamic code execution; always undecidable
if (
nameNode.type === 'identifier' &&
(nameNode.text === 'load' || nameNode.text === 'loadstring' || nameNode.text === 'dofile')
) {
ctx.calls.push({
name: '<dynamic:eval>',
line: node.startPosition.row + 1,
dynamic: true,
dynamicKind: 'eval',
});
return;
}

// Check for require() as import
if (nameNode.type === 'identifier' && nameNode.text === 'require') {
const args = node.childForFieldName('arguments');
Expand Down Expand Up @@ -161,6 +175,37 @@ function handleLuaFunctionCall(node: TreeSitterNode, ctx: ExtractorOutput): void
const field = nameNode.childForFieldName('field');
if (field) call.name = field.text;
if (table) call.receiver = table.text;
} else if (nameNode.type === 'bracket_index_expression') {
// t[k]() — bracket-index function call; key may be variable.
// AST: bracket_index_expression → [table_node, '[', key_expr, ']']
// childForFieldName('key') is not defined for this node type in tree-sitter-lua,
// so we locate the key by scanning past the '[', ']', and the table node (by id).
const table = nameNode.childForFieldName('table');
const tableId = table?.id;
let key: TreeSitterNode | null = null;
for (let i = 0; i < nameNode.childCount; i++) {
const ch = nameNode.child(i);
if (!ch) continue;
// Skip punctuation and the table node (compare by node id)
if (ch.type === '[' || ch.type === ']' || ch.id === tableId) continue;
key = ch;
break;
}
if (key && (key.type === 'string' || key.type === 'string_literal')) {
call.name = key.text.replace(/['"]/g, '');
call.receiver = table?.text;
} else {
// Variable key — flagged as computed-key
ctx.calls.push({
name: '<dynamic:computed-key>',
line: call.line,
dynamic: true,
dynamicKind: 'computed-key',
keyExpr: key?.text,
receiver: table?.text,
});
return;
}
} else {
call.name = nameNode.text;
}
Expand Down
2 changes: 1 addition & 1 deletion src/extractors/php.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ function handlePhpFuncCall(node: TreeSitterNode, ctx: ExtractorOutput): void {
if (!fn) return;
const callLine = node.startPosition.row + 1;

// $fn() — variable function call; cannot resolve statically.
// $fn() — variable function call; target cannot be resolved statically.
// Use keyExpr (not receiver) to capture the callable variable name for diagnostics.
if (fn.type === 'variable_name') {
ctx.calls.push({
Expand Down
2 changes: 1 addition & 1 deletion src/extractors/python.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ function handlePyCall(node: TreeSitterNode, ctx: ExtractorOutput): void {
const argIter = iterPyArgs(node);
const firstArg = argIter.next().value as TreeSitterNode | undefined;
const secondArg = argIter.next().value as TreeSitterNode | undefined;
const receiver = firstArg?.text;
const receiver = firstArg?.type === 'identifier' ? firstArg.text : undefined;
if (secondArg) {
const st = secondArg.type;
if (st === 'string' || st === 'concatenated_string') {
Expand Down
73 changes: 72 additions & 1 deletion src/extractors/swift.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,78 @@ function handleSwiftCallExpression(node: TreeSitterNode, ctx: ExtractorOutput):
} else {
call.name = funcNode.text;
}
if (call.name) ctx.calls.push(call);

if (!call.name) return;

// performSelector — ObjC-style dynamic dispatch; selector not statically knowable
if (call.name === 'performSelector') {
ctx.calls.push({
name: '<dynamic:unresolved>',
line: call.line,
dynamic: true,
dynamicKind: 'unresolved-dynamic',
receiver: call.receiver,
});
return;
}

// NSSelectorFromString("name") — selector from literal string.
// Only inspect the direct first argument to the call (not the whole subtree) to avoid
// false positives from nested calls like NSSelectorFromString(generateSelector("greet")).
//
// Swift AST: call_expression → [simple_identifier, call_suffix]
// call_suffix → [value_arguments]
// value_arguments → ['(', value_argument, ')']
// value_argument → [line_string_literal]
// line_string_literal → ['"', line_str_text, '"']
if (call.name === 'NSSelectorFromString') {
let literal: string | null = null;
// Resolve value_arguments: call_expression has a call_suffix child that wraps value_arguments.
const callSuffix = findChild(node, 'call_suffix');
const valueArgs =
node.childForFieldName('arguments') ??
(callSuffix ? (findChild(callSuffix, 'value_arguments') ?? callSuffix) : null) ??
findChild(node, 'value_arguments');
if (valueArgs) {
for (let i = 0; i < valueArgs.childCount; i++) {
const arg = valueArgs.child(i);
if (!arg) continue;
const t = arg.type;
if (t === '(' || t === ')' || t === ',') continue;
// value_argument wraps the actual value expression
const valueNode =
t === 'value_argument'
? (arg.childForFieldName('value') ?? arg.child(arg.childCount - 1))
: arg;
if (!valueNode) break;
const vt = valueNode.type;
if (vt === 'line_string_literal' || vt === 'string_literal') {
// Look for line_str_text (Swift) or string_content (other languages)
for (let j = 0; j < valueNode.childCount; j++) {
const ch = valueNode.child(j);
if (ch?.type === 'line_str_text' || ch?.type === 'string_content') {
literal = ch.text;
break;
}
}
if (!literal) literal = valueNode.text.replace(/^["']|["']$/g, '');
break;
}
// First non-string non-punctuation argument — computed, not a literal selector
break;
}
}
ctx.calls.push({
name: literal ?? '<dynamic:unresolved>',
line: call.line,
dynamic: true,
dynamicKind: literal ? 'reflection' : 'unresolved-dynamic',
keyExpr: literal ?? undefined,
});
return;
}

ctx.calls.push(call);
}

Comment on lines +305 to 354

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 NSSelectorFromString BFS matches nested string literals

The BFS starts from the entire call_expression node and accepts the first line_string_literal or string_literal it finds anywhere in the subtree. For code like NSSelectorFromString(generateSelector("greet")) or NSSelectorFromString(dict["greet"]), the BFS descends into the nested call/subscript and extracts "greet" as if it were the directly-passed selector. This creates a false-positive reflection edge pointing to greet when the actual runtime selector is not statically knowable. The fix is to look only at the direct arguments of the call rather than BFS-ing the entire subtree.

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 — replaced the BFS over the entire call_expression subtree with a direct argument inspection. The new implementation walks only the direct children of the argument_list (or call_suffix/value_arguments), stopping at the first real argument. Nested string literals inside sub-expressions (e.g. NSSelectorFromString(generateSelector("greet"))) are no longer extracted.

/**
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,7 @@ export interface LOCMetrics {
export type DynamicKind =
| 'computed-literal' // obj["foo"]() — resolvable; already emitted as normal edge
| 'computed-key' // obj[k]() — potentially resolvable via pts; else flagged
| 'reflection' // .call/.apply/.bind / Reflect.* — usually resolved; not flagged (drops silently if unresolved)
| 'reflection' // .call/.apply/.bind / Reflect.* — resolved if target reachable; silently dropped if not (RES-3)
| 'eval' // eval() / new Function() — undecidable; always flagged
| 'unresolved-dynamic'; // any other detected dynamic pattern; flagged

Expand Down
2 changes: 1 addition & 1 deletion tests/benchmarks/resolution/fixtures/dynamic-c/dispatch.c
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ void runFunctionPointer(void (*fp)(const char *)) {
(*fp)("world");
}

/* dlsym(handle, "symbol") — dynamic symbol loading; flagged */
/* dlsym(handle, "greet") — string literal resolves as reflection; fn pointer call flagged */
void runDlsym(void *handle) {
void (*fn)(const char *) = dlsym(handle, "greet");
if (fn) fn("world");
Expand Down
18 changes: 18 additions & 0 deletions tests/benchmarks/resolution/fixtures/dynamic-csharp/Reflection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Phase 6: C# reflection patterns
// GetMethod("name") → reflection kind
// method.Invoke() → unresolved-dynamic
using System.Reflection;

public class Reflection {
public static string Greet(string name) => $"Hello, {name}";

// type.GetMethod("Greet") — reflection kind, resolvable in theory
public static MethodInfo RunGetMethod(Type type) {
return type.GetMethod("Greet");
}

// method.Invoke(target, args) — unresolved-dynamic
public static object RunInvoke(MethodInfo method, object target) {
return method.Invoke(target, new object[] { "world" });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"$schema": "../../expected-edges.schema.json",
"language": "csharp",
"description": "Phase 6 C# fixture: GetMethod resolved as reflection; Invoke flagged. Methods class-qualified in DB → 0% recall until RES-3.",
"edges": [
{
"source": { "name": "Reflection.RunGetMethod", "file": "Reflection.cs" },
"target": { "name": "Reflection.Greet", "file": "Reflection.cs" },
"kind": "calls",
"mode": "dynamic",
"notes": "type.GetMethod('Greet') — reflection kind; 0% recall because DB uses class-qualified name"
}
]
}
Loading
Loading