docs: trim the README and split usage, resolution and development out #6

Merged
trevon merged 2 commits from docs/readme-split into main 2026-09-25 02:54:21 +00:00
12 changed files with 1010 additions and 10 deletions
Showing only changes of commit 4de9884a54 - Show all commits

Merge main into docs/readme-split: carry #7's caller-status docs into the split
Some checks failed
build / build-linux (pull_request) Successful in 1m4s
build / build-macos (pull_request) Has been cancelled
build / release (pull_request) Has been cancelled

trvon 2026-09-24 20:53:21 -06:00
Signed by: trevon
GPG key ID: 38686EA797D7A8A7

View file

@ -43,7 +43,7 @@ finds no supported source says so.
| `brick build <repo>` | Scan a repository; write JSON and HTML. Can layer Fold/SARIF or p4c findings onto the graph. |
| `brick render <model.json>` | Render an existing model to HTML. |
| `brick check <model.json>` | Gate on the structural score, cycles, a baseline diff, or a dependency policy. Metrics are recomputed from the graph, never trusted from the file. |
| `brick mcp <model.json>` | Serve read-only MCP queries: impact, cycles, bridges, evidence. |
| `brick mcp <model.json>` | Serve read-only MCP queries: impact, cycles, bridges, evidence, and caller status (is a function called, unknown, or unreferenced). |
| `brick setup` | Report which optional frontends this binary can initialize. |
Full usage, including the import contracts: [docs/usage.md](docs/usage.md).

View file

@ -69,6 +69,35 @@ Required input: `entity_id`. An edge lookup returns the complete edge and both
endpoint nodes. A node lookup returns the complete node and all incident edges
in stable-ID order.
### `brick_callers`
Required input: `node_id` (a function). Optional `assume_closed_world`
(default false). Returns `report` with the complete function node, `status`,
`basis`, `reasons`, and complete resolved caller edges, plus the language's
`metrics.resolution` coverage and a fixed `caveat`.
- `resolved`: at least one resolved caller edge in any lane.
- `unknown`: no resolved caller, but the model records why one may exist:
`unresolved-call-sites` (same-name unresolved sites, with counts by
`source.unresolved-reason` and example provenance; sites on foreign-typed
receivers and macros are excluded), `referenced-by-name` (callback or
address-taken candidate), `virtual`, `trait-impl`, `external-base`,
`framework-attribute`, `implicit-invocation`, `outside-class-member`,
`parse-incomplete`, `entry-point`, `test-code`, and,
unless `assume_closed_world`, `exported` or `external-linkage`.
- `unreferenced`: neither. This is a review candidate, not proof of dead code.
`basis` is `compiler` only when Imported call evidence covers the function's
file; otherwise `heuristic`.
### `brick_uncalled`
Optional `language`, `assume_closed_world` (default false), `include_unknown`
(default false), and `limit` (default 100). Returns per-language status counts,
`unreferenced_total`, and `unreferenced` reports ordered by path, line, and ID;
with `include_unknown`, also `unknown_total` and `unknown` reports. Use
`assume_closed_world: true` only when the scan covers the complete program.
## Determinism and limits
Nodes, edges, cycles, and bridges are ordered by stable identity, independent

View file

@ -0,0 +1,191 @@
# C/C++ and Rust call resolution for agents
## Problem
A dead-code audit of a real C++ repository found that 93% of symbols had no
resolved incoming edge. Brick could not tell "unresolved" from "unused", so
reporting those symbols as dead would have produced the false findings the
audit was looking for. The audit on 2026-09-22 found these root causes:
| Cause | Where |
| --- | --- |
| C++ bare calls resolved only within one file; `#include` never authorized a cross-file call | `scan.rs` owner-chain lookup |
| Header prototypes not recorded; out-of-line `A::f` named `"A::f"` with a file owner | `source.rs` `declaration` |
| `T& f()` named `"& f(...)"` (fieldless `reference_declarator`) | `source.rs` |
| Every member call (`obj.f()`, `this->f()`, `self.m()`) External | `scan.rs` |
| Calls inside Rust macros (`assert!`, `format!`, `vec!`) dropped (`token_tree` skipped) | `source.rs` |
| `std::lock_guard<M> g(f());` hides `f()` (most vexing parse) | tree-sitter-cpp |
| `static_cast<T>(x)` recorded as a call | `source.rs` |
| No unresolved reason; per-caller External identity; no coverage metric | model |
| Linear node/edge scans and per-node ancestor walks | `scan.rs`, `source.rs` |
## Literature (paperbridge, arXiv/OpenAlex, 2026-09-22)
- Murphy, Notkin et al., "An empirical study of static call graph extractors",
TOSEM 1998, doi:10.1145/279310.279314. Extractors disagree widely, so each
edge keeps its backend and the resolution method that produced it.
- Grove, DeFouw, Dean, Chambers, "Call graph construction in object-oriented
languages", OOPSLA 1997, doi:10.1145/263698.264352; Grove and Chambers,
TOPLAS 2001, doi:10.1145/506315.506316. Receiver type sets bound dispatch.
Brick uses declared static types only, and treats virtual and trait targets as
unknown rather than enumerating overrides.
- Srivastava, "Unreachable procedures in object-oriented programming", LOPLAS
1992, doi:10.1145/161494.161517. Deadness is relative to roots and dispatch
assumptions. `brick_callers` therefore names its reasons and its evidence
basis instead of issuing a verdict.
- Creager and van Antwerpen, "Stack graphs: Name resolution at scale",
arXiv:2211.01224. This is file-local, build-free scope resolution. The C++
include-closure lookup and the Rust impl index follow the same idea at
much lower fidelity.
- Cai and Zhang, "A Cocktail Approach to Practical Call Graph Construction",
OOPSLA 2023, doi:10.1145/3622833, and Li, He et al., Rupta, CC 2024,
doi:10.1145/3640537.3641574. Function pointers (C) and trait/dyn dispatch
(Rust) need pointer or MIR analysis. That work belongs to Fold's compiler lane,
not to syntax.
- LocAgent (ACL 2025, doi:10.18653/v1/2025.acl-long.426) and CodexGraph
(NAACL 2025). Agents navigate code graphs effectively, which makes edge
provenance and uncertainty part of the agent contract.
## Experiment contract
```json
{
"hypothesis": "Include-visible C++ lookup, declared-receiver typing, Rust impl indexing and macro re-parsing raise labeled call recall without clean-negative regressions, and reasons make zero-caller symbols distinguishable from unresolved ones",
"owner": "source extractor and heuristic resolver; caller-status query",
"fixtures": ["tests/fixtures/cpp_xtu", "tests/fixtures/rust_methods", "tests/fixtures/languages", "tests/fixtures/rust_imports"],
"expected": {
"increases": ["labeled C++/Rust relationship precision and recall", "resolved call-site share on unlabeled repositories"],
"preserves": ["schema 4 reader contract", "Heuristic lane and 0.65 confidence", "existing labeled corpora", "stable IDs of unrelated and out-of-line C++ declarations"]
},
"decision": "promote in the default extractor"
}
```
## Results
The labeled corpora were authored before the implementation. Each has 3
repeats with stable full-model JSON, and the quality gate runs both.
| Corpus | Baseline (050b1b1) relationships | Candidate |
| --- | ---: | ---: |
| `cpp_xtu` (headers, out-of-line methods, namespaces, overloads, internal linkage, macros, receivers, bases, a duplicate-program clean negative, `std::string` / untyped receiver negatives) | 12/17, 6 unexpected (first label version) | 24/24 exact |
| `rust_methods` (`Self::`, fallible constructors, `Arc` fields, macro bodies, `dyn` trait, competing trait impls, `std::fs::File` shadowing a repository `File`, generics) | 13/23, precision 0.65 | 23/23 exact |
| `source_languages`, `rust_imports` | exact | exact |
Coverage on unlabeled repositories. These are coverage observations, not
precision claims:
| Repository | Functions with no resolved caller | Resolved call sites | Scan time |
| --- | --- | --- | --- |
| Fold `src/`+`include/` (C++, 2341 functions) | 66.3% → 39.0% | 9.5% → 23.1% | 8.8 s → 2.3 s |
| Paperbridge (Rust, 1175 functions) | 66.7% → 50.6% | 985 → 1606 sites | 2.5 s → 0.8 s |
| Brick (Rust, 329 functions) | 45.3% → 28.9% | 575 → 723 sites | 1.2 s → 0.4 s |
Precision spot checks drew random samples of receiver-typed edges: 12 from Fold
and 14 from Paperbridge. Every sampled edge was plausible, and the three Fold
receiver declarations that were checked by hand were correct.
`brick_uncalled` on Fold (open world) lists 5 `unreferenced` functions, and
grep confirms that all 5 have no uses. It first listed 8; the 3 false
candidates were vexing-parse calls, which extraction now recovers.
On Paperbridge (open world) it lists 0 unreferenced functions. In closed world
it lists 8, all of which are used only by the excluded `tests/` directories.
The candidates that exposed framework-attribute (`#[tool]`, `#[test]`) and
`deserialize_with` string uses are now reported as `unknown` with those
reasons.
## YAMS validation (C++, 15,607 functions including tests)
| | Baseline (050b1b1) | Candidate |
| --- | ---: | ---: |
| Scan time | 575 s | 23 s |
| Functions with no resolved caller | 84.5% | 49.7% |
| Resolved call sites | 2.5% | 13.9% |
| Files reported as incomplete syntax | 616 | 316 |
The YAMS run exposed six defects, each fixed with a regression test:
1. tree-sitter-cpp 0.23.4 parses a `= {}` default argument as a compound
literal with a zero-width MISSING type. The syntax is otherwise intact, so
it is now benign. Before, it dropped every such prototype and flagged half
the files. Of the 910 production files, 70 still have real errors, mostly
`#if` blocks that split braces.
2. `using namespace N;` and `using N::f;` were ignored. They now extend C++
lookup through the include closure, which resolved about 4,500 more sites.
3. The receiver type of a template parameter (`template <class Ptr> f(Ptr p)
{ p->g(); }`) was classified as a foreign type. That is unsound, because it
hid a real caller. Such receivers are now `receiver-type-unknown`. Type
aliases and typedefs resolve to their target, through a smart pointer where
applicable.
4. Functions reachable only through a macro body (`#define dlopen
dlopen_impl`) now count as referenced by name. So do identifiers inside
syntax-error regions.
5. A file with real syntax errors can misplace scope and linkage: the error
recovery for an `#if` that splits a brace moved a public function into an
anonymous namespace. Its functions now carry `source.parse-incomplete`, and
`brick_callers` reports that reason.
6. These are now `implicit-invocation`: coroutine protocol members
(`await_ready`, `final_suspend`, and the rest), range-for `begin`/`end`, ADL
`swap`, and constructors of explicit specializations (`Expected<void>`).
Out-of-line members of classes that are not in the repository
(`tinyfsm::Fsm<..>::set_initial_state`) are `outside-class-member`.
`brick_uncalled` results after the fixes:
- **Open world (default):** 2 candidates, and both are unused. One is
`neutralText`. The other is a Windows-only static `describeProcess`: every
call is made inside a `DaemonCommand` method, where the static member of the
same name wins under C++ class-scope lookup.
- **Closed world:** 357 candidates in `src/` and `include/`.
- The first random sample of 25 was 20 correct; its five errors led to
fixes 3 and 6 above.
- A second, independent sample of 30 was 29 correct, and the error was the
specialization constructor, now fixed.
- "Correct" means the function has no mentions outside its own
declarations, comments, log strings, or unrelated same-named functions.
## Model changes (schema 4, optional fields)
- **Function node attributes:**
- `source.parse-incomplete`;
- `source.qualifier` (out-of-line `A::f`, whose node name is now `f`; the
ID still hashes `A::f`);
- `source.qualified-name` (C++);
- `source.linkage: internal`;
- `source.virtual`;
- `source.implements` (Rust trait impl);
- `source.attributes` (Rust);
- `source.referenced-by-name`.
- **Call edge attributes:**
- `source.unresolved-reason`, with values `not-visible`, `declared-only`,
`ambiguous`, `macro`, `receiver-type-unknown`, `receiver-type-external`,
`member-not-found`, `qualifier-unknown`, `member-receiver`,
`not-in-repository` and `runtime-parameter-binding`;
- `source.resolution` (`declared-receiver-type`, `self-receiver`,
`impl-type`, `using-directive`);
- `source.extraction` (`macro-reparse`, `vexing-parse`).
- **`metrics.resolution`:** per language, the counts of call sites, resolved
sites, unresolved sites by reason, functions, and functions without resolved
callers.
### Intentional identity changes
- C++ and Rust External targets now include the unresolved reason in their
identity, so two differently failing sites never merge and lose a reason.
- Reference-returning C++ functions get correct names and therefore new IDs.
- The labeled-corpus runner renders `source.qualifier` in symbol identities.
## Limits
- All inferred relationships remain Heuristic.
- **C++:** no preprocessing, overload resolution, template instantiation,
ADL, `auto` inference beyond `new T`/`make_unique<T>`, or
`compile_commands.json` include paths.
- **Rust:** types are matched by leaf name, and there is no Cargo workspace,
`mod` tree, or re-export resolution.
- **Dispatch:** virtual and trait dispatch reach only the statically declared
method; overrides and trait implementations are reported as `unknown`, never
as dead.
- The compiler lane (Fold `ProjectCallGraph`, `fold-rust` MIR callees) and
candidate edges are the next stages (see `docs/ROADMAP.md`).

View file

@ -36,4 +36,5 @@ A Paperbridge search on 2026-09-22 (arXiv and OpenAlex) informed the C/C++
and Rust resolution work: Murphy and Notkin (TOSEM 1998), Grove et al. (OOPSLA
1997, TOPLAS 2001), Srivastava (LOPLAS 1992), Creager and van Antwerpen (stack
graphs, arXiv:2211.01224), Cai and Zhang (OOPSLA 2023), Li et al. (Rupta,
CC 2024), and LocAgent (ACL 2025).
CC 2024), and LocAgent (ACL 2025). See `cpp-rust-resolution.md` for how each
shaped the design.

View file

@ -70,7 +70,9 @@ The source-language regression corpus checks exact declarations, owners and
dependencies across six language strata, including clean-negative files; its
three-repeat benchmark runs in the quality gate. These labelled cases establish
the covered behaviours, not language-wide accuracy. See
[language correctness](research/language-correctness.md).
[language correctness](research/language-correctness.md), and
[C/C++ and Rust resolution](research/cpp-rust-resolution.md) for that
resolver's research, validation and the agent-facing caller-status contract.
## Schema

View file

@ -114,10 +114,13 @@ keep their complete evidence. See [policy](architecture/policy.md).
cargo run -- mcp current.brick.json
```
A read-only MCP 2025-06-18 stdio server over a validated model. Its four tools
cover dependency/dependent impact, SCC cycles, weak-graph bridges and complete
node/edge evidence inspection; they never scan, mutate or render. See
[MCP](architecture/mcp.md).
A read-only MCP 2025-06-18 stdio server over a validated model. Its six tools
cover dependency/dependent impact, SCC cycles, weak-graph bridges, complete
node/edge evidence inspection, and caller status: `brick_callers` reports
whether a function is `resolved`, `unknown` (with the reasons callers may
exist) or `unreferenced`, and `brick_uncalled` lists unreferenced functions per
language. Unreferenced is a review candidate, never proof of dead code. The
tools never scan, mutate or render. See [MCP](architecture/mcp.md).
## What the model contains

371
src/callers.rs Normal file
View file

@ -0,0 +1,371 @@
//! Per-function caller status for agents asking "is this used?".
//!
//! Brick's call graph is incomplete by construction (heuristic resolution,
//! untyped receivers, dynamic dispatch, callbacks). A function without a
//! resolved caller is therefore reported as `unknown` whenever the model holds
//! any evidence that an unresolved use could reach it, and as `unreferenced`
//! only when no such evidence exists. Even `unreferenced` is a candidate for
//! review, not a proof: the `basis` field says which evidence lanes covered it.
use crate::model::{Building, Edge, EdgeKind, EvidenceLane, Node, NodeKind};
use serde::Serialize;
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
/// Unresolved-call reasons that cannot reach a repository function: the
/// receiver is a non-repository type, or the name is a preprocessor macro.
const UNREACHABLE_REASONS: [&str; 2] = ["receiver-type-external", "macro"];
const EXAMPLE_LIMIT: usize = 5;
/// C++ members the language calls by convention: coroutine promise/awaiter
/// protocol, range-for, and ADL `swap`.
const IMPLICIT_PROTOCOL: [&str; 15] = [
"await_ready",
"await_suspend",
"await_resume",
"initial_suspend",
"final_suspend",
"get_return_object",
"get_return_object_on_allocation_failure",
"return_value",
"return_void",
"yield_value",
"unhandled_exception",
"await_transform",
"begin",
"end",
"swap",
];
#[derive(Debug, Clone, Copy, Default)]
pub struct CallerOptions {
/// Treat the repository as a complete program: exported/public and
/// external-linkage functions are not assumed to have outside callers.
pub assume_closed_world: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum CallerStatus {
Resolved,
Unknown,
Unreferenced,
}
#[derive(Debug, Clone, Serialize)]
pub struct Reason {
pub kind: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub sites: Option<usize>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub by_reason: BTreeMap<String, usize>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub examples: Vec<String>,
}
impl Reason {
fn flag(kind: &'static str) -> Self {
Self {
kind,
sites: None,
by_reason: BTreeMap::new(),
examples: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct CallerReport<'a> {
pub node: &'a Node,
pub status: CallerStatus,
/// `compiler` when Imported call evidence covers the function's file.
pub basis: &'static str,
pub reasons: Vec<Reason>,
pub callers: Vec<&'a Edge>,
}
pub struct CallerAnalysis<'a> {
nodes: HashMap<&'a str, &'a Node>,
incoming: HashMap<&'a str, Vec<&'a Edge>>,
/// (language, leaf name) -> unresolved call edges that may target it.
unresolved: HashMap<(&'a str, &'a str), Vec<&'a Edge>>,
compiler_files: HashSet<&'a str>,
/// Classes (by id) inheriting from a base outside the repository.
external_bases: HashSet<&'a str>,
/// C++ class qualified name -> class id, for out-of-line methods.
classes: HashMap<&'a str, &'a str>,
/// Enclosing scopes of repository declarations (namespaces and classes).
namespaces: HashSet<&'a str>,
options: CallerOptions,
}
impl<'a> CallerAnalysis<'a> {
pub fn new(building: &'a Building, options: CallerOptions) -> Self {
let nodes: HashMap<&str, &Node> = building
.nodes
.iter()
.map(|node| (node.id.as_str(), node))
.collect();
let mut incoming: HashMap<&str, Vec<&Edge>> = HashMap::new();
let mut unresolved: HashMap<(&str, &str), Vec<&Edge>> = HashMap::new();
let mut compiler_files = HashSet::new();
let external_bases = building
.edges
.iter()
.filter(|edge| {
edge.kind == EdgeKind::Inherits
&& nodes
.get(edge.target.as_str())
.is_some_and(|target| target.kind == NodeKind::External)
})
.map(|edge| edge.source.as_str())
.collect();
let classes = building
.nodes
.iter()
.filter(|node| node.kind == NodeKind::Class)
.filter_map(|node| {
let name = node.attributes.get("source.qualified-name")?.as_str()?;
Some((name, node.id.as_str()))
})
.collect();
let namespaces = building
.nodes
.iter()
.filter_map(|node| node.attributes.get("source.scope")?.as_str())
.flat_map(|scope| {
scope
.match_indices("::")
.map(|(index, _)| &scope[..index])
.chain(std::iter::once(scope))
})
.collect();
for edge in building.edges.iter().filter(|e| e.kind == EdgeKind::Calls) {
let (Some(source), Some(target)) = (
nodes.get(edge.source.as_str()),
nodes.get(edge.target.as_str()),
) else {
continue;
};
if edge
.evidence
.iter()
.any(|evidence| evidence.lane == EvidenceLane::Imported)
{
compiler_files.extend(source.path.as_deref());
compiler_files.extend(target.path.as_deref());
}
if target.kind != NodeKind::External {
incoming.entry(target.id.as_str()).or_default().push(edge);
continue;
}
let reason = unresolved_reason(edge);
if UNREACHABLE_REASONS.contains(&reason) {
continue;
}
if let Some(language) = source.language.as_deref() {
let leaf = target.name.rsplit("::").next().unwrap_or(&target.name);
unresolved.entry((language, leaf)).or_default().push(edge);
}
}
Self {
nodes,
incoming,
unresolved,
compiler_files,
external_bases,
classes,
namespaces,
options,
}
}
pub fn report(&self, node: &'a Node) -> CallerReport<'a> {
let mut callers = self
.incoming
.get(node.id.as_str())
.cloned()
.unwrap_or_default();
callers.sort_by(|left, right| left.id.cmp(&right.id));
let reasons = if callers.is_empty() {
self.reasons(node)
} else {
Vec::new()
};
let status = if !callers.is_empty() {
CallerStatus::Resolved
} else if !reasons.is_empty() {
CallerStatus::Unknown
} else {
CallerStatus::Unreferenced
};
let basis = if node
.path
.as_deref()
.is_some_and(|path| self.compiler_files.contains(path))
{
"compiler"
} else {
"heuristic"
};
CallerReport {
node,
status,
basis,
reasons,
callers,
}
}
fn reasons(&self, node: &Node) -> Vec<Reason> {
let mut reasons = Vec::new();
let language = node.language.as_deref().unwrap_or("");
let flag = |key: &str| {
node.attributes
.get(key)
.is_some_and(|v| v != &Value::Bool(false))
};
if let Some(edges) = self.unresolved.get(&(language, node.name.as_str())) {
let mut reason = Reason::flag("unresolved-call-sites");
let mut examples = BTreeSet::new();
let mut sites = 0;
for edge in edges {
*reason
.by_reason
.entry(unresolved_reason(edge).to_owned())
.or_default() += edge.evidence.len();
sites += edge.evidence.len();
examples.extend(edge.evidence.iter().map(|e| e.provenance.clone()));
}
reason.sites = Some(sites);
reason.examples = examples.into_iter().take(EXAMPLE_LIMIT).collect();
reasons.push(reason);
}
if flag("source.referenced-by-name") {
reasons.push(Reason::flag("referenced-by-name"));
}
if flag("source.virtual") {
reasons.push(Reason::flag("virtual"));
}
if flag("source.implements") {
reasons.push(Reason::flag("trait-impl"));
}
if self
.owning_class(node)
.is_some_and(|class| self.external_bases.contains(class))
{
// Framework bases (visitors, handlers) call overrides without `override`.
reasons.push(Reason::flag("external-base"));
}
if flag("source.attributes") {
reasons.push(Reason::flag("framework-attribute"));
}
if language == "cpp" && self.defines_outside_member(node) {
// `ns::Foreign::f() {}` completes a class the repository does not
// declare (a library template specialization); the library calls it.
reasons.push(Reason::flag("outside-class-member"));
}
if flag("source.parse-incomplete") {
// Syntax errors can drop calls and misplace scope/linkage in this file.
reasons.push(Reason::flag("parse-incomplete"));
}
if language == "cpp" && self.implicitly_invoked(node) {
reasons.push(Reason::flag("implicit-invocation"));
}
if node.name == "main" {
reasons.push(Reason::flag("entry-point"));
}
if is_test_code(node) {
reasons.push(Reason::flag("test-code"));
}
if !self.options.assume_closed_world {
if flag("source.exports") {
reasons.push(Reason::flag("exported"));
}
if language == "cpp"
&& node
.attributes
.get("source.linkage")
.and_then(Value::as_str)
!= Some("internal")
{
reasons.push(Reason::flag("external-linkage"));
}
}
reasons
}
fn owning_class(&self, node: &Node) -> Option<&'a str> {
if let Some(parent) = node
.parent
.as_deref()
.and_then(|id| self.nodes.get(id))
.filter(|parent| parent.kind == NodeKind::Class)
{
return Some(parent.id.as_str());
}
let qualified = node.attributes.get("source.qualified-name")?.as_str()?;
let (class, _) = qualified.rsplit_once("::")?;
self.classes.get(class).copied()
}
fn defines_outside_member(&self, node: &Node) -> bool {
let Some(qualified) = node
.attributes
.get("source.qualified-name")
.and_then(Value::as_str)
else {
return false;
};
node.attributes.contains_key("source.qualifier")
&& qualified.rsplit_once("::").is_some_and(|(class, _)| {
!self.classes.contains_key(class) && !self.namespaces.contains(class)
})
}
/// Constructors, destructors, and operators run without a named call.
fn implicitly_invoked(&self, node: &Node) -> bool {
if node.name.starts_with('~')
|| node.name.starts_with("operator")
|| IMPLICIT_PROTOCOL.contains(&node.name.as_str())
{
return true;
}
let qualifier = node
.attributes
.get("source.qualifier")
.and_then(Value::as_str);
let owner = node
.parent
.as_deref()
.and_then(|id| self.nodes.get(id))
.filter(|parent| parent.kind == NodeKind::Class)
.map(|parent| parent.name.as_str());
qualifier
.and_then(|q| q.rsplit("::").next())
.or(owner)
// `template <> class Expected<void>` names its constructors `Expected`.
.is_some_and(|class| class.split('<').next() == Some(node.name.as_str()))
}
}
fn unresolved_reason(edge: &Edge) -> &str {
edge.attributes
.get("source.unresolved-reason")
.and_then(Value::as_str)
.unwrap_or("unclassified")
}
fn is_test_code(node: &Node) -> bool {
let in_test_scope = node
.attributes
.get("source.scope")
.and_then(Value::as_str)
.is_some_and(|scope| scope.split("::").any(|part| part.contains("test")));
let in_test_path = node.path.as_deref().is_some_and(|path| {
let file = path.rsplit('/').next().unwrap_or(path);
path.split('/')
.any(|part| matches!(part, "tests" | "test" | "benches"))
|| file.starts_with("test_")
|| file.contains("_test.")
});
in_test_scope || in_test_path
}

View file

@ -1,3 +1,4 @@
pub mod callers;
pub mod diff;
pub mod fold;
pub mod mcp;

View file

@ -1,3 +1,4 @@
use crate::callers::{CallerAnalysis, CallerOptions, CallerStatus};
use crate::model::{Building, Edge, EdgeKind, Node, NodeKind, stable_id};
use serde::Deserialize;
use serde_json::{Value, json};
@ -114,6 +115,14 @@ impl Catalog {
let arguments: EvidenceArguments = decode_arguments(arguments)?;
self.evidence(&arguments.entity_id)
}
"brick_callers" => {
let arguments: CallersArguments = decode_arguments(arguments)?;
self.callers(arguments)
}
"brick_uncalled" => {
let arguments: UncalledArguments = decode_arguments(arguments)?;
self.uncalled(arguments)
}
other => Err(McpError::UnknownTool(other.into())),
}
}
@ -291,6 +300,119 @@ impl Catalog {
Err(McpError::UnknownEntity(entity_id.into()))
}
fn callers(&self, arguments: CallersArguments) -> Result<Value, McpError> {
let node = self
.building
.nodes
.iter()
.find(|node| node.id == arguments.node_id)
.ok_or_else(|| McpError::UnknownEntity(arguments.node_id.clone()))?;
if node.kind != NodeKind::Function {
return Err(McpError::UnknownEntity(format!(
"{} is not a function node",
arguments.node_id
)));
}
let options = CallerOptions {
assume_closed_world: arguments.assume_closed_world,
};
let analysis = CallerAnalysis::new(&self.building, options);
let report = analysis.report(node);
let mut result = self.base_result("callers");
insert_field(
&mut result,
"assume_closed_world",
json!(options.assume_closed_world),
);
insert_field(
&mut result,
"report",
serde_json::to_value(&report).expect("serializable report"),
);
insert_field(
&mut result,
"coverage",
self.coverage(node.language.as_deref()),
);
insert_field(&mut result, "caveat", json!(CALLERS_CAVEAT));
Ok(result)
}
fn uncalled(&self, arguments: UncalledArguments) -> Result<Value, McpError> {
let options = CallerOptions {
assume_closed_world: arguments.assume_closed_world,
};
let analysis = CallerAnalysis::new(&self.building, options);
let mut counts: BTreeMap<&str, BTreeMap<&str, usize>> = BTreeMap::new();
let mut unreferenced = Vec::new();
let mut unknown = Vec::new();
for node in self.building.nodes.iter().filter(|node| {
node.kind == NodeKind::Function
&& arguments
.language
.as_deref()
.is_none_or(|language| node.language.as_deref() == Some(language))
}) {
let report = analysis.report(node);
let status = match report.status {
CallerStatus::Resolved => "resolved",
CallerStatus::Unknown => "unknown",
CallerStatus::Unreferenced => "unreferenced",
};
*counts
.entry(node.language.as_deref().unwrap_or("unknown"))
.or_default()
.entry(status)
.or_default() += 1;
match report.status {
CallerStatus::Unreferenced => unreferenced.push(report),
CallerStatus::Unknown if arguments.include_unknown => unknown.push(report),
_ => {}
}
}
let order = |left: &crate::callers::CallerReport<'_>,
right: &crate::callers::CallerReport<'_>| {
(&left.node.path, left.node.line, &left.node.id).cmp(&(
&right.node.path,
right.node.line,
&right.node.id,
))
};
unreferenced.sort_by(order);
unknown.sort_by(order);
let mut result = self.base_result("uncalled");
insert_field(
&mut result,
"assume_closed_world",
json!(options.assume_closed_world),
);
insert_field(&mut result, "status_counts", json!(counts));
insert_field(&mut result, "unreferenced_total", json!(unreferenced.len()));
unreferenced.truncate(arguments.limit);
insert_field(&mut result, "unreferenced", json!(unreferenced));
if arguments.include_unknown {
insert_field(&mut result, "unknown_total", json!(unknown.len()));
unknown.truncate(arguments.limit);
insert_field(&mut result, "unknown", json!(unknown));
}
insert_field(
&mut result,
"coverage",
self.coverage(arguments.language.as_deref()),
);
insert_field(&mut result, "caveat", json!(CALLERS_CAVEAT));
Ok(result)
}
/// Per-language call-resolution coverage recorded in the model's metrics.
fn coverage(&self, language: Option<&str>) -> Value {
let resolution = &self.building.metrics.resolution;
match language {
Some(language) => json!({language: resolution.get(language)}),
None => json!(resolution),
}
}
fn base_result(&self, query: &str) -> Value {
json!({
"query_schema_version": MCP_QUERY_SCHEMA_VERSION,
@ -331,6 +453,33 @@ struct EvidenceArguments {
#[serde(deny_unknown_fields)]
struct EmptyArguments {}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct CallersArguments {
node_id: String,
#[serde(default)]
assume_closed_world: bool,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct UncalledArguments {
#[serde(default)]
language: Option<String>,
#[serde(default)]
assume_closed_world: bool,
#[serde(default)]
include_unknown: bool,
#[serde(default = "default_uncalled_limit")]
limit: usize,
}
fn default_uncalled_limit() -> usize {
100
}
const CALLERS_CAVEAT: &str = "Statuses derive from Brick's heuristic call graph plus any imported compiler evidence. 'unreferenced' means no resolved caller and no recorded evidence of an unresolved use; it is a review candidate, not proof of dead code. 'unknown' lists why callers may exist. Reflection, dynamic loading, generated code, and code outside the scan scope are not modeled.";
fn insert_field(result: &mut Value, key: &str, value: Value) {
result
.as_object_mut()
@ -747,6 +896,33 @@ fn tool_definitions() -> Vec<Value> {
"description": "Return dependency edges that are bridges in the weak undirected dependency graph.",
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": false}
}),
json!({
"name": "brick_callers",
"description": "Classify a function's callers as resolved, unknown (with reasons callers may exist), or unreferenced, with evidence basis and coverage.",
"inputSchema": {
"type": "object",
"properties": {
"node_id": {"type": "string"},
"assume_closed_world": {"type": "boolean", "default": false}
},
"required": ["node_id"],
"additionalProperties": false
}
}),
json!({
"name": "brick_uncalled",
"description": "List functions with no resolved caller and no evidence of unresolved use (dead-code review candidates), with per-status counts and resolution coverage.",
"inputSchema": {
"type": "object",
"properties": {
"language": {"type": "string"},
"assume_closed_world": {"type": "boolean", "default": false},
"include_unknown": {"type": "boolean", "default": false},
"limit": {"type": "integer", "minimum": 0, "default": 100}
},
"additionalProperties": false
}
}),
json!({
"name": "brick_evidence",
"description": "Inspect a complete node or edge record and associated relationship evidence by stable ID.",

View file

@ -20,7 +20,14 @@ import runner
ROOT = pathlib.Path(__file__).resolve().parents[2]
PLAN = ROOT / "tests" / "benchmarks" / "plans" / "mcp_queries.json"
PROTOCOL_VERSION = "2025-06-18"
TOOL_NAMES = ["brick_impact", "brick_cycles", "brick_bridges", "brick_evidence"]
TOOL_NAMES = [
"brick_impact",
"brick_cycles",
"brick_bridges",
"brick_callers",
"brick_uncalled",
"brick_evidence",
]
def load_plan() -> dict:

219
tests/callers.rs Normal file
View file

@ -0,0 +1,219 @@
use brick::Building;
use brick::mcp::Catalog;
use brick::scan;
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::fs;
fn scan_files(files: &[(&str, &str)]) -> Building {
let root = tempfile::tempdir().unwrap();
for (path, content) in files {
let file = root.path().join(path);
fs::create_dir_all(file.parent().unwrap()).unwrap();
fs::write(file, content).unwrap();
}
scan::scan(root.path()).unwrap()
}
/// function name -> (status, reason kinds) for every function in the model.
fn statuses(model: Building, closed_world: bool) -> BTreeMap<String, (String, Vec<String>)> {
let catalog = Catalog::new(model).unwrap();
let result = catalog
.call(
"brick_uncalled",
json!({"assume_closed_world": closed_world, "include_unknown": true, "limit": 1000}),
)
.unwrap();
let mut found = BTreeMap::new();
for (status, list) in [("unreferenced", "unreferenced"), ("unknown", "unknown")] {
for report in result[list].as_array().unwrap() {
let reasons = report["reasons"]
.as_array()
.unwrap()
.iter()
.map(|r| r["kind"].as_str().unwrap().to_owned())
.collect();
found.insert(
report["node"]["name"].as_str().unwrap().to_owned(),
(status.to_owned(), reasons),
);
}
}
found
}
fn cpp_model() -> Building {
scan_files(&[
(
"src/app.h",
"#include <string>\nstruct Base { virtual void hook(); };\nstruct Widget : Base {\n void hook() override;\n int size() const;\n Widget();\n};\nint shared();\n",
),
(
"src/app.cpp",
"#include \"app.h\"\n#include <mutex>\nWidget::Widget() {}\nvoid Base::hook() {}\nvoid Widget::hook() {}\nint Widget::size() const { return 0; }\nstatic std::mutex& lockObject() { static std::mutex m; return m; }\nstatic int unusedHelper() { return 1; }\nstatic int callback(int x) { return x; }\nint shared() { return 2; }\nWidget make();\nint main() {\n std::lock_guard<std::mutex> guard(lockObject());\n std::string text;\n text.size();\n make().size();\n int (*fp)(int) = callback;\n return fp(1) + shared();\n}\n",
),
])
}
#[test]
fn cpp_statuses_separate_dead_code_from_unresolvable_uses() {
let found = statuses(cpp_model(), false);
// Only resolved callers are omitted: shared() and lockObject() (via the vexing parse).
assert!(!found.contains_key("shared"));
assert!(!found.contains_key("lockObject"));
assert_eq!(found["unusedHelper"], ("unreferenced".into(), vec![]));
let reasons = |name: &str| found[name].1.clone();
assert_eq!(found["size"].0, "unknown");
// make().size() may reach Widget::size; std::string::size cannot.
assert!(reasons("size").contains(&"unresolved-call-sites".to_owned()));
assert!(reasons("callback").contains(&"referenced-by-name".to_owned()));
assert!(reasons("hook").contains(&"virtual".to_owned()));
assert!(reasons("Widget").contains(&"implicit-invocation".to_owned()));
assert!(reasons("main").contains(&"entry-point".to_owned()));
}
#[test]
fn closed_world_drops_linkage_but_keeps_evidence_of_indirect_use() {
let open = statuses(cpp_model(), false);
let closed = statuses(cpp_model(), true);
assert!(open["size"].1.contains(&"external-linkage".to_owned()));
assert!(!closed["size"].1.contains(&"external-linkage".to_owned()));
assert_eq!(closed["size"].0, "unknown");
assert_eq!(closed["unusedHelper"].0, "unreferenced");
}
#[test]
fn rust_attributes_attribute_strings_and_trait_impls_are_uses() {
let model = scan_files(&[(
"src/lib.rs",
"use std::fmt;\nstruct Point;\nimpl fmt::Display for Point {\n fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { Ok(()) }\n}\n#[derive(serde::Deserialize)]\nstruct Config {\n #[serde(deserialize_with = \"crate::parse_flag\")]\n flag: bool,\n}\nfn parse_flag() {}\nfn orphan() {}\n#[test]\nfn checks() {}\n#[cfg(test)]\nmod tests {\n fn helper() {}\n}\n",
)]);
let found = statuses(model, true);
assert_eq!(found["orphan"], ("unreferenced".into(), vec![]));
assert!(found["fmt"].1.contains(&"trait-impl".to_owned()));
assert!(
found["parse_flag"]
.1
.contains(&"referenced-by-name".to_owned())
);
assert!(
found["checks"]
.1
.contains(&"framework-attribute".to_owned())
);
assert!(found["helper"].1.contains(&"test-code".to_owned()));
}
#[test]
fn callers_query_reports_resolved_callers_basis_and_rejects_non_functions() {
let model = cpp_model();
let shared = model
.nodes
.iter()
.find(|n| n.name == "shared" && n.path.as_deref() == Some("src/app.cpp"))
.unwrap()
.id
.clone();
let file = model
.nodes
.iter()
.find(|n| {
n.path.as_deref() == Some("src/app.cpp") && n.kind == brick::model::NodeKind::File
})
.unwrap()
.id
.clone();
let catalog = Catalog::new(model).unwrap();
let result = catalog
.call("brick_callers", json!({"node_id": shared}))
.unwrap();
let report = &result["report"];
assert_eq!(report["status"], "resolved");
assert_eq!(report["basis"], "heuristic");
assert_eq!(report["callers"].as_array().unwrap().len(), 1);
assert!(
result["coverage"]["cpp"]["call_site_count"]
.as_u64()
.unwrap()
> 0
);
assert!(result["caveat"].as_str().unwrap().contains("not proof"));
assert!(
catalog
.call("brick_callers", json!({"node_id": file}))
.is_err()
);
assert!(
catalog
.call(
"brick_callers",
json!({"node_id": shared, "extra": Value::Null})
)
.is_err()
);
}
#[test]
fn macro_bodies_and_syntax_errors_make_functions_unknown_not_dead() {
let model = scan_files(&[
(
"src/plugin.cpp",
"static void* open_impl(const char* name) { return nullptr; }\n#define open_library open_impl\nstatic void* unused_impl() { return nullptr; }\nvoid* load() { return open_library(\"x\"); }\n",
),
(
"src/broken.cpp",
"namespace {\n#if defined(A)\n}\n#endif\nstatic int lost() { return 1; }\n",
),
]);
let found = statuses(model, true);
assert!(
found["open_impl"]
.1
.contains(&"referenced-by-name".to_owned())
);
assert_eq!(found["unused_impl"], ("unreferenced".into(), vec![]));
assert!(
found["lost"].1.contains(&"parse-incomplete".to_owned()),
"{found:?}"
);
}
#[test]
fn template_parameters_aliases_and_protocol_methods_are_not_dead() {
let model = scan_files(&[
(
"src/repair.h",
"#include <memory>\nnamespace app {\nstruct Repair { void enqueue(); void drain(); void unused(); };\nusing RepairPtr = std::shared_ptr<Repair>;\nstruct Awaiter { bool await_ready() const noexcept; };\ntemplate <class T> struct Box { Box(); };\ntemplate <> struct Box<void> { Box() {} };\n}\n",
),
(
"src/repair.cpp",
"#include \"repair.h\"\n#include <lib/fsm.h>\nnamespace app {\nvoid Repair::enqueue() {}\nvoid Repair::drain() {}\nvoid Repair::unused() {}\nbool Awaiter::await_ready() const noexcept { return true; }\n}\ntemplate <> void lib::Fsm<int>::start() {}\n",
),
(
"src/use.cpp",
"#include \"repair.h\"\nnamespace app {\ntemplate <typename Ptr>\nvoid poke(Ptr service) { service->enqueue(); }\nvoid run(RepairPtr repair) { repair->drain(); }\n}\n",
),
]);
let found = statuses(model, true);
// Alias through shared_ptr resolves; the templated receiver keeps enqueue possible.
assert!(!found.contains_key("drain"), "{found:?}");
assert_eq!(found["enqueue"].0, "unknown");
assert!(
found["enqueue"]
.1
.contains(&"unresolved-call-sites".to_owned())
);
assert!(
found["await_ready"]
.1
.contains(&"implicit-invocation".to_owned())
);
assert!(
found["start"]
.1
.contains(&"outside-class-member".to_owned())
);
assert!(found["Box"].1.contains(&"implicit-invocation".to_owned()));
// Clean negative: nothing can reach unused().
assert_eq!(found["unused"], ("unreferenced".into(), vec![]));
}

View file

@ -102,7 +102,7 @@ fn mcp_serves_versioned_impact_cycles_bridges_and_complete_evidence() {
let messages = responses(&stdout);
assert_eq!(messages.len(), 6);
assert_eq!(messages[0]["result"]["protocolVersion"], "2025-06-18");
assert_eq!(messages[1]["result"]["tools"].as_array().unwrap().len(), 4);
assert_eq!(messages[1]["result"]["tools"].as_array().unwrap().len(), 6);
let impact = &messages[2]["result"]["structuredContent"];
assert_eq!(impact["query_schema_version"], 1);
@ -173,7 +173,7 @@ fn mcp_enforces_initialization_lifecycle_and_required_params() {
assert_eq!(messages[5]["error"]["code"], -32002);
assert_eq!(messages[6]["error"]["code"], -32602);
assert_eq!(messages[7]["result"]["protocolVersion"], "2025-06-18");
assert_eq!(messages[8]["result"]["tools"].as_array().unwrap().len(), 4);
assert_eq!(messages[8]["result"]["tools"].as_array().unwrap().len(), 6);
}
#[test]