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>
273 lines
10 KiB
Rust
273 lines
10 KiB
Rust
use brick::scan;
|
|
use serde_json::{Value, json};
|
|
use std::fs;
|
|
use std::io::Write;
|
|
use std::path::Path;
|
|
use std::process::{Command, Stdio};
|
|
|
|
fn run_mcp(model: &Path, requests: &[Value]) -> (std::process::ExitStatus, String, String) {
|
|
let mut child = Command::new(env!("CARGO_BIN_EXE_brick"))
|
|
.args(["mcp", model.to_str().unwrap()])
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.unwrap();
|
|
{
|
|
let stdin = child.stdin.as_mut().unwrap();
|
|
for request in requests {
|
|
writeln!(stdin, "{}", serde_json::to_string(request).unwrap()).unwrap();
|
|
}
|
|
}
|
|
let output = child.wait_with_output().unwrap();
|
|
(
|
|
output.status,
|
|
String::from_utf8(output.stdout).unwrap(),
|
|
String::from_utf8(output.stderr).unwrap(),
|
|
)
|
|
}
|
|
|
|
fn responses(stdout: &str) -> Vec<Value> {
|
|
stdout
|
|
.lines()
|
|
.filter(|line| !line.is_empty())
|
|
.map(|line| serde_json::from_str(line).unwrap())
|
|
.collect()
|
|
}
|
|
|
|
fn call(id: usize, name: &str, arguments: Value) -> Value {
|
|
json!({
|
|
"jsonrpc": "2.0",
|
|
"id": id,
|
|
"method": "tools/call",
|
|
"params": {"name": name, "arguments": arguments}
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_serves_versioned_impact_cycles_bridges_and_complete_evidence() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
let project = directory.path().join("project");
|
|
fs::create_dir_all(&project).unwrap();
|
|
fs::write(
|
|
project.join("lib.rs"),
|
|
"fn a() { b(); }\nfn b() { a(); c(); }\nfn c() {}\n",
|
|
)
|
|
.unwrap();
|
|
let model = scan::scan(&project).unwrap();
|
|
let c_id = model
|
|
.nodes
|
|
.iter()
|
|
.find(|node| node.name == "c")
|
|
.unwrap()
|
|
.id
|
|
.clone();
|
|
let bridge_id = model
|
|
.edges
|
|
.iter()
|
|
.find(|edge| {
|
|
let source = model
|
|
.nodes
|
|
.iter()
|
|
.find(|node| node.id == edge.source)
|
|
.unwrap();
|
|
let target = model
|
|
.nodes
|
|
.iter()
|
|
.find(|node| node.id == edge.target)
|
|
.unwrap();
|
|
source.name == "b" && target.name == "c"
|
|
})
|
|
.unwrap()
|
|
.id
|
|
.clone();
|
|
let graph = directory.path().join("graph.json");
|
|
fs::write(&graph, serde_json::to_vec_pretty(&model).unwrap()).unwrap();
|
|
|
|
let requests = vec![
|
|
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}),
|
|
json!({"jsonrpc":"2.0","method":"notifications/initialized"}),
|
|
json!({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}),
|
|
call(
|
|
3,
|
|
"brick_impact",
|
|
json!({"node_id": c_id, "direction":"dependents", "max_depth":2}),
|
|
),
|
|
call(4, "brick_cycles", json!({})),
|
|
call(5, "brick_bridges", json!({})),
|
|
call(6, "brick_evidence", json!({"entity_id": bridge_id})),
|
|
];
|
|
let (status, stdout, stderr) = run_mcp(&graph, &requests);
|
|
assert!(status.success(), "{stderr}");
|
|
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(), 6);
|
|
|
|
let impact = &messages[2]["result"]["structuredContent"];
|
|
assert_eq!(impact["query_schema_version"], 1);
|
|
assert_eq!(impact["building_schema_version"], 4);
|
|
assert_eq!(impact["impacted_nodes"].as_array().unwrap().len(), 2);
|
|
|
|
let cycles = &messages[3]["result"]["structuredContent"];
|
|
assert_eq!(cycles["cycles"].as_array().unwrap().len(), 1);
|
|
assert_eq!(cycles["cycles"][0]["nodes"].as_array().unwrap().len(), 2);
|
|
|
|
let bridges = &messages[4]["result"]["structuredContent"];
|
|
assert!(
|
|
bridges["bridges"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|entry| entry["edge"]["id"] == bridge_id)
|
|
);
|
|
|
|
let evidence = &messages[5]["result"]["structuredContent"];
|
|
assert_eq!(evidence["entity_kind"], "edge");
|
|
assert_eq!(evidence["edge"]["id"], bridge_id);
|
|
assert_eq!(evidence["edge"]["evidence"][0]["lane"], "heuristic");
|
|
assert_eq!(
|
|
evidence["edge"]["evidence"][0]["backend"],
|
|
"tree-sitter-rust:0.24.2:brick-source-v1:heuristic-resolver-v2"
|
|
);
|
|
assert!(evidence["edge"]["evidence"][0].get("confidence").is_some());
|
|
assert!(evidence["edge"]["evidence"][0].get("provenance").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_enforces_initialization_lifecycle_and_required_params() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
fs::write(directory.path().join("lib.rs"), "fn lone() {}\n").unwrap();
|
|
let graph = directory.path().join("graph.json");
|
|
fs::write(
|
|
&graph,
|
|
serde_json::to_vec_pretty(&scan::scan(directory.path()).unwrap()).unwrap(),
|
|
)
|
|
.unwrap();
|
|
let requests = vec![
|
|
json!([]),
|
|
json!({"jsonrpc":"2.0"}),
|
|
json!({"jsonrpc":"2.0","id":{},"method":"ping"}),
|
|
json!({"jsonrpc":"2.0","id":1,"method":"ping","params":[]}),
|
|
json!({"jsonrpc":"2.0","id":"ping","method":"ping","params":{}}),
|
|
json!({"jsonrpc":"2.0","method":"notifications/initialized","params":[]}),
|
|
json!({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}),
|
|
json!({"jsonrpc":"2.0","id":3,"method":"initialize","params":{}}),
|
|
json!({"jsonrpc":"2.0","id":4,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}),
|
|
json!({"jsonrpc":"2.0","method":"notifications/initialized"}),
|
|
json!({"jsonrpc":"2.0","id":5,"method":"tools/list","params":{}}),
|
|
];
|
|
let (status, stdout, stderr) = run_mcp(&graph, &requests);
|
|
assert!(status.success(), "{stderr}");
|
|
let messages = responses(&stdout);
|
|
assert_eq!(messages.len(), 9);
|
|
for message in &messages[0..4] {
|
|
assert_eq!(message["error"]["code"], -32600);
|
|
}
|
|
assert_eq!(messages[0]["id"], Value::Null);
|
|
assert_eq!(messages[1]["id"], Value::Null);
|
|
assert_eq!(messages[2]["id"], Value::Null);
|
|
assert_eq!(messages[3]["id"], 1);
|
|
assert_eq!(messages[4]["id"], "ping");
|
|
assert_eq!(messages[4]["result"], json!({}));
|
|
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(), 6);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_returns_clean_negatives_and_rejects_incompatible_models() {
|
|
let directory = tempfile::tempdir().unwrap();
|
|
fs::write(directory.path().join("lib.rs"), "fn lone() {}\n").unwrap();
|
|
let model = scan::scan(directory.path()).unwrap();
|
|
let lone_id = model
|
|
.nodes
|
|
.iter()
|
|
.find(|node| node.name == "lone")
|
|
.unwrap()
|
|
.id
|
|
.clone();
|
|
let graph = directory.path().join("graph.json");
|
|
fs::write(&graph, serde_json::to_vec_pretty(&model).unwrap()).unwrap();
|
|
let requests = vec![
|
|
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}),
|
|
json!({"jsonrpc":"2.0","method":"notifications/initialized"}),
|
|
call(2, "brick_impact", json!({"node_id": lone_id})),
|
|
call(3, "brick_cycles", json!({})),
|
|
call(4, "brick_bridges", json!({})),
|
|
call(5, "brick_evidence", json!({"entity_id":"edge:missing"})),
|
|
call(6, "brick_cycles", json!({"unknown": true})),
|
|
call(7, "brick_unknown", json!({})),
|
|
];
|
|
let (status, stdout, stderr) = run_mcp(&graph, &requests);
|
|
assert!(status.success(), "{stderr}");
|
|
let messages = responses(&stdout);
|
|
assert!(
|
|
messages[1]["result"]["structuredContent"]["impacted_nodes"]
|
|
.as_array()
|
|
.unwrap()
|
|
.is_empty()
|
|
);
|
|
assert!(
|
|
messages[2]["result"]["structuredContent"]["cycles"]
|
|
.as_array()
|
|
.unwrap()
|
|
.is_empty()
|
|
);
|
|
assert!(
|
|
messages[3]["result"]["structuredContent"]["bridges"]
|
|
.as_array()
|
|
.unwrap()
|
|
.is_empty()
|
|
);
|
|
assert_eq!(messages[4]["result"]["isError"], true);
|
|
assert!(
|
|
messages[4]["result"]["content"][0]["text"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("unknown Brick entity")
|
|
);
|
|
assert_eq!(messages[5]["error"]["code"], -32602);
|
|
assert_eq!(messages[6]["error"]["code"], -32602);
|
|
|
|
let no_requests: Vec<Value> = Vec::new();
|
|
let mut incompatible: Value = serde_json::to_value(&model).unwrap();
|
|
incompatible
|
|
.as_object_mut()
|
|
.unwrap()
|
|
.insert("schema_version".into(), json!(3));
|
|
let old_graph = directory.path().join("old.json");
|
|
fs::write(
|
|
&old_graph,
|
|
serde_json::to_vec_pretty(&incompatible).unwrap(),
|
|
)
|
|
.unwrap();
|
|
let (status, _, stderr) = run_mcp(&old_graph, &no_requests);
|
|
assert!(!status.success());
|
|
assert!(stderr.contains("incompatible"));
|
|
|
|
let mut no_evidence = serde_json::to_value(model).unwrap();
|
|
no_evidence["edges"][0]["evidence"] = json!([]);
|
|
let no_evidence_graph = directory.path().join("no-evidence.json");
|
|
fs::write(
|
|
&no_evidence_graph,
|
|
serde_json::to_vec_pretty(&no_evidence).unwrap(),
|
|
)
|
|
.unwrap();
|
|
let (status, _, stderr) = run_mcp(&no_evidence_graph, &no_requests);
|
|
assert!(!status.success());
|
|
assert!(stderr.contains("has no evidence"));
|
|
|
|
let mut invalid_scope = serde_json::to_value(scan::scan(directory.path()).unwrap()).unwrap();
|
|
invalid_scope["scope"]["path"] = json!("../src");
|
|
let invalid_scope_graph = directory.path().join("invalid-scope.json");
|
|
fs::write(
|
|
&invalid_scope_graph,
|
|
serde_json::to_vec_pretty(&invalid_scope).unwrap(),
|
|
)
|
|
.unwrap();
|
|
let (status, _, stderr) = run_mcp(&invalid_scope_graph, &no_requests);
|
|
assert!(!status.success());
|
|
assert!(stderr.contains("invalid scan scope"));
|
|
}
|