diff --git a/packages/core/src/repowise/core/ingestion/parser.py b/packages/core/src/repowise/core/ingestion/parser.py index 2013d6de9..d36cc33f2 100644 --- a/packages/core/src/repowise/core/ingestion/parser.py +++ b/packages/core/src/repowise/core/ingestion/parser.py @@ -1317,9 +1317,12 @@ def _extract_calls( for call in calls: key = (call.line, call.target_name, call.receiver_name) existing = deduplicated.get(key) - # Two patterns match a scoped call (one keeps the qualifier, one does - # not) and both dedup to this key, so the richer record has to win - # whichever order they arrive in. + # Two of the three scoped-call patterns can match the same two-part call + # (one keeps the qualifier, one does not) and both dedup to this key, so + # the richer record has to win whichever order they arrive in. The + # three-part pattern (ns::util::fn()) never collides here, it's the + # only pattern that can match a nested qualified_identifier, so it + # always lands as a fresh key. if ( existing is None or (existing.receiver_call is None and call.receiver_call is not None) diff --git a/packages/core/src/repowise/core/ingestion/queries/cpp.scm b/packages/core/src/repowise/core/ingestion/queries/cpp.scm index 4ec3284a4..503dd219e 100644 --- a/packages/core/src/repowise/core/ingestion/queries/cpp.scm +++ b/packages/core/src/repowise/core/ingestion/queries/cpp.scm @@ -237,6 +237,25 @@ arguments: (argument_list) @call.arguments ) @call.site +; The same call once more, for a three-part qualifier: ns::util::fn(args). +; The grammar nests qualified_identifier left-recursively (see the two-level +; qualified function definition above), so the outer node's name field is +; itself a qualified_identifier rather than an identifier, neither pattern +; above can match it, and no call site was produced at all for this shape +; (#1918). Capturing the innermost identifier as the target and its adjacent +; scope as the qualifier mirrors how a two-part call is already captured; +; the deeper namespace prefix (ns) is dropped the same way the class-name +; extractor already drops it for definitions. +(call_expression + function: (qualified_identifier + name: (qualified_identifier + scope: (namespace_identifier) @call.scope + name: (identifier) @call.target + ) + ) + arguments: (argument_list) @call.arguments +) @call.site + ; Chained call: obj.method1().method2(args) (call_expression function: (field_expression diff --git a/tests/unit/ingestion/test_cpp_scoped_calls.py b/tests/unit/ingestion/test_cpp_scoped_calls.py index 4660e80ad..c02141780 100644 --- a/tests/unit/ingestion/test_cpp_scoped_calls.py +++ b/tests/unit/ingestion/test_cpp_scoped_calls.py @@ -100,3 +100,60 @@ def test_qualifier_naming_no_known_class_still_resolves_by_other_tiers( ) targets = _call_targets(_build(tmp_path), "main.cc") assert any(t.endswith("Helper") for t in targets) + +class TestThreePartQualifiedCallProducesACallSite: + """A three-part qualified call like ``ns::util::toHex()`` produced no + call site at all, not merely an unresolved one (#1918). + + ``cpp.scm``'s two scoped-call patterns require ``name: (identifier)``. + tree-sitter nests a three-part qualifier left-recursively, so the outer + node's ``name`` field is itself a ``qualified_identifier`` and neither + pattern matches. The call never reaches the resolver, so it cannot be + resolved, declined, or counted -- it simply is never produced as a + ``CallSite``. + """ + + def _repo(self, root: Path) -> None: + (root / "util.h").write_text( + "#pragma once\nnamespace ns { namespace util {\nint toHex(int x);\n}}\n" + ) + (root / "util.cc").write_text( + '#include "util.h"\nnamespace ns { namespace util {\n' + "int toHex(int x) { return x; }\n}}\n" + ) + (root / "main.cc").write_text( + '#include "util.h"\n' + "int callTwoPart() { return util::toHex(1); }\n" + "int callThreePart() { return ns::util::toHex(2); }\n" + ) + + def test_three_part_call_resolves_to_the_function(self, tmp_path: Path) -> None: + self._repo(tmp_path) + graph = _build(tmp_path) + assert any( + s.endswith("callThreePart") and t.endswith("toHex") + for s, t, d in graph.edges(data=True) + if d.get("edge_type") == "calls" + ) + + def test_two_part_control_is_unaffected(self, tmp_path: Path) -> None: + """The three-part pattern must not crowd out the existing two-part + match -- each caller keeps its own distinct edge to toHex.""" + self._repo(tmp_path) + graph = _build(tmp_path) + assert any( + s.endswith("callTwoPart") and t.endswith("toHex") + for s, t, d in graph.edges(data=True) + if d.get("edge_type") == "calls" + ) + + def test_unqualified_control_is_unaffected(self, tmp_path: Path) -> None: + (tmp_path / "lib.h").write_text("#pragma once\nint free_thing(int x);\n") + (tmp_path / "lib.cc").write_text( + '#include "lib.h"\nint free_thing(int x) { return x; }\n' + ) + (tmp_path / "caller.cc").write_text( + '#include "lib.h"\nint main() { return free_thing(1); }\n' + ) + targets = _call_targets(_build(tmp_path), "caller.cc") + assert any(t.endswith("free_thing") for t in targets) \ No newline at end of file