feat: add brick_callers and brick_uncalled caller-status queries #4
7 changed files with 806 additions and 3 deletions
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>
commit
d292b9f3f2
|
|
@ -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
371
src/callers.rs
Normal 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
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod callers;
|
||||
pub mod diff;
|
||||
pub mod fold;
|
||||
pub mod mcp;
|
||||
|
|
|
|||
176
src/mcp.rs
176
src/mcp.rs
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
219
tests/callers.rs
Normal 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![]));
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue