From d292b9f3f2734a60a7fea65a89409b21ebe595f5 Mon Sep 17 00:00:00 2001 From: trvon Date: Tue, 22 Sep 2026 15:12:13 -0600 Subject: [PATCH 1/2] 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) --- docs/architecture/mcp.md | 29 +++ src/callers.rs | 371 +++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/mcp.rs | 176 ++++++++++++++++ tests/benchmarks/mcp_runner.py | 9 +- tests/callers.rs | 219 +++++++++++++++++++ tests/mcp_cli.rs | 4 +- 7 files changed, 806 insertions(+), 3 deletions(-) create mode 100644 src/callers.rs create mode 100644 tests/callers.rs diff --git a/docs/architecture/mcp.md b/docs/architecture/mcp.md index af94896..c0a3171 100644 --- a/docs/architecture/mcp.md +++ b/docs/architecture/mcp.md @@ -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 diff --git a/src/callers.rs b/src/callers.rs new file mode 100644 index 0000000..1f8c53e --- /dev/null +++ b/src/callers.rs @@ -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, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub by_reason: BTreeMap, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub examples: Vec, +} + +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, + 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 { + 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` 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 +} diff --git a/src/lib.rs b/src/lib.rs index 87f9535..ee8188d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod callers; pub mod diff; pub mod fold; pub mod mcp; diff --git a/src/mcp.rs b/src/mcp.rs index d5e283f..793b965 100644 --- a/src/mcp.rs +++ b/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 { + 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 { + 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, + #[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 { "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.", diff --git a/tests/benchmarks/mcp_runner.py b/tests/benchmarks/mcp_runner.py index e359bc1..be7f2cb 100644 --- a/tests/benchmarks/mcp_runner.py +++ b/tests/benchmarks/mcp_runner.py @@ -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: diff --git a/tests/callers.rs b/tests/callers.rs new file mode 100644 index 0000000..6f8ca93 --- /dev/null +++ b/tests/callers.rs @@ -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)> { + 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 \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 \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 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 \nnamespace app {\nstruct Repair { void enqueue(); void drain(); void unused(); };\nusing RepairPtr = std::shared_ptr;\nstruct Awaiter { bool await_ready() const noexcept; };\ntemplate struct Box { Box(); };\ntemplate <> struct Box { Box() {} };\n}\n", + ), + ( + "src/repair.cpp", + "#include \"repair.h\"\n#include \nnamespace app {\nvoid Repair::enqueue() {}\nvoid Repair::drain() {}\nvoid Repair::unused() {}\nbool Awaiter::await_ready() const noexcept { return true; }\n}\ntemplate <> void lib::Fsm::start() {}\n", + ), + ( + "src/use.cpp", + "#include \"repair.h\"\nnamespace app {\ntemplate \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![])); +} diff --git a/tests/mcp_cli.rs b/tests/mcp_cli.rs index ff73f90..328b555 100644 --- a/tests/mcp_cli.rs +++ b/tests/mcp_cli.rs @@ -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] -- 2.52.0 From 6d4335d3ae2cb43f35bfdac6191e142982fb7a2a Mon Sep 17 00:00:00 2001 From: trvon Date: Tue, 22 Sep 2026 15:12:13 -0600 Subject: [PATCH 2/2] docs: record C/C++ and Rust resolution research and validation Adds the audit, literature rationale, experiment contract, labeled-corpus results, Fold/Paperbridge/Brick/YAMS coverage and dead-code spot checks, model changes, identity changes, and remaining limits. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 4 +- docs/research/cpp-rust-resolution.md | 191 +++++++++++++++++++++++++++ docs/research/literature.md | 3 +- 3 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 docs/research/cpp-rust-resolution.md diff --git a/README.md b/README.md index c4cd919..39dffd0 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,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 labeled cases establish the covered behaviors, not language-wide accuracy. See -`docs/research/language-correctness.md` for the acceptance evidence and limits. +`docs/research/language-correctness.md` for the acceptance evidence and limits, +and `docs/research/cpp-rust-resolution.md` for C/C++ and Rust resolution and the +agent-facing caller-status contract. P4 extraction is a conservative P4_16 lexical preview, not a p4c frontend. The optional Tree-sitter pilot does not preprocess macros and rejects all pilot diff --git a/docs/research/cpp-rust-resolution.md b/docs/research/cpp-rust-resolution.md new file mode 100644 index 0000000..39da758 --- /dev/null +++ b/docs/research/cpp-rust-resolution.md @@ -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 g(f());` hides `f()` (most vexing parse) | tree-sitter-cpp | +| `static_cast(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 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`). + 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`, 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`). diff --git a/docs/research/literature.md b/docs/research/literature.md index 96283c1..6279ac0 100644 --- a/docs/research/literature.md +++ b/docs/research/literature.md @@ -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. -- 2.52.0