Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Engine, ()>` 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
Expand Down
14 changes: 11 additions & 3 deletions src/trailmark/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 16 additions & 2 deletions src/trailmark/models/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
52 changes: 52 additions & 0 deletions src/trailmark/query/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
22 changes: 22 additions & 0 deletions src/trailmark/storage/graph_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]):
Expand Down
25 changes: 25 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
67 changes: 67 additions & 0 deletions tests/test_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading