diff --git a/README.md b/README.md index 5fb8b72..2ccc8c0 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 @@ -165,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 @@ -275,6 +279,66 @@ graph TD class A,AV,CS cls ``` +### 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. + ## Installation The examples below track the current development branch. For the latest 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..880071a 100644 --- a/src/trailmark/query/api.py +++ b/src/trailmark/query/api.py @@ -284,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/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..06a9f66 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -188,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 622e7ab..5f47870 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -210,6 +210,8 @@ class TestQueryEngineAPI: "subgraph", "subgraph_edges", "subgraph_names", + "unresolved_calls", + "call_resolution", "summary", "to_json", ) @@ -256,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_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..863c3d8 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -541,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()