feat: add brick_callers and brick_uncalled caller-status queries #4

Merged
trevon merged 2 commits from feat/caller-status into feat/cpp-rust-resolution 2026-09-22 21:15:02 +00:00
7 changed files with 806 additions and 3 deletions
Showing only changes of commit d292b9f3f2 - Show all commits

feat: add brick_callers and brick_uncalled caller-status queries

Agents asking "is this function used?" get resolved, unknown, or
unreferenced per function instead of a bare zero in-degree. Unknown lists
why callers may exist: same-name unresolved call sites (excluding foreign
receivers and macros), name references (callbacks, address-taken, macro
bodies), virtual/trait-impl/external-base/outside-class members, framework
attributes, implicit protocol invocations, entry points, test code,
incomplete parses, and, unless assume_closed_world is set, exported or
external-linkage functions. Unreferenced is a review candidate with an
explicit heuristic/compiler evidence basis, never a dead-code proof.

The queries are read-only over validated schema-4 models and return
complete canonical nodes and edges plus metrics.resolution coverage.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
trvon 2026-09-22 15:12:13 -06:00
Signed by: trevon
GPG key ID: 083EB1C25C575E81

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

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]