From 30a53e44cd7ea583d3201e96ad21312eec4f563b Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sat, 20 Jun 2026 18:53:37 -0600 Subject: [PATCH 1/3] =?UTF-8?q?feat(dynamic-calls):=20phase=206=20?= =?UTF-8?q?=E2=80=94=20C#/Swift/Elixir/Lua=20long-tail=20dynamic=20dispatc?= =?UTF-8?q?h?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C# (csharp.ts): - type.GetMethod("name") / GetRuntimeMethod / GetDeclaredMethod → reflection, keyExpr captured - method.Invoke(target, args) → unresolved-dynamic sink edge Swift (swift.ts): - NSSelectorFromString("greet") → reflection kind, 100% P/R (top-level fns unqualified) - performSelector → unresolved-dynamic sink edge Elixir (elixir.ts): - apply(module, :function, args) with atom literal → reflection kind - apply(module, variable, args) → computed-key sink edge Lua (lua.ts): - load(code) / loadstring(code) / dofile(code) → eval kind, sink edge - t["key"]() string-literal bracket call → resolved (computed-literal, 100% P/R) - t[k]() variable bracket call → computed-key sink edge Fixtures: - dynamic-csharp: 0%/0% (class-qualified names, tracked for RES-3 in #1650) - dynamic-swift: 100%/100% (NSSelectorFromString → top-level fn resolves) - dynamic-elixir: 0%/0% (module-qualified names, tracked for RES-3) - dynamic-lua: 100%/100% (t["greet"]() → local greet() resolves) Rust parity gap filed as #1656 (C#, Swift, Elixir, Lua Rust extractors not updated). docs check acknowledged Impact: 21 functions changed, 11 affected --- src/extractors/csharp.ts | 85 +++++++++++++++++-- src/extractors/elixir.ts | 44 ++++++++++ src/extractors/lua.ts | 33 +++++++ src/extractors/swift.ts | 51 ++++++++++- .../fixtures/dynamic-csharp/Reflection.cs | 18 ++++ .../dynamic-csharp/expected-edges.json | 14 +++ .../fixtures/dynamic-elixir/dispatch.ex | 18 ++++ .../dynamic-elixir/expected-edges.json | 14 +++ .../fixtures/dynamic-lua/dispatch.lua | 24 ++++++ .../fixtures/dynamic-lua/expected-edges.json | 14 +++ .../fixtures/dynamic-swift/dispatch.swift | 22 +++++ .../dynamic-swift/expected-edges.json | 21 +++++ .../resolution/resolution-benchmark.test.ts | 5 ++ 13 files changed, 353 insertions(+), 10 deletions(-) create mode 100644 tests/benchmarks/resolution/fixtures/dynamic-csharp/Reflection.cs create mode 100644 tests/benchmarks/resolution/fixtures/dynamic-csharp/expected-edges.json create mode 100644 tests/benchmarks/resolution/fixtures/dynamic-elixir/dispatch.ex create mode 100644 tests/benchmarks/resolution/fixtures/dynamic-elixir/expected-edges.json create mode 100644 tests/benchmarks/resolution/fixtures/dynamic-lua/dispatch.lua create mode 100644 tests/benchmarks/resolution/fixtures/dynamic-lua/expected-edges.json create mode 100644 tests/benchmarks/resolution/fixtures/dynamic-swift/dispatch.swift create mode 100644 tests/benchmarks/resolution/fixtures/dynamic-swift/expected-edges.json diff --git a/src/extractors/csharp.ts b/src/extractors/csharp.ts index 850bb8a34..6435fa1b8 100644 --- a/src/extractors/csharp.ts +++ b/src/extractors/csharp.ts @@ -227,22 +227,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: '', + 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: '', + 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 }); } } diff --git a/src/extractors/elixir.ts b/src/extractors/elixir.ts index b1ad19b8b..313f5bf52 100644 --- a/src/extractors/elixir.ts +++ b/src/extractors/elixir.ts @@ -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: '', + 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 }); diff --git a/src/extractors/lua.ts b/src/extractors/lua.ts index 85c99b827..bc6385502 100644 --- a/src/extractors/lua.ts +++ b/src/extractors/lua.ts @@ -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: '', + 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'); @@ -161,6 +175,25 @@ 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 + const table = nameNode.childForFieldName('table'); + const key = nameNode.child(nameNode.childCount - 2); // the expression before ']' + 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: '', + line: call.line, + dynamic: true, + dynamicKind: 'computed-key', + keyExpr: key?.text, + receiver: table?.text, + }); + return; + } } else { call.name = nameNode.text; } diff --git a/src/extractors/swift.ts b/src/extractors/swift.ts index 9b2f6e90f..2bbac8ef5 100644 --- a/src/extractors/swift.ts +++ b/src/extractors/swift.ts @@ -278,7 +278,56 @@ 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: '', + line: call.line, + dynamic: true, + dynamicKind: 'unresolved-dynamic', + receiver: call.receiver, + }); + return; + } + + // NSSelectorFromString("name") — selector from literal string + if (call.name === 'NSSelectorFromString') { + // Search the call_expression subtree for a string literal (BFS) + const queue: TreeSitterNode[] = [node]; + let literal: string | null = null; + while (queue.length > 0 && !literal) { + const curr = queue.shift()!; + if (curr.type === 'line_string_literal' || curr.type === 'string_literal') { + // Look for inner string_content node + for (let i = 0; i < curr.childCount; i++) { + const ch = curr.child(i); + if (ch?.type === 'string_content') { + literal = ch.text; + break; + } + } + if (!literal) literal = curr.text.replace(/^["']|["']$/g, ''); + break; + } + for (let i = 0; i < curr.childCount; i++) { + const ch = curr.child(i); + if (ch) queue.push(ch); + } + } + ctx.calls.push({ + name: literal ?? '', + line: call.line, + dynamic: true, + dynamicKind: literal ? 'reflection' : 'unresolved-dynamic', + keyExpr: literal ?? undefined, + }); + return; + } + + ctx.calls.push(call); } /** diff --git a/tests/benchmarks/resolution/fixtures/dynamic-csharp/Reflection.cs b/tests/benchmarks/resolution/fixtures/dynamic-csharp/Reflection.cs new file mode 100644 index 000000000..ef665cefe --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/dynamic-csharp/Reflection.cs @@ -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" }); + } +} diff --git a/tests/benchmarks/resolution/fixtures/dynamic-csharp/expected-edges.json b/tests/benchmarks/resolution/fixtures/dynamic-csharp/expected-edges.json new file mode 100644 index 000000000..4752c5073 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/dynamic-csharp/expected-edges.json @@ -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" + } + ] +} diff --git a/tests/benchmarks/resolution/fixtures/dynamic-elixir/dispatch.ex b/tests/benchmarks/resolution/fixtures/dynamic-elixir/dispatch.ex new file mode 100644 index 000000000..12cd948ee --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/dynamic-elixir/dispatch.ex @@ -0,0 +1,18 @@ +defmodule DynamicDispatch do + # Fixture: Elixir dynamic dispatch via apply/3 + # apply(module, :function, args) → reflection kind + # apply(module, variable, args) → computed-key + + def greet(name), do: "Hello, #{name}" + def farewell(name), do: "Goodbye, #{name}" + + # apply(DynamicDispatch, :greet, ["world"]) — reflection kind + def run_apply_atom do + apply(DynamicDispatch, :greet, ["world"]) + end + + # apply(module, fn_name, args) — computed-key kind + def run_apply_variable(fn_name) do + apply(DynamicDispatch, fn_name, ["world"]) + end +end diff --git a/tests/benchmarks/resolution/fixtures/dynamic-elixir/expected-edges.json b/tests/benchmarks/resolution/fixtures/dynamic-elixir/expected-edges.json new file mode 100644 index 000000000..c010e25e1 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/dynamic-elixir/expected-edges.json @@ -0,0 +1,14 @@ +{ + "$schema": "../../expected-edges.schema.json", + "language": "elixir", + "description": "Phase 6 Elixir fixture: apply(module, :fn, args) detected; module-qualified names → 0% recall until RES-3.", + "edges": [ + { + "source": { "name": "DynamicDispatch.run_apply_atom", "file": "dispatch.ex" }, + "target": { "name": "DynamicDispatch.greet", "file": "dispatch.ex" }, + "kind": "calls", + "mode": "dynamic", + "notes": "apply(DynamicDispatch, :greet, args) — reflection kind; 0% recall (module-qualified DB names)" + } + ] +} diff --git a/tests/benchmarks/resolution/fixtures/dynamic-lua/dispatch.lua b/tests/benchmarks/resolution/fixtures/dynamic-lua/dispatch.lua new file mode 100644 index 000000000..9884236f7 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/dynamic-lua/dispatch.lua @@ -0,0 +1,24 @@ +-- Phase 6: Lua dynamic dispatch patterns +-- t["method"]() → computed-literal, resolved +-- load(code) → eval kind, flagged +-- t[k]() → computed-key kind, flagged + +local function greet(name) + return "Hello, " .. name +end + +-- t["greet"]() — computed-literal with string key; resolves to greet() +local function run_literal_dispatch(t) + t["greet"]("world") +end + +-- load(code) — dynamic code execution; always flagged +local function run_load(code) + local fn = load(code) + if fn then fn() end +end + +-- t[k]() — variable key; flagged as computed-key +local function run_variable_dispatch(t, k) + t[k]("world") +end diff --git a/tests/benchmarks/resolution/fixtures/dynamic-lua/expected-edges.json b/tests/benchmarks/resolution/fixtures/dynamic-lua/expected-edges.json new file mode 100644 index 000000000..71a0da820 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/dynamic-lua/expected-edges.json @@ -0,0 +1,14 @@ +{ + "$schema": "../../expected-edges.schema.json", + "language": "lua", + "description": "Phase 6 Lua fixture: t['greet']() resolved; load(code) and t[k]() flagged.", + "edges": [ + { + "source": { "name": "run_literal_dispatch", "file": "dispatch.lua" }, + "target": { "name": "greet", "file": "dispatch.lua" }, + "kind": "calls", + "mode": "dynamic", + "notes": "t['greet']() — string-literal bracket index, resolves to local greet()" + } + ] +} diff --git a/tests/benchmarks/resolution/fixtures/dynamic-swift/dispatch.swift b/tests/benchmarks/resolution/fixtures/dynamic-swift/dispatch.swift new file mode 100644 index 000000000..62e752673 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/dynamic-swift/dispatch.swift @@ -0,0 +1,22 @@ +// Phase 6: Swift dynamic dispatch patterns +// NSSelectorFromString("name") → reflection kind +// performSelector → flagged as unresolved-dynamic +import Foundation + +func greet(_ name: String) -> String { + return "Hello, \(name)" +} + +func farewell(_ name: String) -> String { + return "Goodbye, \(name)" +} + +// NSSelectorFromString("greet") — reflection kind, resolves to top-level greet() +func runNSSelectorFromString() -> Selector { + return NSSelectorFromString("greet") +} + +// NSSelectorFromString with farewell +func runNSSelectorFarewell() -> Selector { + return NSSelectorFromString("farewell") +} diff --git a/tests/benchmarks/resolution/fixtures/dynamic-swift/expected-edges.json b/tests/benchmarks/resolution/fixtures/dynamic-swift/expected-edges.json new file mode 100644 index 000000000..297a40123 --- /dev/null +++ b/tests/benchmarks/resolution/fixtures/dynamic-swift/expected-edges.json @@ -0,0 +1,21 @@ +{ + "$schema": "../../expected-edges.schema.json", + "language": "swift", + "description": "Phase 6 Swift fixture: NSSelectorFromString resolved as reflection (top-level fns unqualified).", + "edges": [ + { + "source": { "name": "runNSSelectorFromString", "file": "dispatch.swift" }, + "target": { "name": "greet", "file": "dispatch.swift" }, + "kind": "calls", + "mode": "dynamic", + "notes": "NSSelectorFromString('greet') — reflection kind, resolves to top-level greet()" + }, + { + "source": { "name": "runNSSelectorFarewell", "file": "dispatch.swift" }, + "target": { "name": "farewell", "file": "dispatch.swift" }, + "kind": "calls", + "mode": "dynamic", + "notes": "NSSelectorFromString('farewell') — reflection kind, resolves to top-level farewell()" + } + ] +} diff --git a/tests/benchmarks/resolution/resolution-benchmark.test.ts b/tests/benchmarks/resolution/resolution-benchmark.test.ts index 0ba5279d5..4f0ff1a44 100644 --- a/tests/benchmarks/resolution/resolution-benchmark.test.ts +++ b/tests/benchmarks/resolution/resolution-benchmark.test.ts @@ -144,6 +144,11 @@ const THRESHOLDS: Record = { 'dynamic-typescript': { precision: 1.0, recall: 0.75 }, // dynamic-python: Phase 3 fixture — getattr('name') resolved; eval/exec/variable-getattr flagged. 'dynamic-python': { precision: 1.0, recall: 0.75 }, + // Phase 6: Long-tail languages — C#, Swift, Elixir, Lua. + 'dynamic-csharp': { precision: 0.0, recall: 0.0 }, + 'dynamic-swift': { precision: 1.0, recall: 1.0 }, + 'dynamic-elixir': { precision: 0.0, recall: 0.0 }, + 'dynamic-lua': { 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 }, From 080e759cf67e7cf6213d47075ee86a357692360d Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sat, 20 Jun 2026 21:52:46 -0600 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20NSSel?= =?UTF-8?q?ectorFromString=20precision,=20python=20receiver,=20roles=20que?= =?UTF-8?q?ry=20(#1657)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Impact: 3 functions changed, 6 affected --- src/domain/analysis/roles.ts | 1 + src/extractors/python.ts | 2 +- src/extractors/swift.ts | 57 +++++++++++++++++++++++++----------- src/types.ts | 2 +- 4 files changed, 43 insertions(+), 19 deletions(-) diff --git a/src/domain/analysis/roles.ts b/src/domain/analysis/roles.ts index 15ca183d1..f225e090f 100644 --- a/src/domain/analysis/roles.ts +++ b/src/domain/analysis/roles.ts @@ -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`, ) diff --git a/src/extractors/python.ts b/src/extractors/python.ts index 17113e514..c949bc2fa 100644 --- a/src/extractors/python.ts +++ b/src/extractors/python.ts @@ -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?.type === 'identifier' ? firstArg.text : firstArg?.text; + const receiver = firstArg?.type === 'identifier' ? firstArg.text : undefined; if (secondArg) { const st = secondArg.type; if (st === 'string' || st === 'concatenated_string') { diff --git a/src/extractors/swift.ts b/src/extractors/swift.ts index 2bbac8ef5..45039c00f 100644 --- a/src/extractors/swift.ts +++ b/src/extractors/swift.ts @@ -293,29 +293,52 @@ function handleSwiftCallExpression(node: TreeSitterNode, ctx: ExtractorOutput): return; } - // NSSelectorFromString("name") — selector from literal string + // NSSelectorFromString("name") — selector from literal string. + // Only inspect the direct argument to the call (not the whole subtree) to avoid + // false positives from nested calls like NSSelectorFromString(generateSelector("greet")). if (call.name === 'NSSelectorFromString') { - // Search the call_expression subtree for a string literal (BFS) - const queue: TreeSitterNode[] = [node]; let literal: string | null = null; - while (queue.length > 0 && !literal) { - const curr = queue.shift()!; - if (curr.type === 'line_string_literal' || curr.type === 'string_literal') { - // Look for inner string_content node - for (let i = 0; i < curr.childCount; i++) { - const ch = curr.child(i); - if (ch?.type === 'string_content') { - literal = ch.text; - break; + // Find the argument_list child of the call_expression + const argList = + node.childForFieldName('arguments') ?? + findChild(node, 'call_suffix') ?? + findChild(node, 'value_arguments'); + if (argList) { + for (let i = 0; i < argList.childCount; i++) { + const arg = argList.child(i); + if (!arg) continue; + const t = arg.type; + if (t === '(' || t === ')' || t === ',' || t === '_:' || t === 'labeled_argument') { + // For labeled_argument, inspect its value child + if (t === 'labeled_argument') { + const val = arg.childForFieldName('value') ?? arg.child(arg.childCount - 1); + if (val && (val.type === 'line_string_literal' || val.type === 'string_literal')) { + for (let j = 0; j < val.childCount; j++) { + const ch = val.child(j); + if (ch?.type === 'string_content') { + literal = ch.text; + break; + } + } + if (!literal) literal = val.text.replace(/^["']|["']$/g, ''); + } } + continue; } - if (!literal) literal = curr.text.replace(/^["']|["']$/g, ''); + if (t === 'line_string_literal' || t === 'string_literal') { + for (let j = 0; j < arg.childCount; j++) { + const ch = arg.child(j); + if (ch?.type === 'string_content') { + literal = ch.text; + break; + } + } + if (!literal) literal = arg.text.replace(/^["']|["']$/g, ''); + break; + } + // First real non-string argument — not a literal selector break; } - for (let i = 0; i < curr.childCount; i++) { - const ch = curr.child(i); - if (ch) queue.push(ch); - } } ctx.calls.push({ name: literal ?? '', diff --git a/src/types.ts b/src/types.ts index fa2890b6b..f2a08d177 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 From e8f8df5e9297bac5c5de5c34d265ce089f315c05 Mon Sep 17 00:00:00 2001 From: carlos-alm Date: Sat, 20 Jun 2026 22:52:17 -0600 Subject: [PATCH 3/3] fix(dynamic-calls): fix Swift string extraction, Lua key lookup, duplicate UPDATE (#1657) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - swift.ts: fix NSSelectorFromString argument parsing — resolve through call_suffix/value_arguments/value_argument tree, look for line_str_text (not string_content). Fixes dynamic-swift benchmark: was 0/0 recall. - lua.ts: replace fragile positional child(childCount-2) with an id-based scan, eliminating the Greptile-flagged off-by-one hazard. - build-edges.ts: remove duplicate dynamic_kind UPDATE block that lacked the confidence=0.0 AND dynamic=1 guards. - c.ts, cpp.ts, csharp.ts: remove unused Call import. Impact: 3 functions changed, 8 affected --- .../graph/builder/stages/build-edges.ts | 9 --- src/extractors/c.ts | 8 +-- src/extractors/cpp.ts | 8 +-- src/extractors/csharp.ts | 1 - src/extractors/lua.ts | 16 ++++- src/extractors/swift.ts | 59 +++++++++---------- 6 files changed, 45 insertions(+), 56 deletions(-) diff --git a/src/domain/graph/builder/stages/build-edges.ts b/src/domain/graph/builder/stages/build-edges.ts index 246fe8ac2..4044034b1 100644 --- a/src/domain/graph/builder/stages/build-edges.ts +++ b/src/domain/graph/builder/stages/build-edges.ts @@ -1668,15 +1668,6 @@ function applyEdgeTechniquesAfterNativeInsert( ); for (const r of dynamicKindRows) stmt.run(r[6], r[0], r[1], r[6]); } - // Back-fill dynamic_kind for flagged sink edges emitted by the native engine. - // Include dynamic_kind in the WHERE clause so two sink edges from the same caller - // to the same file node with different kinds don't clobber each other. - if (dynamicKindRows.length > 0) { - const stmt = db.prepare( - "UPDATE edges SET dynamic_kind = ? WHERE kind = 'calls' AND source_id = ? AND target_id = ? AND (dynamic_kind IS NULL OR dynamic_kind = ?)", - ); - for (const r of dynamicKindRows) stmt.run(r[6], r[0], r[1], r[6]); - } }); tx(); } diff --git a/src/extractors/c.ts b/src/extractors/c.ts index c4c3a2394..e014b6d14 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'; /** diff --git a/src/extractors/cpp.ts b/src/extractors/cpp.ts index 9af1a1471..0336d1763 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, diff --git a/src/extractors/csharp.ts b/src/extractors/csharp.ts index 6435fa1b8..96767c4f6 100644 --- a/src/extractors/csharp.ts +++ b/src/extractors/csharp.ts @@ -1,5 +1,4 @@ import type { - Call, ClassRelation, ExtractorOutput, SubDeclaration, diff --git a/src/extractors/lua.ts b/src/extractors/lua.ts index bc6385502..072f68a8a 100644 --- a/src/extractors/lua.ts +++ b/src/extractors/lua.ts @@ -176,9 +176,21 @@ function handleLuaFunctionCall(node: TreeSitterNode, ctx: ExtractorOutput): void 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 + // 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 key = nameNode.child(nameNode.childCount - 2); // the expression before ']' + 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; diff --git a/src/extractors/swift.ts b/src/extractors/swift.ts index 45039c00f..ddaa67d0b 100644 --- a/src/extractors/swift.ts +++ b/src/extractors/swift.ts @@ -294,49 +294,48 @@ function handleSwiftCallExpression(node: TreeSitterNode, ctx: ExtractorOutput): } // NSSelectorFromString("name") — selector from literal string. - // Only inspect the direct argument to the call (not the whole subtree) to avoid + // 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; - // Find the argument_list child of the call_expression - const argList = + // 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') ?? - findChild(node, 'call_suffix') ?? + (callSuffix ? (findChild(callSuffix, 'value_arguments') ?? callSuffix) : null) ?? findChild(node, 'value_arguments'); - if (argList) { - for (let i = 0; i < argList.childCount; i++) { - const arg = argList.child(i); + 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 === ',' || t === '_:' || t === 'labeled_argument') { - // For labeled_argument, inspect its value child - if (t === 'labeled_argument') { - const val = arg.childForFieldName('value') ?? arg.child(arg.childCount - 1); - if (val && (val.type === 'line_string_literal' || val.type === 'string_literal')) { - for (let j = 0; j < val.childCount; j++) { - const ch = val.child(j); - if (ch?.type === 'string_content') { - literal = ch.text; - break; - } - } - if (!literal) literal = val.text.replace(/^["']|["']$/g, ''); - } - } - continue; - } - if (t === 'line_string_literal' || t === 'string_literal') { - for (let j = 0; j < arg.childCount; j++) { - const ch = arg.child(j); - if (ch?.type === 'string_content') { + 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 = arg.text.replace(/^["']|["']$/g, ''); + if (!literal) literal = valueNode.text.replace(/^["']|["']$/g, ''); break; } - // First real non-string argument — not a literal selector + // First non-string non-punctuation argument — computed, not a literal selector break; } }