From 0aab9435b435dab36733152211c02f4e1279bcf2 Mon Sep 17 00:00:00 2001 From: Scott Arciszewski Date: Fri, 16 Oct 2026 13:50:49 -0400 Subject: [PATCH 1/2] feat: report unresolved call sites and document Rust resolution limits --- README.md | 64 ++++++++++++++++++ src/trailmark/cli.py | 14 +++- src/trailmark/models/graph.py | 18 ++++- src/trailmark/query/api.py | 52 +++++++++++++++ src/trailmark/storage/graph_store.py | 22 +++++++ tests/test_cli.py | 33 ++++++++++ tests/test_models.py | 25 +++++++ tests/test_query.py | 67 +++++++++++++++++++ tests/test_readme.py | 18 +++++ tests/test_rust_resolution.py | 98 ++++++++++++++++++++++++++++ tests/test_storage.py | 34 ++++++++++ 11 files changed, 440 insertions(+), 5 deletions(-) create mode 100644 tests/test_rust_resolution.py diff --git a/README.md b/README.md index 5fb8b72..26221dc 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,8 @@ Trailmark does not expand arbitrary macros. Literal Verus invocations inside opaque macros or function bodies are skipped with a warning. Uninvoked `macro_rules!` definitions are not expanded. Function signatures without bodies and calls in `requires`/`ensures` clauses are not extracted. +See [call-resolution diagnostics](#call-resolution-diagnostics) for inspecting +unresolved Rust calls and the limits of reachability through them. ```mermaid flowchart TD @@ -141,6 +143,8 @@ The `QueryEngine` provides a high-level API over the indexed graph: | --- | --- | | `callers_of(name)` | Direct callers of the named target | | `callees_of(name)` | Direct callees of the named source | +| `unresolved_calls(name=None)` | Unresolved call sites, optionally restricted to one caller, with raw expressions and locations | +| `call_resolution()` | Extracted call-site counts and unresolved calls grouped by callee label | | `ancestors_of(name)` | Every function that can transitively reach the target (upward slice) | | `reachable_from(name)` | Every function transitively reachable from the source | | `paths_between(src, dst)` | All simple call paths between two nodes | @@ -238,6 +242,66 @@ parses, including single-language parses, also apply repository links and materialize `TYPE_USES` edges. Binary analysis support imports external JSON call graphs; Trailmark does not disassemble executables itself. +### Call-Resolution Diagnostics + +```bash +trailmark analyze --language rust --resolution path/to/project +``` + +The JSON report distinguishes `call_sites`, `resolved_call_sites`, +`unresolved_call_sites`, `unresolved_targets` (distinct proxy IDs), and +`affected_callers`. `unresolved_by_callee` groups calls by their final syntactic +name, such as `unwrap` or `expect`, and sorts groups by descending call-site +count, then name. These labels do not identify a particular trait implementation. +Repeated calls to the same proxy count as separate sites. Explicitly linked +external proxies are not classified as unresolved. + +For detailed inspection, use the Python API: + +```python +engine = QueryEngine.from_directory("./project", language="rust") +engine.call_resolution() +engine.unresolved_calls() # every unresolved call site +engine.unresolved_calls("handle") # only calls made directly by this function +``` + +Each row includes the caller (`source`), proxy ID (`target`), `raw_target`, +`callee`, confidence, and the individual call's `location` (or `None` when +unavailable). A proxy node's own location represents its first occurrence; +use call-site locations when examining repeated calls. A missing caller returns +`[]`. The reports also work for other supported languages. + +Rust resolution currently has these limits: + +| Pattern | Behavior | +| --- | --- | +| Standard methods: `.unwrap()`, `.expect()`, `.clone()`, `.len()` | Calls are retained; receiver types and dependency implementations are not inferred | +| Iterator chains: `.iter().map(...).collect()` | Each syntactic call is retained; intermediate result types are not propagated | +| Trait conversions: `.into()`, `.try_into()`, `TokenId::try_from()` | Trait implementations and generated methods may remain unresolved | +| External calls: `UdpSocket::bind()`, `prost_build::Config::new()` | Dependency source and compiler resolution are not loaded automatically | +| Generated builders: `Action::builder().timestamp(...)` | Calls are retained; arbitrary macro expansion does not generate their implementations | +| Local value receivers: `engine.execute()`, `self.side.converse()` | Receiver types are not inferred, even from explicit parameter annotations | +| Enum constructors: `ActionKind::Deposit(1)`, `Ok(...)`, `Err(...)` | Parenthesized constructors are retained as calls and can remain proxies; a bare variant value is not a call | + +Declared return types are recorded as metadata, but they do not resolve later +calls on returned values. For example, `make() -> Result` does not +link `make().unwrap().execute()` to `Engine.execute`. Calls to `make`, `unwrap`, +and `execute` are recorded separately; recording them does not establish the +receiver's type or the callee's implementation. + +Unresolved proxies retain the boundary where resolution stops. Reachability and +preanalysis cannot follow a missing implementation beyond that boundary, so +local effects may be absent from the resulting paths. Thousands of proxies +alone do not show that the corresponding call expressions were dropped. +Review the affected callers and supply reviewed missing call edges through +`.trailmark/links.toml`, or import an externally produced graph with +`augment_binary()` when appropriate. + +The report measures resolution of extracted calls, not overall program +coverage. Opaque macro bodies and implicit calls may produce no edges and are +outside its counts. Zero unresolved calls is not proof that the graph contains +every executable path. Hiding common proxy names does not restore those paths. + ### Example Graph Given this Python code: diff --git a/src/trailmark/cli.py b/src/trailmark/cli.py index 6455d6f..1e2169a 100644 --- a/src/trailmark/cli.py +++ b/src/trailmark/cli.py @@ -45,19 +45,25 @@ def build_parser() -> argparse.ArgumentParser: default="python", help="Source language (default: python)", ) - analyze.add_argument( + output = analyze.add_mutually_exclusive_group() + output.add_argument( "--summary", "-s", action="store_true", help="Print summary instead of full graph", ) - analyze.add_argument( + output.add_argument( "--complexity", "-c", type=int, default=0, help="Show functions with complexity >= threshold", ) + output.add_argument( + "--resolution", + action="store_true", + help="Report extracted call-resolution counts and unresolved callee groups as JSON", + ) diff_cmd = subparsers.add_parser( "diff", @@ -176,7 +182,9 @@ def _run_analyze(args: argparse.Namespace) -> None: language=args.language, ) - if args.summary: + if args.resolution: + print(json.dumps(engine.call_resolution(), indent=2)) + elif args.summary: _print_summary(engine) elif args.complexity > 0: _print_complexity(engine, args.complexity) diff --git a/src/trailmark/models/graph.py b/src/trailmark/models/graph.py index dbc4daf..c42e680 100644 --- a/src/trailmark/models/graph.py +++ b/src/trailmark/models/graph.py @@ -5,8 +5,8 @@ from dataclasses import dataclass, field from trailmark.models.annotations import Annotation, AnnotationKind, EntrypointTag -from trailmark.models.edges import CodeEdge -from trailmark.models.nodes import CodeUnit +from trailmark.models.edges import CodeEdge, EdgeKind +from trailmark.models.nodes import CodeUnit, NodeKind @dataclass @@ -26,6 +26,20 @@ class CodeGraph: language: str = "" root_path: str = "" + def unresolved_calls(self) -> list[CodeEdge]: + """Return calls with missing targets or explicit unresolved proxies.""" + result: list[CodeEdge] = [] + for edge in self.edges: + if edge.kind != EdgeKind.CALLS: + continue + target = self.nodes.get(edge.target_id) + if target is None or ( + target.kind == NodeKind.PROXY + and dict(target.attributes).get("proxy_kind") == "unresolved" + ): + result.append(edge) + return result + def add_annotation( self, node_id: str, diff --git a/src/trailmark/query/api.py b/src/trailmark/query/api.py index e228762..a751393 100644 --- a/src/trailmark/query/api.py +++ b/src/trailmark/query/api.py @@ -89,6 +89,58 @@ def callees_of(self, name: str) -> list[dict[str, Any]]: return [] return [_unit_to_dict(u) for u in self._store.callees_of(node_id)] + def unresolved_calls(self, name: str | None = None) -> list[dict[str, Any]]: + """Report unresolved call sites, including raw targets and source spans. + + ``name`` restricts the report to one caller. A missing caller returns + ``[]``; omitting it reports every unresolved call in the graph. + """ + node_id = self._store.find_node_id(name) if name is not None else None + if name is not None and node_id is None: + return [] + result: list[dict[str, Any]] = [] + for edge in self._store.unresolved_calls(node_id): + target = self._store.unit(edge.target_id) + attributes = dict(target.attributes) if target is not None else {} + raw_target = str( + dict(edge.attributes).get( + "raw_target", attributes.get("raw_symbol", edge.target_id) + ) + ) + row = _edge_to_dict(edge) + row["raw_target"] = raw_target + row["callee"] = raw_target.rsplit(".", 1)[-1].rsplit(":", 1)[-1].strip() + row["location"] = asdict(edge.location) if edge.location is not None else None + result.append(row) + return result + + def call_resolution(self) -> dict[str, Any]: + """Count graph call sites and group unresolved targets by callee label. + + These counts describe extracted edges, not completeness of the source + program: opaque macros and implicit calls may contribute no edges. + """ + calls = self._store.call_edges() + unresolved = self.unresolved_calls() + grouped: dict[str, list[dict[str, Any]]] = {} + for row in unresolved: + grouped.setdefault(row["callee"], []).append(row) + return { + "call_sites": len(calls), + "resolved_call_sites": len(calls) - len(unresolved), + "unresolved_call_sites": len(unresolved), + "unresolved_targets": len({row["target"] for row in unresolved}), + "affected_callers": len({row["source"] for row in unresolved}), + "unresolved_by_callee": [ + { + "callee": callee, + "call_sites": len(grouped[callee]), + "targets": len({row["target"] for row in grouped[callee]}), + } + for callee in sorted(grouped, key=lambda name: (-len(grouped[name]), name)) + ], + } + def ancestors_of(self, name: str) -> list[dict[str, Any]]: """Find every function/method that can transitively reach ``name``. diff --git a/src/trailmark/storage/graph_store.py b/src/trailmark/storage/graph_store.py index 125f8f0..79a448d 100644 --- a/src/trailmark/storage/graph_store.py +++ b/src/trailmark/storage/graph_store.py @@ -100,6 +100,28 @@ def unit(self, node_id: str) -> CodeUnit | None: """Return the graph unit for ``node_id`` if it exists.""" return self._node(node_id) + def call_edges(self) -> list[CodeEdge]: + """Return calls with endpoints present in the indexed graph.""" + return [ + edge + for edge in self._graph.edges + if edge.kind == EdgeKind.CALLS + and edge.source_id in self._id_to_idx + and edge.target_id in self._id_to_idx + ] + + def unresolved_calls(self, node_id: str | None = None) -> list[CodeEdge]: + """Return unresolved call sites, optionally restricted to one caller.""" + if node_id is not None and self._idx(node_id) is None: + return [] + return [ + edge + for edge in self._graph.unresolved_calls() + if edge.source_id in self._id_to_idx + and edge.target_id in self._id_to_idx + and (node_id is None or edge.source_id == node_id) + ] + def callers_of(self, node_id: str) -> list[CodeUnit]: """Return all nodes that call the given node.""" target_idx = self._idx(node_id) diff --git a/tests/test_cli.py b/tests/test_cli.py index e0df110..c465a8c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,6 +13,39 @@ from trailmark.cli import main +class TestResolutionReport: + def test_cli_reports_call_resolution_as_json(self, tmp_path: Path) -> None: + (tmp_path / "app.rs").write_text( + "fn caller() { value.unwrap(); value.unwrap(); other.expect(0); }" + ) + with ( + patch.object( + sys, + "argv", + ["trailmark", "analyze", str(tmp_path), "--language", "rust", "--resolution"], + ), + patch("sys.stdout", new_callable=StringIO) as output, + ): + main() + report = json.loads(output.getvalue()) + assert report["call_sites"] == report["unresolved_call_sites"] == 3 + assert report["resolved_call_sites"] == 0 + assert report["unresolved_targets"] == 2 + assert report["unresolved_by_callee"] == [ + {"callee": "unwrap", "call_sites": 2, "targets": 1}, + {"callee": "expect", "call_sites": 1, "targets": 1}, + ] + + @pytest.mark.parametrize("other_flag", ["--summary", "--complexity=1"]) + def test_resolution_report_rejects_conflicting_output_flags(self, other_flag: str) -> None: + with ( + patch.object(sys, "argv", ["trailmark", "analyze", ".", "--resolution", other_flag]), + pytest.raises(SystemExit) as exc, + ): + main() + assert exc.value.code == 2 + + class TestMainNoArgs: def test_no_command_prints_help_and_exits(self) -> None: with patch.object(sys, "argv", ["trailmark"]): diff --git a/tests/test_models.py b/tests/test_models.py index 2ddcfed..d2494f2 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -157,6 +157,31 @@ def test_proxy_node(self) -> None: assert unit.attributes[0] == ("raw_symbol", "mod:missing") +class TestCallResolution: + def test_unresolved_calls_include_missing_and_unresolved_proxy_targets(self) -> None: + source = _make_unit("f", "f") + unresolved = _make_unit("proxy:unknown", "unknown", kind=NodeKind.PROXY) + unresolved = CodeUnit( + id=unresolved.id, + name=unresolved.name, + kind=unresolved.kind, + location=unresolved.location, + attributes=(("proxy_kind", "unresolved"),), + ) + external = _make_unit("proxy:external", "external", kind=NodeKind.PROXY) + edges = [ + CodeEdge("f", "missing", EdgeKind.CALLS), + CodeEdge("f", unresolved.id, EdgeKind.CALLS), + CodeEdge("f", external.id, EdgeKind.CALLS), + CodeEdge("f", "f", EdgeKind.CALLS), + CodeEdge("f", "missing", EdgeKind.TYPE_USES), + ] + graph = CodeGraph(nodes={n.id: n for n in (source, unresolved, external)}, edges=edges) + assert graph.unresolved_calls() == edges[:2] + assert graph.edges == edges + assert CodeGraph().unresolved_calls() == [] + + class TestCodeEdge: def test_call_edge(self) -> None: edge = CodeEdge( diff --git a/tests/test_query.py b/tests/test_query.py index f7ce02b..582db8d 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -77,6 +77,73 @@ def _build_engine() -> QueryEngine: return QueryEngine.from_graph(graph) +class TestQueryEngineCallResolution: + def test_counts_call_sites_separately_from_targets_and_preserves_locations(self) -> None: + nodes = {name: _make_node(name, name) for name in ("mod:entry", "mod:other", "mod:helper")} + sites = [ + CodeEdge("mod:entry", "a.unwrap", EdgeKind.CALLS, location=_LOC), + CodeEdge("mod:entry", "a.unwrap", EdgeKind.CALLS, location=_LOC), + CodeEdge("mod:other", "b.unwrap", EdgeKind.CALLS), + CodeEdge("mod:entry", "a.expect", EdgeKind.CALLS), + CodeEdge("mod:entry", "mod:helper", EdgeKind.CALLS), + CodeEdge("mod:entry", "unknown_type", EdgeKind.TYPE_USES), + ] + engine = QueryEngine.from_graph(CodeGraph(nodes=nodes, edges=sites)) + assert engine.call_resolution() == { + "call_sites": 5, + "resolved_call_sites": 1, + "unresolved_call_sites": 4, + "unresolved_targets": 3, + "affected_callers": 2, + "unresolved_by_callee": [ + {"callee": "unwrap", "call_sites": 3, "targets": 2}, + {"callee": "expect", "call_sites": 1, "targets": 1}, + ], + } + rows = engine.unresolved_calls("mod:entry") + assert len(rows) == 3 + assert rows[0]["location"] == { + "file_path": "test.py", + "start_line": 1, + "end_line": 10, + "start_col": None, + "end_col": None, + } + assert rows[2]["location"] is None + assert rows[0]["raw_target"] == "a.unwrap" + assert rows[0]["confidence"] == "certain" + assert engine.unresolved_calls("missing") == [] + assert engine.unresolved_calls("mod:helper") == [] + assert json.loads(json.dumps(engine.call_resolution())) == engine.call_resolution() + + def test_raw_targets_are_taken_from_each_edge_when_proxy_ids_collide(self) -> None: + source = _make_node("mod:f", "f") + engine = QueryEngine.from_graph( + CodeGraph( + nodes={source.id: source}, + edges=[ + CodeEdge(source.id, "a-b.unwrap", EdgeKind.CALLS), + CodeEdge(source.id, "a_b.unwrap", EdgeKind.CALLS), + ], + ) + ) + assert {row["raw_target"] for row in engine.unresolved_calls()} == { + "a-b.unwrap", + "a_b.unwrap", + } + + def test_empty_graph_has_no_calls(self) -> None: + report = QueryEngine.from_graph(CodeGraph()).call_resolution() + assert report == { + "call_sites": 0, + "resolved_call_sites": 0, + "unresolved_call_sites": 0, + "unresolved_targets": 0, + "affected_callers": 0, + "unresolved_by_callee": [], + } + + class TestQueryEngineCallers: def test_callers_of(self) -> None: engine = _build_engine() diff --git a/tests/test_readme.py b/tests/test_readme.py index 622e7ab..1a41de6 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -183,10 +183,28 @@ def test_verus_support_is_documented(self, readme_text: str) -> None: assert "installing published packages needs neither" in readme_text +class TestCallResolutionDocs: + def test_documented_diagnostics_distinguish_sites_from_targets( + self, readme_text: str, tmp_path: Path + ) -> None: + assert "--resolution" in readme_text + assert "receiver types" in readme_text + assert "outside its counts" in readme_text + assert "Zero unresolved calls is not proof" in readme_text + (tmp_path / "app.rs").write_text("fn handle() { value.unwrap(); value.unwrap(); }") + engine = QueryEngine.from_directory(str(tmp_path), language="rust") + report = engine.call_resolution() + assert report["unresolved_call_sites"] == 2 + assert report["unresolved_targets"] == 1 + assert len(engine.unresolved_calls("handle")) == 2 + + class TestQueryEngineAPI: EXPECTED_METHODS = ( "callers_of", "callees_of", + "unresolved_calls", + "call_resolution", "ancestors_of", "reachable_from", "paths_between", diff --git a/tests/test_rust_resolution.py b/tests/test_rust_resolution.py new file mode 100644 index 0000000..27160c1 --- /dev/null +++ b/tests/test_rust_resolution.py @@ -0,0 +1,98 @@ +"""Call-resolution diagnostics for the patterns reported in issue #73.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from trailmark.parse import parse_file +from trailmark.query.api import QueryEngine + +SOURCE = """\ +struct Engine; +impl Engine { fn execute(&self) {} } +fn make() -> Result { Ok(Engine) } +fn local(engine: &Engine) { + engine.execute(); + make().unwrap().execute(); + Engine::execute(engine); +} +fn standard(values: Vec) { + values.clone(); values.len(); + values.first().unwrap(); values.first().expect("missing"); +} +fn iterators(values: Vec) { values.iter().map(|x| *x).collect::>(); } +fn conversions(value: u64) { value.into(); value.try_into(); TokenId::try_from(value); } +fn external() { UdpSocket::bind("127.0.0.1:0"); prost_build::Config::new(); } +fn builders() { Action::builder().timestamp(0); } +enum ActionKind { Deposit(u64), Withdraw } +fn constructors() { ActionKind::Deposit(1); ActionKind::Withdraw; Ok(1); Err(()); } +""" + + +@pytest.fixture +def engine(tmp_path: Path) -> QueryEngine: + path = tmp_path / "patterns.rs" + path.write_text(SOURCE) + return QueryEngine.from_graph(parse_file(str(path))) + + +@pytest.mark.parametrize( + ("caller", "callees"), + [ + ("local", {"execute", "unwrap"}), + ("standard", {"clone", "len", "first", "unwrap", "expect"}), + ("iterators", {"iter", "map", "collect"}), + ("conversions", {"into", "try_into", "try_from"}), + ("external", {"bind", "new"}), + ("builders", {"builder", "timestamp"}), + ("constructors", {"Deposit", "Ok", "Err"}), + ], +) +def test_reported_rust_patterns_remain_visible( + engine: QueryEngine, caller: str, callees: set[str] +) -> None: + rows = engine.unresolved_calls(caller) + assert {row["callee"] for row in rows} == callees + assert all(row["source"] == f"patterns:{caller}" for row in rows) + assert all(row["location"]["file_path"].endswith("patterns.rs") for row in rows) + assert all(row["location"]["start_line"] >= 1 for row in rows) + + +def test_return_type_metadata_does_not_resolve_chained_receiver(engine: QueryEngine) -> None: + assert engine.type_references("make")[0]["name"] == "Result" + assert "patterns:make" in {row["id"] for row in engine.callees_of("local")} + rows = engine.unresolved_calls("local") + assert {row["raw_target"] for row in rows} == { + "engine.execute", + "make().unwrap().execute", + "make().unwrap", + } + assert engine.paths_between("local", "Engine.execute") == [ + ["patterns:local", "patterns:Engine.execute"] + ] + # Only the explicit Engine::execute call connects this implementation. + assert len([row for row in rows if row["callee"] == "execute"]) == 2 + + +def test_opaque_macros_are_outside_diagnostic_denominator(tmp_path: Path) -> None: + path = tmp_path / "opaque.rs" + path.write_text( + "fn helper() {} fn caller() { helper(); } opaque! { fn hidden() { unknown(); } }" + ) + engine = QueryEngine.from_graph(parse_file(str(path))) + report = engine.call_resolution() + assert report["call_sites"] == report["resolved_call_sites"] == 1 + assert report["unresolved_call_sites"] == 0 + assert engine.callees_of("hidden") == [] + assert not any(node["name"] == "hidden" for node in engine.reachable_from("caller")) + + +def test_unresolved_value_receivers_do_not_reach_the_local_implementation(tmp_path: Path) -> None: + path = tmp_path / "patterns.rs" + path.write_text(SOURCE.replace("Engine::execute(engine);", "")) + engine = QueryEngine.from_graph(parse_file(str(path))) + assert engine.paths_between("local", "Engine.execute") == [] + assert "patterns:Engine.execute" not in {n["id"] for n in engine.reachable_from("local")} + assert len(engine.unresolved_calls("local")) == 3 diff --git a/tests/test_storage.py b/tests/test_storage.py index c900f38..067c7dc 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -66,6 +66,40 @@ def _build_contains_bridge_graph() -> tuple[CodeGraph, GraphStore]: return graph, GraphStore(graph) +class TestGraphStoreCallResolution: + def test_reports_validate_endpoints_and_filter_callers(self) -> None: + source = _make_node("f", "f") + other = _make_node("g", "g") + proxy = CodeUnit( + id="proxy:unknown", + name="unknown", + kind=NodeKind.PROXY, + location=_LOC, + attributes=(("proxy_kind", "unresolved"),), + ) + calls = [ + CodeEdge("f", proxy.id, EdgeKind.CALLS), + CodeEdge("g", proxy.id, EdgeKind.CALLS), + CodeEdge("f", "g", EdgeKind.CALLS), + ] + graph = CodeGraph( + nodes={n.id: n for n in (source, other, proxy)}, + edges=[ + *calls, + CodeEdge("missing", proxy.id, EdgeKind.CALLS), + CodeEdge("f", "missing", EdgeKind.CALLS), + CodeEdge("f", proxy.id, EdgeKind.TYPE_USES), + ], + ) + store = GraphStore(graph) + assert store.call_edges() == calls + assert store.unresolved_calls() == calls[:2] + assert store.unresolved_calls("f") == calls[:1] + assert store.unresolved_calls("g") == calls[1:2] + assert store.unresolved_calls("missing") == [] + assert store.unresolved_calls(proxy.id) == [] + + class TestGraphStoreCallers: def test_callers_of_b(self) -> None: _, store = _build_graph() From c535192a2e204c26a72f692881e74713a03632b6 Mon Sep 17 00:00:00 2001 From: Scott Arciszewski Date: Fri, 16 Oct 2026 13:52:15 -0400 Subject: [PATCH 2/2] Keep diagnostics additions independent of the state-web change --- README.md | 78 ++++++++++----------- src/trailmark/query/api.py | 104 ++++++++++++++-------------- tests/test_query.py | 134 ++++++++++++++++++------------------- tests/test_readme.py | 36 +++++----- tests/test_storage.py | 68 +++++++++---------- 5 files changed, 210 insertions(+), 210 deletions(-) diff --git a/README.md b/README.md index 26221dc..2ccc8c0 100644 --- a/README.md +++ b/README.md @@ -143,8 +143,6 @@ The `QueryEngine` provides a high-level API over the indexed graph: | --- | --- | | `callers_of(name)` | Direct callers of the named target | | `callees_of(name)` | Direct callees of the named source | -| `unresolved_calls(name=None)` | Unresolved call sites, optionally restricted to one caller, with raw expressions and locations | -| `call_resolution()` | Extracted call-site counts and unresolved calls grouped by callee label | | `ancestors_of(name)` | Every function that can transitively reach the target (upward slice) | | `reachable_from(name)` | Every function transitively reachable from the source | | `paths_between(src, dst)` | All simple call paths between two nodes | @@ -169,6 +167,8 @@ The `QueryEngine` provides a high-level API over the indexed graph: | `subgraph_edges(name)` | Return induced edges inside a named subgraph | | `subgraph_names()` | List every named subgraph currently on the graph | | `summary()` | Node counts, edge counts, dependencies | +| `unresolved_calls(name=None)` | Unresolved call sites, optionally restricted to one caller, with raw expressions and locations | +| `call_resolution()` | Extracted call-site counts and unresolved calls grouped by callee label | | `to_json()` | Full graph export | ### Data Model @@ -242,6 +242,43 @@ parses, including single-language parses, also apply repository links and materialize `TYPE_USES` edges. Binary analysis support imports external JSON call graphs; Trailmark does not disassemble executables itself. +### Example Graph + +Given this Python code: + +```python +class Auth: + def verify(self, token: str) -> bool: + return self._check_sig(token) + + def _check_sig(self, token: str) -> bool: + ... + +def handle_request(req: Request) -> Response: + auth = Auth() + if auth.verify(req.token): + return process(req) + return deny() +``` + +Trailmark produces a graph like: + +```mermaid +graph TD + HR["handle_request"] -->|calls| AV["Auth.verify"] + HR -->|calls| P["process"] + HR -->|calls| D["deny"] + AV -->|calls| CS["Auth._check_sig"] + A["Auth"] -->|contains| AV + A -->|contains| CS + + classDef fn fill:#007bff26,stroke:#007bff,color:#007bff + classDef cls fill:#6f42c126,stroke:#6f42c1,color:#6f42c1 + + class HR,P,D fn + class A,AV,CS cls +``` + ### Call-Resolution Diagnostics ```bash @@ -302,43 +339,6 @@ coverage. Opaque macro bodies and implicit calls may produce no edges and are outside its counts. Zero unresolved calls is not proof that the graph contains every executable path. Hiding common proxy names does not restore those paths. -### Example Graph - -Given this Python code: - -```python -class Auth: - def verify(self, token: str) -> bool: - return self._check_sig(token) - - def _check_sig(self, token: str) -> bool: - ... - -def handle_request(req: Request) -> Response: - auth = Auth() - if auth.verify(req.token): - return process(req) - return deny() -``` - -Trailmark produces a graph like: - -```mermaid -graph TD - HR["handle_request"] -->|calls| AV["Auth.verify"] - HR -->|calls| P["process"] - HR -->|calls| D["deny"] - AV -->|calls| CS["Auth._check_sig"] - A["Auth"] -->|contains| AV - A -->|contains| CS - - classDef fn fill:#007bff26,stroke:#007bff,color:#007bff - classDef cls fill:#6f42c126,stroke:#6f42c1,color:#6f42c1 - - class HR,P,D fn - class A,AV,CS cls -``` - ## Installation The examples below track the current development branch. For the latest diff --git a/src/trailmark/query/api.py b/src/trailmark/query/api.py index a751393..880071a 100644 --- a/src/trailmark/query/api.py +++ b/src/trailmark/query/api.py @@ -89,58 +89,6 @@ def callees_of(self, name: str) -> list[dict[str, Any]]: return [] return [_unit_to_dict(u) for u in self._store.callees_of(node_id)] - def unresolved_calls(self, name: str | None = None) -> list[dict[str, Any]]: - """Report unresolved call sites, including raw targets and source spans. - - ``name`` restricts the report to one caller. A missing caller returns - ``[]``; omitting it reports every unresolved call in the graph. - """ - node_id = self._store.find_node_id(name) if name is not None else None - if name is not None and node_id is None: - return [] - result: list[dict[str, Any]] = [] - for edge in self._store.unresolved_calls(node_id): - target = self._store.unit(edge.target_id) - attributes = dict(target.attributes) if target is not None else {} - raw_target = str( - dict(edge.attributes).get( - "raw_target", attributes.get("raw_symbol", edge.target_id) - ) - ) - row = _edge_to_dict(edge) - row["raw_target"] = raw_target - row["callee"] = raw_target.rsplit(".", 1)[-1].rsplit(":", 1)[-1].strip() - row["location"] = asdict(edge.location) if edge.location is not None else None - result.append(row) - return result - - def call_resolution(self) -> dict[str, Any]: - """Count graph call sites and group unresolved targets by callee label. - - These counts describe extracted edges, not completeness of the source - program: opaque macros and implicit calls may contribute no edges. - """ - calls = self._store.call_edges() - unresolved = self.unresolved_calls() - grouped: dict[str, list[dict[str, Any]]] = {} - for row in unresolved: - grouped.setdefault(row["callee"], []).append(row) - return { - "call_sites": len(calls), - "resolved_call_sites": len(calls) - len(unresolved), - "unresolved_call_sites": len(unresolved), - "unresolved_targets": len({row["target"] for row in unresolved}), - "affected_callers": len({row["source"] for row in unresolved}), - "unresolved_by_callee": [ - { - "callee": callee, - "call_sites": len(grouped[callee]), - "targets": len({row["target"] for row in grouped[callee]}), - } - for callee in sorted(grouped, key=lambda name: (-len(grouped[name]), name)) - ], - } - def ancestors_of(self, name: str) -> list[dict[str, Any]]: """Find every function/method that can transitively reach ``name``. @@ -336,6 +284,58 @@ def summary(self) -> dict[str, Any]: "entrypoints": len(graph.entrypoints), } + def unresolved_calls(self, name: str | None = None) -> list[dict[str, Any]]: + """Report unresolved call sites, including raw targets and source spans. + + ``name`` restricts the report to one caller. A missing caller returns + ``[]``; omitting it reports every unresolved call in the graph. + """ + node_id = self._store.find_node_id(name) if name is not None else None + if name is not None and node_id is None: + return [] + result: list[dict[str, Any]] = [] + for edge in self._store.unresolved_calls(node_id): + target = self._store.unit(edge.target_id) + attributes = dict(target.attributes) if target is not None else {} + raw_target = str( + dict(edge.attributes).get( + "raw_target", attributes.get("raw_symbol", edge.target_id) + ) + ) + row = _edge_to_dict(edge) + row["raw_target"] = raw_target + row["callee"] = raw_target.rsplit(".", 1)[-1].rsplit(":", 1)[-1].strip() + row["location"] = asdict(edge.location) if edge.location is not None else None + result.append(row) + return result + + def call_resolution(self) -> dict[str, Any]: + """Count graph call sites and group unresolved targets by callee label. + + These counts describe extracted edges, not completeness of the source + program: opaque macros and implicit calls may contribute no edges. + """ + calls = self._store.call_edges() + unresolved = self.unresolved_calls() + grouped: dict[str, list[dict[str, Any]]] = {} + for row in unresolved: + grouped.setdefault(row["callee"], []).append(row) + return { + "call_sites": len(calls), + "resolved_call_sites": len(calls) - len(unresolved), + "unresolved_call_sites": len(unresolved), + "unresolved_targets": len({row["target"] for row in unresolved}), + "affected_callers": len({row["source"] for row in unresolved}), + "unresolved_by_callee": [ + { + "callee": callee, + "call_sites": len(grouped[callee]), + "targets": len({row["target"] for row in grouped[callee]}), + } + for callee in sorted(grouped, key=lambda name: (-len(grouped[name]), name)) + ], + } + def preanalysis(self) -> dict[str, Any]: """Run all pre-analysis passes and return a summary. diff --git a/tests/test_query.py b/tests/test_query.py index 582db8d..06a9f66 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -77,73 +77,6 @@ def _build_engine() -> QueryEngine: return QueryEngine.from_graph(graph) -class TestQueryEngineCallResolution: - def test_counts_call_sites_separately_from_targets_and_preserves_locations(self) -> None: - nodes = {name: _make_node(name, name) for name in ("mod:entry", "mod:other", "mod:helper")} - sites = [ - CodeEdge("mod:entry", "a.unwrap", EdgeKind.CALLS, location=_LOC), - CodeEdge("mod:entry", "a.unwrap", EdgeKind.CALLS, location=_LOC), - CodeEdge("mod:other", "b.unwrap", EdgeKind.CALLS), - CodeEdge("mod:entry", "a.expect", EdgeKind.CALLS), - CodeEdge("mod:entry", "mod:helper", EdgeKind.CALLS), - CodeEdge("mod:entry", "unknown_type", EdgeKind.TYPE_USES), - ] - engine = QueryEngine.from_graph(CodeGraph(nodes=nodes, edges=sites)) - assert engine.call_resolution() == { - "call_sites": 5, - "resolved_call_sites": 1, - "unresolved_call_sites": 4, - "unresolved_targets": 3, - "affected_callers": 2, - "unresolved_by_callee": [ - {"callee": "unwrap", "call_sites": 3, "targets": 2}, - {"callee": "expect", "call_sites": 1, "targets": 1}, - ], - } - rows = engine.unresolved_calls("mod:entry") - assert len(rows) == 3 - assert rows[0]["location"] == { - "file_path": "test.py", - "start_line": 1, - "end_line": 10, - "start_col": None, - "end_col": None, - } - assert rows[2]["location"] is None - assert rows[0]["raw_target"] == "a.unwrap" - assert rows[0]["confidence"] == "certain" - assert engine.unresolved_calls("missing") == [] - assert engine.unresolved_calls("mod:helper") == [] - assert json.loads(json.dumps(engine.call_resolution())) == engine.call_resolution() - - def test_raw_targets_are_taken_from_each_edge_when_proxy_ids_collide(self) -> None: - source = _make_node("mod:f", "f") - engine = QueryEngine.from_graph( - CodeGraph( - nodes={source.id: source}, - edges=[ - CodeEdge(source.id, "a-b.unwrap", EdgeKind.CALLS), - CodeEdge(source.id, "a_b.unwrap", EdgeKind.CALLS), - ], - ) - ) - assert {row["raw_target"] for row in engine.unresolved_calls()} == { - "a-b.unwrap", - "a_b.unwrap", - } - - def test_empty_graph_has_no_calls(self) -> None: - report = QueryEngine.from_graph(CodeGraph()).call_resolution() - assert report == { - "call_sites": 0, - "resolved_call_sites": 0, - "unresolved_call_sites": 0, - "unresolved_targets": 0, - "affected_callers": 0, - "unresolved_by_callee": [], - } - - class TestQueryEngineCallers: def test_callers_of(self) -> None: engine = _build_engine() @@ -255,6 +188,73 @@ def test_connect_subgraphs_with_bridge_edges(self) -> None: ) == [["src", "proxy", "resolved", "bin"]] +class TestQueryEngineCallResolution: + def test_counts_call_sites_separately_from_targets_and_preserves_locations(self) -> None: + nodes = {name: _make_node(name, name) for name in ("mod:entry", "mod:other", "mod:helper")} + sites = [ + CodeEdge("mod:entry", "a.unwrap", EdgeKind.CALLS, location=_LOC), + CodeEdge("mod:entry", "a.unwrap", EdgeKind.CALLS, location=_LOC), + CodeEdge("mod:other", "b.unwrap", EdgeKind.CALLS), + CodeEdge("mod:entry", "a.expect", EdgeKind.CALLS), + CodeEdge("mod:entry", "mod:helper", EdgeKind.CALLS), + CodeEdge("mod:entry", "unknown_type", EdgeKind.TYPE_USES), + ] + engine = QueryEngine.from_graph(CodeGraph(nodes=nodes, edges=sites)) + assert engine.call_resolution() == { + "call_sites": 5, + "resolved_call_sites": 1, + "unresolved_call_sites": 4, + "unresolved_targets": 3, + "affected_callers": 2, + "unresolved_by_callee": [ + {"callee": "unwrap", "call_sites": 3, "targets": 2}, + {"callee": "expect", "call_sites": 1, "targets": 1}, + ], + } + rows = engine.unresolved_calls("mod:entry") + assert len(rows) == 3 + assert rows[0]["location"] == { + "file_path": "test.py", + "start_line": 1, + "end_line": 10, + "start_col": None, + "end_col": None, + } + assert rows[2]["location"] is None + assert rows[0]["raw_target"] == "a.unwrap" + assert rows[0]["confidence"] == "certain" + assert engine.unresolved_calls("missing") == [] + assert engine.unresolved_calls("mod:helper") == [] + assert json.loads(json.dumps(engine.call_resolution())) == engine.call_resolution() + + def test_raw_targets_are_taken_from_each_edge_when_proxy_ids_collide(self) -> None: + source = _make_node("mod:f", "f") + engine = QueryEngine.from_graph( + CodeGraph( + nodes={source.id: source}, + edges=[ + CodeEdge(source.id, "a-b.unwrap", EdgeKind.CALLS), + CodeEdge(source.id, "a_b.unwrap", EdgeKind.CALLS), + ], + ) + ) + assert {row["raw_target"] for row in engine.unresolved_calls()} == { + "a-b.unwrap", + "a_b.unwrap", + } + + def test_empty_graph_has_no_calls(self) -> None: + report = QueryEngine.from_graph(CodeGraph()).call_resolution() + assert report == { + "call_sites": 0, + "resolved_call_sites": 0, + "unresolved_call_sites": 0, + "unresolved_targets": 0, + "affected_callers": 0, + "unresolved_by_callee": [], + } + + class TestQueryEngineTypes: def test_generic_parameters(self) -> None: node = CodeUnit( diff --git a/tests/test_readme.py b/tests/test_readme.py index 1a41de6..5f47870 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -183,28 +183,10 @@ def test_verus_support_is_documented(self, readme_text: str) -> None: assert "installing published packages needs neither" in readme_text -class TestCallResolutionDocs: - def test_documented_diagnostics_distinguish_sites_from_targets( - self, readme_text: str, tmp_path: Path - ) -> None: - assert "--resolution" in readme_text - assert "receiver types" in readme_text - assert "outside its counts" in readme_text - assert "Zero unresolved calls is not proof" in readme_text - (tmp_path / "app.rs").write_text("fn handle() { value.unwrap(); value.unwrap(); }") - engine = QueryEngine.from_directory(str(tmp_path), language="rust") - report = engine.call_resolution() - assert report["unresolved_call_sites"] == 2 - assert report["unresolved_targets"] == 1 - assert len(engine.unresolved_calls("handle")) == 2 - - class TestQueryEngineAPI: EXPECTED_METHODS = ( "callers_of", "callees_of", - "unresolved_calls", - "call_resolution", "ancestors_of", "reachable_from", "paths_between", @@ -228,6 +210,8 @@ class TestQueryEngineAPI: "subgraph", "subgraph_edges", "subgraph_names", + "unresolved_calls", + "call_resolution", "summary", "to_json", ) @@ -274,6 +258,22 @@ def test_clear_annotations_signature_matches_readme(self) -> None: assert params["kind"].default is None +class TestCallResolutionDocs: + def test_documented_diagnostics_distinguish_sites_from_targets( + self, readme_text: str, tmp_path: Path + ) -> None: + assert "--resolution" in readme_text + assert "receiver types" in readme_text + assert "outside its counts" in readme_text + assert "Zero unresolved calls is not proof" in readme_text + (tmp_path / "app.rs").write_text("fn handle() { value.unwrap(); value.unwrap(); }") + engine = QueryEngine.from_directory(str(tmp_path), language="rust") + report = engine.call_resolution() + assert report["unresolved_call_sites"] == 2 + assert report["unresolved_targets"] == 1 + assert len(engine.unresolved_calls("handle")) == 2 + + class TestConfigurationDocs: def test_links_toml_uses_per_endpoint_external_flags(self, readme_text: str) -> None: """README documents per-endpoint external link flags, not entry-level external.""" diff --git a/tests/test_storage.py b/tests/test_storage.py index 067c7dc..863c3d8 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -66,40 +66,6 @@ def _build_contains_bridge_graph() -> tuple[CodeGraph, GraphStore]: return graph, GraphStore(graph) -class TestGraphStoreCallResolution: - def test_reports_validate_endpoints_and_filter_callers(self) -> None: - source = _make_node("f", "f") - other = _make_node("g", "g") - proxy = CodeUnit( - id="proxy:unknown", - name="unknown", - kind=NodeKind.PROXY, - location=_LOC, - attributes=(("proxy_kind", "unresolved"),), - ) - calls = [ - CodeEdge("f", proxy.id, EdgeKind.CALLS), - CodeEdge("g", proxy.id, EdgeKind.CALLS), - CodeEdge("f", "g", EdgeKind.CALLS), - ] - graph = CodeGraph( - nodes={n.id: n for n in (source, other, proxy)}, - edges=[ - *calls, - CodeEdge("missing", proxy.id, EdgeKind.CALLS), - CodeEdge("f", "missing", EdgeKind.CALLS), - CodeEdge("f", proxy.id, EdgeKind.TYPE_USES), - ], - ) - store = GraphStore(graph) - assert store.call_edges() == calls - assert store.unresolved_calls() == calls[:2] - assert store.unresolved_calls("f") == calls[:1] - assert store.unresolved_calls("g") == calls[1:2] - assert store.unresolved_calls("missing") == [] - assert store.unresolved_calls(proxy.id) == [] - - class TestGraphStoreCallers: def test_callers_of_b(self) -> None: _, store = _build_graph() @@ -575,6 +541,40 @@ def test_complexity_multiple_matches(self) -> None: assert ids == {"a", "b"} +class TestGraphStoreCallResolution: + def test_reports_validate_endpoints_and_filter_callers(self) -> None: + source = _make_node("f", "f") + other = _make_node("g", "g") + proxy = CodeUnit( + id="proxy:unknown", + name="unknown", + kind=NodeKind.PROXY, + location=_LOC, + attributes=(("proxy_kind", "unresolved"),), + ) + calls = [ + CodeEdge("f", proxy.id, EdgeKind.CALLS), + CodeEdge("g", proxy.id, EdgeKind.CALLS), + CodeEdge("f", "g", EdgeKind.CALLS), + ] + graph = CodeGraph( + nodes={n.id: n for n in (source, other, proxy)}, + edges=[ + *calls, + CodeEdge("missing", proxy.id, EdgeKind.CALLS), + CodeEdge("f", "missing", EdgeKind.CALLS), + CodeEdge("f", proxy.id, EdgeKind.TYPE_USES), + ], + ) + store = GraphStore(graph) + assert store.call_edges() == calls + assert store.unresolved_calls() == calls[:2] + assert store.unresolved_calls("f") == calls[:1] + assert store.unresolved_calls("g") == calls[1:2] + assert store.unresolved_calls("missing") == [] + assert store.unresolved_calls(proxy.id) == [] + + class TestGraphStoreFindNode: def test_find_by_exact_id(self) -> None: _, store = _build_graph()