feat: resolve C/C++ and Rust calls across files and typed receivers #3

Merged
trevon merged 2 commits from feat/cpp-rust-resolution into main 2026-09-22 21:14:53 +00:00
28 changed files with 3386 additions and 106 deletions

View file

@ -161,9 +161,20 @@ Relative JS/TS and Dart imports resolve against the importing directory; missing
paths never fall back to unrelated basenames. Explicit JS/TS named/default import paths never fall back to unrelated basenames. Explicit JS/TS named/default import
bindings, module-scoped Rust grouped/aliased imports, and module-qualified Rust bindings, module-scoped Rust grouped/aliased imports, and module-qualified Rust
public calls can resolve across files. public calls can resolve across files.
Member calls and ambiguous targets stay external. Bare package specifiers, C++ calls resolve through the include closure: a header prototype pairs with
tsconfig aliases, Rust wildcard/block-local binding expansion, and C++ cross-translation-unit its definition in another translation unit, lookup walks class (including
resolution need further adapters. File fan-out remains an observation rather in-repository bases) and namespace scopes outward, and `static`/anonymous
definitions stay file-local. C++ and Rust member calls resolve only through an
explicitly declared receiver type (parameter, local, field, `this`/`self`,
smart pointer, or `Self`-returning constructor) and carry
`source.resolution`. Calls inside Rust expression macros (`assert!`, `format!`,
`vec!`, ...) are recovered by re-parsing and carry `source.extraction`.
Every unresolved call edge records `source.unresolved-reason`, and
`metrics.resolution` reports per-language call-site coverage. Ambiguous
targets stay external. Bare package specifiers, tsconfig aliases, Rust
wildcard/block-local binding expansion, Cargo workspace module paths,
`compile_commands.json` include paths, and compiler-grade dispatch need further
adapters. File fan-out remains an observation rather
than proof of semantic coupling. Scope architecture gates to production paths than proof of semantic coupling. Scope architecture gates to production paths
and verify candidates against source and tests. and verify candidates against source and tests.

View file

@ -34,7 +34,12 @@
- Expand and qualify Tree-sitter/compiler frontends per language while keeping - Expand and qualify Tree-sitter/compiler frontends per language while keeping
them separate evidence backends; do not promote the P4 pilot until macro and them separate evidence backends; do not promote the P4 pilot until macro and
multi-project labeled-corpus gates pass. multi-project labeled-corpus gates pass.
- Overload/alias/dynamic-dispatch ambiguity represented as candidate edges. - Overload/alias/dynamic-dispatch ambiguity represented as candidate edges
(schema 5); today ambiguity is an External target with reason `ambiguous`.
- Fold `ProjectCallGraph` and `fold-rust` MIR call export, imported as a
separate Imported call lane with per-file Heuristic/Imported agreement.
- `compile_commands.json` include paths and Cargo workspace/`mod`-tree module
resolution for the heuristic C++/Rust resolvers.
- Namespace/package clustering and richer versioned policy rule kinds beyond - Namespace/package clustering and richer versioned policy rule kinds beyond
the seed forbidden-dependency boundary. the seed forbidden-dependency boundary.

View file

@ -29,3 +29,11 @@ The visualization metaphor and the health indicator are consumers of evidence,
not replacements for it. No paper above validates Brick's v0.1 weights; those not replacements for it. No paper above validates Brick's v0.1 weights; those
weights remain explicitly experimental. weights remain explicitly experimental.
## Call resolution (2026-09-22)
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).

View file

@ -11,6 +11,8 @@ python3 -m unittest discover -s tests/benchmarks -p 'test_*.py'
python3 tests/benchmarks/runner.py self-test python3 tests/benchmarks/runner.py self-test
python3 tests/benchmarks/runner.py run source_languages python3 tests/benchmarks/runner.py run source_languages
python3 tests/benchmarks/runner.py run rust_imports python3 tests/benchmarks/runner.py run rust_imports
python3 tests/benchmarks/runner.py run cpp_xtu
python3 tests/benchmarks/runner.py run rust_methods
python3 tests/benchmarks/diff_runner.py self-test python3 tests/benchmarks/diff_runner.py self-test
python3 tests/benchmarks/policy_runner.py self-test python3 tests/benchmarks/policy_runner.py self-test
python3 tests/benchmarks/mcp_runner.py self-test python3 tests/benchmarks/mcp_runner.py self-test

View file

@ -1,8 +1,9 @@
use crate::model::{ use crate::model::{
Building, Diagnostic, Edge, EdgeKind, Node, NodeKind, P4Metrics, StructuralHealth, Building, Diagnostic, Edge, EdgeKind, EvidenceLane, Node, NodeKind, P4Metrics,
ResolutionMetrics, StructuralHealth,
}; };
use serde_json::Value; use serde_json::Value;
use std::collections::{HashMap, HashSet, VecDeque}; use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
const P4_DIAGNOSTIC_PREFIX: &str = "P4 structural observation (not verifier proof):"; const P4_DIAGNOSTIC_PREFIX: &str = "P4 structural observation (not verifier proof):";
@ -87,6 +88,7 @@ pub fn calculate(building: &mut Building) {
building.metrics.max_fan_in = max_fan_in; building.metrics.max_fan_in = max_fan_in;
building.metrics.max_fan_out = max_fan_out; building.metrics.max_fan_out = max_fan_out;
building.metrics.p4 = p4_metrics; building.metrics.p4 = p4_metrics;
building.metrics.resolution = calculate_resolution(building);
building.metrics.structural_health = StructuralHealth { building.metrics.structural_health = StructuralHealth {
score: round(score), score: round(score),
experimental: true, experimental: true,
@ -104,6 +106,68 @@ pub fn calculate(building: &mut Building) {
building.diagnostics.extend(p4_diagnostics); building.diagnostics.extend(p4_diagnostics);
} }
/// Counts syntactic call sites (heuristic evidence entries), not deduplicated edges.
fn calculate_resolution(building: &Building) -> BTreeMap<String, ResolutionMetrics> {
let nodes: HashMap<&str, &Node> = building
.nodes
.iter()
.map(|node| (node.id.as_str(), node))
.collect();
let mut result: BTreeMap<String, ResolutionMetrics> = BTreeMap::new();
let mut called = HashSet::new();
for edge in building
.edges
.iter()
.filter(|edge| edge.kind == EdgeKind::Calls)
{
let (Some(source), Some(target)) = (
nodes.get(edge.source.as_str()),
nodes.get(edge.target.as_str()),
) else {
continue;
};
let Some(language) = source.language.as_deref() else {
continue;
};
let sites = edge
.evidence
.iter()
.filter(|evidence| evidence.lane == EvidenceLane::Heuristic)
.count();
let metrics = result.entry(language.into()).or_default();
metrics.call_site_count += sites;
if target.kind == NodeKind::External {
let reason = edge
.attributes
.get("source.unresolved-reason")
.and_then(Value::as_str)
.unwrap_or("unclassified");
*metrics
.unresolved_call_sites_by_reason
.entry(reason.into())
.or_default() += sites;
} else {
metrics.resolved_call_site_count += sites;
called.insert(target.id.as_str());
}
}
for node in building
.nodes
.iter()
.filter(|node| node.kind == NodeKind::Function)
{
let Some(language) = node.language.as_deref() else {
continue;
};
let metrics = result.entry(language.into()).or_default();
metrics.function_count += 1;
if !called.contains(node.id.as_str()) {
metrics.functions_without_resolved_callers += 1;
}
}
result
}
fn calculate_p4(building: &Building) -> (P4Metrics, Vec<Diagnostic>) { fn calculate_p4(building: &Building) -> (P4Metrics, Vec<Diagnostic>) {
let nodes: HashMap<&str, &Node> = building let nodes: HashMap<&str, &Node> = building
.nodes .nodes

View file

@ -436,9 +436,24 @@ pub struct Metrics {
pub max_fan_in: usize, pub max_fan_in: usize,
pub max_fan_out: usize, pub max_fan_out: usize,
pub p4: P4Metrics, pub p4: P4Metrics,
/// Per-language call-resolution coverage. Absent in models from older builds.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub resolution: BTreeMap<String, ResolutionMetrics>,
pub structural_health: StructuralHealth, pub structural_health: StructuralHealth,
} }
/// How much of a language's call graph resolved to in-repository symbols.
/// A symbol without resolved callers is not evidence of dead code unless the
/// unresolved call sites that could reach it are accounted for.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolutionMetrics {
pub call_site_count: usize,
pub resolved_call_site_count: usize,
pub unresolved_call_sites_by_reason: BTreeMap<String, usize>,
pub function_count: usize,
pub functions_without_resolved_callers: usize,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct P4Metrics { pub struct P4Metrics {
pub parser_state_count: usize, pub parser_state_count: usize,

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,14 @@
{
"schema_version": 1,
"name": "cpp_xtu",
"corpus": "tests/fixtures/cpp_xtu/cpp",
"repeats": 3,
"direct_binary": true,
"measure_rss": true,
"expect": {
"min_symbols": 23,
"min_dependency_edges": 24,
"cycles": 0
},
"source_labels": "labels.json"
}

View file

@ -0,0 +1,14 @@
{
"schema_version": 1,
"name": "rust_methods",
"corpus": "tests/fixtures/rust_methods/rust",
"repeats": 3,
"direct_binary": true,
"measure_rss": true,
"expect": {
"min_symbols": 21,
"min_dependency_edges": 23,
"cycles": 0
},
"source_labels": "labels.json"
}

View file

@ -412,7 +412,8 @@ def source_language_quality(model: dict, labels: dict) -> dict:
if current["id"] in visited: if current["id"] in visited:
raise AssertionError("cyclic declaration ownership") raise AssertionError("cyclic declaration ownership")
visited.add(current["id"]) visited.add(current["id"])
names.append(current["name"]) qualifier = current.get("attributes", {}).get("source.qualifier")
names.append(f"{qualifier}::{current['name']}" if qualifier else current["name"])
current = nodes[current["parent"]] current = nodes[current["parent"]]
scope = node.get("attributes", {}).get("source.scope") scope = node.get("attributes", {}).get("source.scope")
return "::".join([node["path"], *([f"scope={scope}"] if scope else []), *reversed(names)]) return "::".join([node["path"], *([f"scope={scope}"] if scope else []), *reversed(names)])

328
tests/cpp_xtu.rs Normal file
View file

@ -0,0 +1,328 @@
use brick::model::{Building, EdgeKind, Node, NodeKind};
use brick::scan;
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()
}
fn function<'a>(model: &'a Building, path: &str, name: &str) -> &'a Node {
model
.nodes
.iter()
.find(|n| n.kind == NodeKind::Function && n.path.as_deref() == Some(path) && n.name == name)
.unwrap_or_else(|| panic!("missing {path}::{name}"))
}
/// (target node, unresolved reason) for every call edge leaving `source`.
fn calls<'a>(model: &'a Building, source: &Node) -> Vec<(&'a Node, Option<String>)> {
model
.edges
.iter()
.filter(|e| e.kind == EdgeKind::Calls && e.source == source.id)
.map(|e| {
let target = model.nodes.iter().find(|n| n.id == e.target).unwrap();
let reason = e
.attributes
.get("source.unresolved-reason")
.and_then(|v| v.as_str())
.map(str::to_owned);
(target, reason)
})
.collect()
}
fn single_call<'a>(model: &'a Building, source: &Node) -> (&'a Node, Option<String>) {
let mut found = calls(model, source);
assert_eq!(found.len(), 1, "calls from {}", source.name);
found.remove(0)
}
#[test]
fn header_prototype_resolves_to_the_paired_definition_across_files() {
let model = scan_files(&[
("src/util.h", "int helper(int x);\n"),
(
"src/util.cpp",
"#include \"util.h\"\nint helper(int x) { return x; }\n",
),
(
"src/main.cpp",
"#include \"util.h\"\nint main() { return helper(1); }\n",
),
// A second program's same-named definition never includes util.h.
("tools/dup.cpp", "int helper(int x) { return x + 1; }\n"),
]);
let (target, reason) = single_call(&model, function(&model, "src/main.cpp", "main"));
assert_eq!(target.path.as_deref(), Some("src/util.cpp"));
assert_eq!(reason, None);
let dup = function(&model, "tools/dup.cpp", "helper");
assert!(
!model
.edges
.iter()
.any(|e| e.kind == EdgeKind::Calls && e.target == dup.id)
);
}
#[test]
fn undeclared_and_internal_definitions_in_other_files_stay_unresolved() {
let model = scan_files(&[
(
"src/util.cpp",
"static int twice(int x) { return 2 * x; }\nint helper(int x) { return twice(x); }\n",
),
(
"src/other.cpp",
"static int twice(int x) { return x + x; }\nint local() { return twice(1); }\nint caller() { return helper(1); }\n",
),
]);
let (local, _) = single_call(&model, function(&model, "src/other.cpp", "local"));
assert_eq!(local.path.as_deref(), Some("src/other.cpp"));
assert_eq!(
local
.attributes
.get("source.linkage")
.and_then(|v| v.as_str()),
Some("internal")
);
let (helper, _) = single_call(&model, function(&model, "src/util.cpp", "helper"));
assert_eq!(helper.path.as_deref(), Some("src/util.cpp"));
// No prototype for helper is visible from other.cpp.
let (target, reason) = single_call(&model, function(&model, "src/other.cpp", "caller"));
assert_eq!(target.kind, NodeKind::External);
assert_eq!(reason.as_deref(), Some("not-visible"));
}
#[test]
fn out_of_line_methods_use_leaf_names_and_resolve_implicit_and_explicit_this() {
let model = scan_files(&[
(
"include/geo/shape.h",
"namespace geo {\nclass Shape {\npublic:\n double area() const;\n int& counter();\nprivate:\n double scale(double x) const;\n};\n}\n",
),
(
"src/shape.cpp",
"#include \"geo/shape.h\"\nnamespace geo {\ndouble Shape::area() const {\n return this->scale(2.0)\n + scale(1.0);\n}\ndouble Shape::scale(double x) const { return x; }\nint& Shape::counter() { static int n = 0; return n; }\n}\n",
),
]);
let area = function(&model, "src/shape.cpp", "area");
assert_eq!(
area.attributes
.get("source.qualifier")
.and_then(|v| v.as_str()),
Some("Shape")
);
assert_eq!(
area.attributes
.get("source.qualified-name")
.and_then(|v| v.as_str()),
Some("geo::Shape::area")
);
// Both call lines merge into one edge with two pieces of provenance.
let (scale, reason) = single_call(&model, area);
assert_eq!(scale.name, "scale");
assert_eq!(scale.path.as_deref(), Some("src/shape.cpp"));
assert_eq!(reason, None);
let edge = model
.edges
.iter()
.find(|e| e.source == area.id && e.target == scale.id)
.unwrap();
assert_eq!(edge.evidence.len(), 2);
// A reference return type no longer leaks into the declaration name.
function(&model, "src/shape.cpp", "counter");
}
#[test]
fn macro_ambiguous_external_and_untyped_receivers_are_classified_not_guessed() {
let model = scan_files(&[
("src/log.h", "#define LOG_TWICE(x) ((x) * 2)\n"),
(
"src/main.cpp",
"#include \"log.h\"\n#include <string>\nstruct Box { int size() { return 1; } };\nBox make();\nvoid emit(int) {}\nvoid emit(double) {}\nint main() {\n std::string text;\n emit(1);\n text.size();\n make().size();\n return static_cast<int>(LOG_TWICE(2));\n}\n",
),
]);
let main = function(&model, "src/main.cpp", "main");
let mut reasons: Vec<_> = calls(&model, main)
.into_iter()
.map(|(target, reason)| {
assert_eq!(target.kind, NodeKind::External);
(target.name.clone(), reason.unwrap())
})
.collect();
reasons.sort();
assert_eq!(
reasons,
[
("LOG_TWICE".to_owned(), "macro".to_owned()),
("emit".to_owned(), "ambiguous".to_owned()),
("make".to_owned(), "declared-only".to_owned()),
("size".to_owned(), "receiver-type-external".to_owned()),
("size".to_owned(), "receiver-type-unknown".to_owned()),
]
);
}
#[test]
fn declared_receivers_fields_and_bases_resolve_members_with_a_label() {
let model = scan_files(&[
(
"src/shape.h",
"#include <memory>\nstruct Base { int id() const; };\nstruct Shape : Base {\n int area() const;\n int total();\n std::unique_ptr<Shape> next_;\n Shape* raw_;\n};\n",
),
(
"src/shape.cpp",
"#include \"shape.h\"\nint Base::id() const { return 0; }\nint Shape::area() const { return id(); }\nint Shape::total() {\n return next_->area()\n + this->raw_->area();\n}\n",
),
(
"src/main.cpp",
"#include \"shape.h\"\nint use(const Shape& ref, Shape* ptr) {\n auto owned = std::make_unique<Shape>();\n return ref.area()\n + ptr->id()\n + owned->area();\n}\n",
),
]);
let targets = |source: &Node| {
let mut found: Vec<_> = calls(&model, source)
.into_iter()
.filter(|(target, _)| !target.name.starts_with("std::"))
.map(|(target, reason)| {
assert_eq!(reason, None, "{} -> {}", source.name, target.name);
(target.path.clone().unwrap(), target.name.clone())
})
.collect();
found.sort();
found.dedup();
found
};
let area = ("src/shape.cpp".to_owned(), "area".to_owned());
let id = ("src/shape.cpp".to_owned(), "id".to_owned());
assert_eq!(
targets(function(&model, "src/main.cpp", "use")),
[area.clone(), id.clone()]
);
assert_eq!(
targets(function(&model, "src/shape.cpp", "total")),
std::slice::from_ref(&area)
);
// Unqualified lookup inside a class scope reaches in-repository bases.
assert_eq!(targets(function(&model, "src/shape.cpp", "area")), [id]);
let use_fn = function(&model, "src/main.cpp", "use");
assert!(
model
.edges
.iter()
.filter(|e| e.kind == EdgeKind::Calls && e.source == use_fn.id)
.filter(|e| model
.nodes
.iter()
.any(|n| n.id == e.target && n.kind == NodeKind::Function))
.all(|e| e
.attributes
.get("source.resolution")
.and_then(|v| v.as_str())
== Some("declared-receiver-type"))
);
}
#[test]
fn conversion_operators_are_declarations_matching_their_prototypes() {
let model = scan_files(&[(
"src/flag.cpp",
"struct Flag {\n explicit operator bool() const;\n};\nFlag::operator bool() const { return true; }\n",
)]);
let operator = function(&model, "src/flag.cpp", "operator bool");
assert_eq!(
operator
.attributes
.get("source.qualified-name")
.and_then(|v| v.as_str()),
Some("Flag::operator bool")
);
}
#[test]
fn resolution_metrics_count_call_sites_by_outcome() {
let model = scan_files(&[(
"src/main.cpp",
"struct Box { int get() { return 1; } };\nBox make();\nint one() { return 1; }\nint main() {\n return one()\n + one()\n + make().get();\n}\n",
)]);
let cpp = &model.metrics.resolution["cpp"];
assert_eq!(cpp.call_site_count, 4);
assert_eq!(cpp.resolved_call_site_count, 2);
assert_eq!(
cpp.unresolved_call_sites_by_reason["receiver-type-unknown"],
1
);
assert_eq!(cpp.unresolved_call_sites_by_reason["declared-only"], 1);
assert_eq!(cpp.function_count, 3);
// main has no callers and Box::get is only reachable through an untyped receiver.
assert_eq!(cpp.functions_without_resolved_callers, 2);
}
#[test]
fn using_directives_and_declarations_extend_lookup_only_where_visible() {
let model = scan_files(&[
(
"include/lib/tune.h",
"namespace lib::daemon {\nstruct Tune { static int ticks(); };\nint helper();\n}\n",
),
(
"src/tune.cpp",
"#include \"lib/tune.h\"\nnamespace lib::daemon {\nint Tune::ticks() { return 1; }\nint helper() { return 2; }\n}\n",
),
(
"tests/tune_test.cpp",
"#include \"lib/tune.h\"\nusing namespace lib::daemon;\nint check() {\n Tune tune;\n return Tune::ticks()\n + tune.ticks();\n}\n",
),
(
"tests/alias_test.cpp",
"#include \"lib/tune.h\"\nusing lib::daemon::helper;\nint alias() { return helper(); }\n",
),
// Clean negative: the directive lives in a file this caller never includes.
("tests/other_using.cpp", "using namespace lib::daemon;\n"),
(
"tests/negative_test.cpp",
"#include \"lib/tune.h\"\nint negative() { return helper(); }\n",
),
]);
let (ticks, reason) = single_call(&model, function(&model, "tests/tune_test.cpp", "check"));
assert_eq!(ticks.path.as_deref(), Some("src/tune.cpp"));
assert_eq!(ticks.name, "ticks");
assert_eq!(reason, None);
let (helper, reason) = single_call(&model, function(&model, "tests/alias_test.cpp", "alias"));
assert_eq!(helper.path.as_deref(), Some("src/tune.cpp"));
assert_eq!(reason, None);
let (target, reason) = single_call(
&model,
function(&model, "tests/negative_test.cpp", "negative"),
);
assert_eq!(target.kind, NodeKind::External);
assert_eq!(reason.as_deref(), Some("not-visible"));
}
#[test]
fn brace_default_arguments_do_not_hide_prototypes_or_report_errors() {
let model = scan_files(&[
(
"src/api.h",
"struct Config {};\nint store(const Config& config = {});\n",
),
(
"src/api.cpp",
"#include \"api.h\"\nint store(const Config& config) { return 0; }\n",
),
(
"src/main.cpp",
"#include \"api.h\"\nint main() { return store(); }\n",
),
]);
let (target, reason) = single_call(&model, function(&model, "src/main.cpp", "main"));
assert_eq!(target.path.as_deref(), Some("src/api.cpp"));
assert_eq!(reason, None);
assert!(model.diagnostics.is_empty(), "{:?}", model.diagnostics);
}

View file

@ -0,0 +1,21 @@
#pragma once
namespace geo {
class Shape {
public:
Shape();
double area() const;
static Shape unit();
int& counter();
private:
double scale(double x) const;
};
class Circle : public Shape {
public:
double radius() const;
};
double total(const Shape& shape);
} // namespace geo

223
tests/fixtures/cpp_xtu/cpp/labels.json vendored Normal file
View file

@ -0,0 +1,223 @@
{
"language_prefixes": {
"headers": "include/",
"sources": "src/",
"tools": "tools/"
},
"symbols": [
[
"include/geo/shape.h::scope=geo::Shape",
"class"
],
[
"src/shape.cpp::scope=geo::Shape::Shape",
"function"
],
[
"src/shape.cpp::scope=geo::Shape::area",
"function"
],
[
"src/shape.cpp::scope=geo::Shape::scale",
"function"
],
[
"src/shape.cpp::scope=geo::Shape::unit",
"function"
],
[
"src/shape.cpp::scope=geo::Shape::counter",
"function"
],
[
"src/shape.cpp::scope=geo::total",
"function"
],
[
"src/util.cpp::twice",
"function"
],
[
"src/util.cpp::helper",
"function"
],
[
"src/main.cpp::main",
"function"
],
[
"src/other.cpp::twice",
"function"
],
[
"src/other.cpp::orphan",
"function"
],
[
"src/other.cpp::undeclared_caller",
"function"
],
[
"src/overload.cpp::log_value",
"function"
],
[
"src/overload.cpp::log_value",
"function"
],
[
"src/overload.cpp::use_log",
"function"
],
[
"src/legacy.c::add",
"function"
],
[
"src/legacy.c::legacy_sum",
"function"
],
[
"tools/dup.cpp::helper",
"function"
],
[
"include/geo/shape.h::scope=geo::Circle",
"class"
],
[
"src/shape.cpp::scope=geo::Circle::radius",
"function"
],
[
"src/buffer.h::Buffer",
"class"
],
[
"src/buffer.h::Buffer::size",
"function"
]
],
"relationships": [
[
"src/shape.cpp",
"imports",
"include/geo/shape.h"
],
[
"src/util.cpp",
"imports",
"src/util.h"
],
[
"src/main.cpp",
"imports",
"include/geo/shape.h"
],
[
"src/main.cpp",
"imports",
"src/util.h"
],
[
"src/shape.cpp::scope=geo::Shape::area",
"calls",
"src/shape.cpp::scope=geo::Shape::scale"
],
[
"src/shape.cpp::scope=geo::Shape::unit",
"calls",
"src/shape.cpp::scope=geo::Shape::Shape"
],
[
"src/util.cpp::helper",
"calls",
"src/util.cpp::twice"
],
[
"src/main.cpp::main",
"calls",
"src/shape.cpp::scope=geo::Shape::unit"
],
[
"src/main.cpp::main",
"calls",
"src/util.cpp::helper"
],
[
"src/main.cpp::main",
"calls",
"src/shape.cpp::scope=geo::total"
],
[
"src/main.cpp::main",
"calls",
"external:cpp:LOG_TWICE"
],
[
"src/other.cpp::orphan",
"calls",
"src/other.cpp::twice"
],
[
"src/other.cpp::undeclared_caller",
"calls",
"external:cpp:helper"
],
[
"src/overload.cpp::use_log",
"calls",
"external:cpp:log_value"
],
[
"src/legacy.c::legacy_sum",
"calls",
"src/legacy.c::add"
],
[
"src/main.cpp",
"imports",
"src/buffer.h"
],
[
"src/main.cpp",
"imports",
"external:cpp:string"
],
[
"include/geo/shape.h::scope=geo::Circle",
"inherits",
"include/geo/shape.h::scope=geo::Shape"
],
[
"src/shape.cpp::scope=geo::Circle::radius",
"calls",
"src/shape.cpp::scope=geo::Shape::area"
],
[
"src/shape.cpp::scope=geo::total",
"calls",
"src/shape.cpp::scope=geo::Shape::area"
],
[
"src/main.cpp::main",
"calls",
"src/shape.cpp::scope=geo::Shape::counter"
],
[
"src/main.cpp::main",
"calls",
"external:cpp:size"
],
[
"src/main.cpp::main",
"calls",
"external:cpp:size"
],
[
"src/main.cpp::main",
"calls",
"external:cpp:make_buffer"
]
]
}

View file

@ -0,0 +1,6 @@
#pragma once
// Clean negative: a repository size() that std::string and untyped receivers must not reach.
struct Buffer {
int size() const { return 0; }
};

View file

@ -0,0 +1,3 @@
static int add(int a, int b) { return a + b; }
int legacy_sum(void) { return add(1, 2); }

16
tests/fixtures/cpp_xtu/cpp/src/main.cpp vendored Normal file
View file

@ -0,0 +1,16 @@
#include "geo/shape.h"
#include "buffer.h"
#include "util.h"
#include <string>
Buffer make_buffer();
int main() {
std::string text;
text.size();
make_buffer().size();
geo::Shape shape = geo::Shape::unit();
int n = helper(3);
shape.counter();
return static_cast<int>(geo::total(shape)) + n + LOG_TWICE(n);
}

View file

@ -0,0 +1,6 @@
// Clean negative: same-named internal function and an undeclared cross-TU call.
static int twice(int x) { return x + x; }
int orphan() { return twice(1); }
int undeclared_caller() { return helper(1); }

View file

@ -0,0 +1,4 @@
void log_value(int) {}
void log_value(double) {}
void use_log() { log_value(1); }

View file

@ -0,0 +1,20 @@
#include "geo/shape.h"
namespace geo {
Shape::Shape() {}
double Shape::area() const { return this->scale(2.0) + scale(1.0); }
double Shape::scale(double x) const { return x; }
Shape Shape::unit() { return Shape(); }
int& Shape::counter() {
static int count = 0;
return count;
}
double Circle::radius() const { return area(); }
double total(const Shape& shape) { return shape.area(); }
} // namespace geo

View file

@ -0,0 +1,5 @@
#include "util.h"
static int twice(int x) { return x * 2; }
int helper(int x) { return twice(x); }

4
tests/fixtures/cpp_xtu/cpp/src/util.h vendored Normal file
View file

@ -0,0 +1,4 @@
#pragma once
int helper(int x);
#define LOG_TWICE(x) ((x) * 2)

View file

@ -0,0 +1,2 @@
// Clean negative: a second program's helper that never includes util.h.
int helper(int x) { return x; }

View file

@ -0,0 +1,51 @@
{
"language_prefixes": {"rust": "src/"},
"symbols": [
["src/lib.rs::run", "function"],
["src/lib.rs::describe", "function"],
["src/store.rs::Engine", "class"],
["src/store.rs::Engine::commit", "function"],
["src/store.rs::Store", "class"],
["src/store.rs::Store::open", "function"],
["src/store.rs::Store::build", "function"],
["src/store.rs::Store::flush", "function"],
["src/store.rs::Store::name", "function"],
["src/store.rs::Store::is_ready", "function"],
["src/store.rs::Store::log", "function"],
["src/store.rs::Named", "class"],
["src/store.rs::Named::label", "function"],
["src/store.rs::Store::label", "function"],
["src/store.rs::Engine::label", "function"],
["src/store.rs::show", "function"],
["src/store.rs::show_generic", "function"],
["src/negative.rs::File", "class"],
["src/negative.rs::File::open", "function"],
["src/negative.rs::File::sync_all", "function"],
["src/negative.rs::unrelated", "function"]
],
"relationships": [
["src/lib.rs", "imports", "src/store.rs"],
["src/lib.rs", "imports", "external:rust:std::fs::File"],
["src/store.rs", "imports", "external:rust:std::sync::Arc"],
["src/lib.rs::run", "calls", "src/store.rs::Store::open"],
["src/lib.rs::run", "calls", "src/store.rs::Store::flush"],
["src/lib.rs::run", "calls", "src/store.rs::Store::is_ready"],
["src/lib.rs::run", "calls", "src/lib.rs::describe"],
["src/lib.rs::run", "calls", "external:rust:File::open"],
["src/lib.rs::run", "calls", "external:rust:sync_all"],
["src/lib.rs::run", "calls", "external:rust:Ok"],
["src/lib.rs::describe", "calls", "src/store.rs::Store::name"],
["src/store.rs::Store::open", "calls", "external:rust:Ok"],
["src/store.rs::Store::open", "calls", "src/store.rs::Store::build"],
["src/store.rs::Store::build", "calls", "external:rust:Arc::new"],
["src/store.rs::Store::build", "calls", "external:rust:into"],
["src/store.rs::Store::flush", "calls", "src/store.rs::Engine::commit"],
["src/store.rs::Store::flush", "calls", "src/store.rs::Store::log"],
["src/store.rs::Store::label", "calls", "src/store.rs::Store::name"],
["src/store.rs::Store::label", "calls", "external:rust:to_owned"],
["src/store.rs::Engine::label", "calls", "external:rust:String::new"],
["src/store.rs::show", "calls", "src/store.rs::Named::label"],
["src/store.rs::show_generic", "calls", "external:rust:label"],
["src/negative.rs::unrelated", "calls", "external:rust:len"]
]
}

View file

@ -0,0 +1,19 @@
mod negative;
mod store;
use crate::store::Store;
use std::fs::File;
pub fn run(path: &str) -> anyhow::Result<()> {
let store = Store::open(path)?;
store.flush();
assert!(store.is_ready(), "not ready: {}", describe(&store));
// Clean negative: std's File must not reach the repository's negative::File.
let file = File::open(path)?;
file.sync_all()?;
Ok(())
}
fn describe(store: &Store) -> String {
format!("{}", store.name())
}

View file

@ -0,0 +1,14 @@
// Clean negative: same names as std::fs::File; nothing in lib.rs may call these.
pub struct File;
impl File {
pub fn open(_path: &str) -> Self {
File
}
pub fn sync_all(&self) {}
}
pub fn unrelated(value: &str) -> usize {
value.len()
}

View file

@ -0,0 +1,65 @@
use std::sync::Arc;
pub struct Engine;
impl Engine {
pub fn commit(&self) {}
}
pub struct Store {
inner: Arc<Engine>,
name: String,
}
impl Store {
pub fn open(path: &str) -> anyhow::Result<Self> {
Ok(Self::build(path))
}
fn build(path: &str) -> Self {
Store {
inner: Arc::new(Engine),
name: path.into(),
}
}
pub fn flush(&self) {
self.inner.commit();
self.log();
}
pub fn name(&self) -> &str {
&self.name
}
pub fn is_ready(&self) -> bool {
true
}
fn log(&self) {}
}
pub trait Named {
fn label(&self) -> String;
}
impl Named for Store {
fn label(&self) -> String {
self.name().to_owned()
}
}
impl Named for Engine {
fn label(&self) -> String {
String::new()
}
}
pub fn show(item: &dyn Named) -> String {
item.label()
}
// Clean negative: a generic receiver names no concrete implementation.
pub fn show_generic<T: Named>(item: &T) -> String {
item.label()
}

155
tests/rust_methods.rs Normal file
View file

@ -0,0 +1,155 @@
use brick::model::{Building, EdgeKind, Node, NodeKind};
use brick::scan;
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()
}
fn function<'a>(model: &'a Building, path: &str, name: &str) -> &'a Node {
model
.nodes
.iter()
.find(|n| n.kind == NodeKind::Function && n.path.as_deref() == Some(path) && n.name == name)
.unwrap_or_else(|| panic!("missing {path}::{name}"))
}
/// (target parent name, target name, attribute value) for calls leaving `source`.
fn calls(model: &Building, source: &Node, attribute: &str) -> Vec<(String, String, String)> {
let mut found: Vec<_> = model
.edges
.iter()
.filter(|e| e.kind == EdgeKind::Calls && e.source == source.id)
.map(|e| {
let target = model.nodes.iter().find(|n| n.id == e.target).unwrap();
let parent = target
.parent
.as_ref()
.and_then(|id| model.nodes.iter().find(|n| &n.id == id))
.map(|n| n.name.clone())
.unwrap_or_default();
let value = e
.attributes
.get(attribute)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned();
(parent, target.name.clone(), value)
})
.collect();
found.sort();
found
}
#[test]
fn expression_macro_bodies_yield_calls_and_other_macros_stay_opaque() {
let model = scan_files(&[(
"src/lib.rs",
"fn check() -> bool { true }\nfn name() -> u8 { 0 }\nfn hidden() {}\nmacro_rules! custom { ($e:expr) => { $e } }\nfn run() {\n assert!(check(), \"{}\", format!(\"{}\", name()));\n custom!(hidden());\n}\n",
)]);
let run = function(&model, "src/lib.rs", "run");
assert_eq!(
calls(&model, run, "source.extraction"),
[
("lib.rs".into(), "check".into(), "macro-reparse".into()),
("lib.rs".into(), "name".into(), "macro-reparse".into()),
]
);
let hidden = function(&model, "src/lib.rs", "hidden");
assert!(
!model
.edges
.iter()
.any(|e| e.target == hidden.id && e.kind == EdgeKind::Calls)
);
}
#[test]
fn receivers_resolve_through_self_fields_constructors_and_dyn_traits() {
let model = scan_files(&[(
"src/lib.rs",
"use std::sync::Arc;\npub struct Engine;\nimpl Engine { pub fn commit(&self) {} }\npub trait Named { fn label(&self) -> String; }\npub struct Store { inner: Arc<Engine> }\nimpl Store {\n pub fn open() -> Result<Self, ()> { Ok(Self::build()) }\n fn build() -> Self { Store { inner: Arc::new(Engine) } }\n fn flush(&self) {\n self.inner.commit();\n self.log();\n }\n fn log(&self) {}\n}\nfn run(item: &dyn Named) -> Result<(), ()> {\n let store = Store::open()?;\n store.flush();\n item.label();\n Ok(())\n}\n",
)]);
let resolved = |source: &Node| {
calls(&model, source, "source.resolution")
.into_iter()
.filter(|(parent, _, _)| !parent.is_empty())
.collect::<Vec<_>>()
};
assert_eq!(
resolved(function(&model, "src/lib.rs", "flush")),
[
(
"Engine".into(),
"commit".into(),
"declared-receiver-type".into()
),
("Store".into(), "log".into(), "self-receiver".into()),
]
);
assert_eq!(
resolved(function(&model, "src/lib.rs", "open")),
[("Store".into(), "build".into(), "impl-type".into())]
);
assert_eq!(
resolved(function(&model, "src/lib.rs", "run")),
[
(
"Named".into(),
"label".into(),
"declared-receiver-type".into()
),
(
"Store".into(),
"flush".into(),
"declared-receiver-type".into()
),
// Same-file `Type::f` already resolves by qualified lookup, unlabeled.
("Store".into(), "open".into(), "".into()),
]
);
}
#[test]
fn foreign_imports_generics_and_competing_trait_impls_never_guess() {
let model = scan_files(&[
(
"src/lib.rs",
"mod local;\nuse std::fs::File;\npub trait Named { fn label(&self) -> String; }\nstruct A;\nstruct B;\nstruct C;\nimpl Named for A { fn label(&self) -> String { String::new() } }\nimpl Named for C { fn label(&self) -> String { String::new() } }\ntrait Other { fn label(&self) -> String; }\nimpl Other for A { fn label(&self) -> String { String::new() } }\nfn run<T: Named>(generic: &T, a: A) {\n let file = File::open(\"x\").unwrap();\n file.sync_all();\n generic.label();\n a.label();\n}\n",
),
(
"src/local.rs",
"pub struct File;\nimpl File {\n pub fn open(_p: &str) -> Self { File }\n pub fn sync_all(&self) {}\n}\n",
),
]);
let run = function(&model, "src/lib.rs", "run");
let reasons: Vec<_> = calls(&model, run, "source.unresolved-reason")
.into_iter()
.map(|(_, name, reason)| (name, reason))
.collect();
assert_eq!(
reasons,
[
("File::open".into(), "qualifier-unknown".into()),
("label".into(), "ambiguous".into()),
("label".into(), "receiver-type-unknown".into()),
("sync_all".into(), "receiver-type-unknown".into()),
("unwrap".into(), "receiver-type-unknown".into()),
]
);
for name in ["open", "sync_all"] {
let local = function(&model, "src/local.rs", name);
assert!(
!model
.edges
.iter()
.any(|e| e.target == local.id && e.kind == EdgeKind::Calls)
);
}
}