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