feat: add brick_callers and brick_uncalled caller-status queries #7

Merged
trevon merged 3 commits from feat/caller-status into main 2026-09-25 02:51:17 +00:00
12 changed files with 1232 additions and 89 deletions
Showing only changes of commit d7be35d7ef - Show all commits

Merge branch 'main' into feat/caller-status
Some checks failed
build / build-linux (pull_request) Successful in 1m4s
build / build-macos (pull_request) Has been cancelled
build / release (pull_request) Has been cancelled

trevon 2026-09-25 02:51:04 +00:00

11
Cargo.lock generated
View file

@ -84,6 +84,7 @@ dependencies = [
"serde_json",
"tempfile",
"tree-sitter",
"tree-sitter-c",
"tree-sitter-cpp",
"tree-sitter-dart",
"tree-sitter-javascript",
@ -423,6 +424,16 @@ dependencies = [
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-c"
version = "0.24.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9b2eb57a55fed6b00812912e730b7a275cf4fe98bfd6a5d76263d4438371728"
dependencies = [
"cc",
"tree-sitter-language",
]
[[package]]
name = "tree-sitter-cpp"
version = "0.23.4"

View file

@ -16,6 +16,7 @@ regex = "1.12"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tree-sitter = "=0.26.13"
tree-sitter-c = "=0.24.2"
tree-sitter-cpp = "=0.23.4"
tree-sitter-javascript = "=0.25.0"
tree-sitter-typescript = "=0.23.2"

View file

@ -6,8 +6,8 @@ elements, and dependency edges show calls, imports, and inheritance. The same
model is emitted as versioned JSON for other tools, including a future Fold
integration.
The default extractor uses pinned Tree-sitter grammars for Rust, Dart, C/C++,
JavaScript/JSX, and TypeScript/TSX. Python and Swift retain the lexical preview;
The default extractor uses pinned Tree-sitter grammars for Rust, Dart, C
(`.c`), C++ (including headers), JavaScript/JSX, and TypeScript/TSX. Python and Swift retain the lexical preview;
P4_16 retains its separate lexical and optional pilot paths. Declarations are
Syntactic evidence. Calls, imports, and inheritance retain Heuristic target
resolution with backend, confidence, and source location, independently of
@ -32,8 +32,16 @@ Layer Fold findings onto the building:
--json fold.brick.json \
--html fold.brick.html
A report must be a SARIF log or a Fold findings report; anything else is an
error. Findings keep Fold's symbolic lane only when the report names Fold as its
producer (`tool.name` / `tool.driver.name`); other producers' findings are
imported, never symbolic. Findings without a file location are diagnosed, and
SARIF `file://` URIs are decoded before matching.
Fold artifact paths are normalized lexically across `/`, `\\`, `.`, and `..`
components before exact repository-relative matching. Absolute paths are
components before exact repository-relative matching; a path with `..` that
exists in this checkout follows the filesystem instead, so `link/..` resolves
through the symlink. Absolute paths are
accepted only beneath the model's scan root (including existing filesystem
aliases whose canonical targets are beneath that root); leading traversal and unrelated
suffix paths remain diagnostics. Findings for excluded, generated, or genuinely
@ -152,26 +160,43 @@ Brick extracts syntax, without compiler type checking or build configuration.
Macro expansion, overload resolution, dynamic dispatch, generated code, and
full package/build-system resolution remain outside the default frontend.
Malformed syntax produces diagnostics; recoverable sibling facts remain visible.
A file with invalid UTF-8 is decoded lossily with a diagnostic; files with NUL
bytes are skipped as binary. `build` prints diagnostic counts, and a scan that
finds no supported source says so. `check` recomputes metrics from the graph
(stored metrics are never trusted) and fails a model with no scanned source.
Its score is not proof of correctness, maintainability,
or security. Use the component metrics and evidence records; do not rank teams
or repositories by the composite alone.
Resolution respects language, file, declaration ownership, and lexical namespace.
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
bindings, module-scoped Rust grouped/aliased imports, and module-qualified Rust
public calls can resolve across files.
Grammar-based resolution respects language, file, declaration ownership, and
lexical namespace. Relative JS/TS and Dart imports resolve against the importing
directory (Dart `package:<own package>/` against `lib/`); missing paths never
fall back to unrelated basenames. Explicit JS/TS named/default import bindings,
module-scoped Rust grouped/aliased imports, module-qualified Rust public calls,
and public top-level Dart names from imported libraries can resolve across files
(Dart `show`/`hide` combinators are not modelled). The lexical preview resolves
Python names in the calling file, then in files it imports; Swift names in the
calling file, then by a module-wide unique name (Swift files of one module see
each other without imports).
C++ calls resolve through the include closure: a header prototype pairs with
its definition in another translation unit, lookup walks class (including
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!`,
definitions stay file-local. Declarations in `#elif`/`#else` branches stay in
the graph (`source.preprocessor-branch: alternative`) but are never call
targets, because Brick does not evaluate conditions; several `using`
directives that nominate different declarations make a call ambiguous. 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`. Rust types are identified by their
declaring file, so same-named types in different modules resolve through the
caller's `use` binding or stay ambiguous. 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
`metrics.resolution` reports per-language call-site coverage; read the graph
with that coverage in mind (on a large C++ repository only a minority of call
sites resolve). Ambiguous targets stay external. Virtual calls resolve to the
declared type's method only (overrides are not fanned out), and Catch2
`TEST_CASE` bodies are not yet recognized as functions. 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

View file

@ -42,7 +42,8 @@ change cycle, isolation, and fan metrics; no health weights are changed.
| Rust const/impl | Const function, distinct type-owned methods, stable IDs after unrelated insertions; unresolved impl cannot create cyclic ownership |
| Fold absolute alias | `src/fold.rs` tests existing root aliases while rejecting outside and excluded files; original default-tempdir test passes |
The grammar versions are pinned in Cargo.toml and Cargo.lock: C++ 0.23.4,
The grammar versions are pinned in Cargo.toml and Cargo.lock: C 0.24.2 (`.c`
files), C++ 0.23.4,
JavaScript 0.25.0, TypeScript/TSX 0.23.2, Dart 0.2.0, Rust 0.24.2, with the
Tree-sitter Rust runtime at 0.26.13. Upstream API/grammar references:
[C++](https://docs.rs/tree-sitter-cpp/0.23.4/tree_sitter_cpp/),

View file

@ -2,7 +2,7 @@ use crate::metrics;
use crate::model::{
Building, Confidence, Edge, EdgeKind, Evidence, EvidenceLane, Node, NodeKind, stable_id,
};
use anyhow::{Context, Result};
use anyhow::{Context, Result, bail};
use serde_json::Value;
use std::collections::BTreeMap;
use std::fs;
@ -13,11 +13,67 @@ pub fn import_findings(building: &mut Building, report: &Path) -> Result<usize>
.with_context(|| format!("cannot read Fold report {}", report.display()))?;
let value: Value = serde_json::from_str(&raw)
.with_context(|| format!("invalid JSON in Fold report {}", report.display()))?;
let findings = if value.get("runs").is_some() {
let sarif_runs = value.get("runs").and_then(Value::as_array);
let is_native = value.get("findings").and_then(Value::as_array).is_some();
if sarif_runs.is_none() && !is_native {
// Anything else imported zero findings silently, like a clean run.
bail!(
"{} is not a Fold report: expected a SARIF log (`runs`) or a Fold findings report (`findings`)",
report.display()
);
}
// Fold's proved/latent tiers mean something only when Fold produced them:
// a report from another tool never earns the Symbolic lane by labelling itself.
let producers: Vec<Option<&str>> = match sarif_runs {
Some(runs) => runs
.iter()
.map(|run| run.pointer("/tool/driver/name").and_then(Value::as_str))
.collect(),
None => vec![value.pointer("/tool/name").and_then(Value::as_str)],
};
let from_fold = !producers.is_empty() && producers.iter().all(|name| *name == Some("fold"));
if !from_fold {
let named: Vec<&str> = producers
.iter()
.map(|name| name.unwrap_or("unnamed"))
.collect();
building.diagnostics.push(crate::model::Diagnostic {
level: "warning".into(),
message: format!(
"report {} was not produced by Fold (producer: {}); its findings are imported, never symbolic",
report.display(),
named.join(", ")
),
path: None,
});
}
if let Some(version) = value.get("output_version")
&& version.as_u64() != Some(1)
{
building.diagnostics.push(crate::model::Diagnostic {
level: "warning".into(),
message: format!(
"Fold report {} has unsupported output_version {version}; read as version 1",
report.display()
),
path: None,
});
}
let (findings, unlocated) = if sarif_runs.is_some() {
sarif_findings(&value)
} else {
native_findings(&value)
};
if unlocated > 0 {
building.diagnostics.push(crate::model::Diagnostic {
level: "warning".into(),
message: format!(
"{unlocated} Fold finding(s) in {} had no file location and were not imported",
report.display()
),
path: None,
});
}
let mut imported = 0;
for finding in findings {
@ -83,7 +139,7 @@ pub fn import_findings(building: &mut Building, report: &Path) -> Result<usize>
.unwrap_or_else(|| format!("{}:{}:{}", finding.rule, relative_path, finding.line));
let node_id = stable_id("finding", &identity);
let evidence = Evidence {
lane: fold_lane(&backend, finding.confidence.as_deref()),
lane: fold_lane(&backend, finding.confidence.as_deref(), from_fold),
backend: format!("fold:discover:{backend}"),
confidence: finding.confidence.clone().map(Confidence::Label),
provenance: format!("{}:{}", report.display(), finding.line),
@ -141,12 +197,17 @@ struct Finding {
backend: Option<String>,
}
fn native_findings(value: &Value) -> Vec<Finding> {
value
/// Findings plus the number dropped for lack of a file location.
fn native_findings(value: &Value) -> (Vec<Finding>, usize) {
let items: Vec<&Value> = value
.get("findings")
.and_then(Value::as_array)
.into_iter()
.flatten()
.collect();
let total = items.len();
let findings: Vec<Finding> = items
.into_iter()
.filter_map(|item| {
Some(Finding {
stable_id: string(item, ["id", "finding_id"]),
@ -159,11 +220,14 @@ fn native_findings(value: &Value) -> Vec<Finding> {
backend: native_backend(item),
})
})
.collect()
.collect();
let dropped = total - findings.len();
(findings, dropped)
}
fn sarif_findings(value: &Value) -> Vec<Finding> {
value
/// Findings plus the number dropped for lack of a file location.
fn sarif_findings(value: &Value) -> (Vec<Finding>, usize) {
let items: Vec<&Value> = value
.get("runs")
.and_then(Value::as_array)
.into_iter()
@ -174,6 +238,10 @@ fn sarif_findings(value: &Value) -> Vec<Finding> {
.into_iter()
.flatten()
})
.collect();
let total = items.len();
let findings: Vec<Finding> = items
.into_iter()
.filter_map(|item| {
let location = item.pointer("/locations/0/physicalLocation")?;
Some(Finding {
@ -194,7 +262,7 @@ fn sarif_findings(value: &Value) -> Vec<Finding> {
.and_then(Value::as_str)
.unwrap_or_default()
.into(),
path: location.pointer("/artifactLocation/uri")?.as_str()?.into(),
path: sarif_uri_path(location.pointer("/artifactLocation/uri")?.as_str()?),
line: location
.pointer("/region/startLine")
.and_then(Value::as_u64)
@ -209,7 +277,34 @@ fn sarif_findings(value: &Value) -> Vec<Finding> {
.map(str::to_owned),
})
})
.collect()
.collect();
let dropped = total - findings.len();
(findings, dropped)
}
/// SARIF `artifactLocation.uri` as a path: `file://[localhost]/p` loses its scheme and
/// percent-escapes (`%20`) are decoded; relative URIs pass through unchanged.
fn sarif_uri_path(uri: &str) -> String {
let Some(rest) = uri.strip_prefix("file://") else {
return uri.to_owned();
};
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
let bytes = rest.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& i + 2 < bytes.len()
&& let Ok(byte) = u8::from_str_radix(&rest[i + 1..i + 3], 16)
{
decoded.push(byte);
i += 3;
continue;
}
decoded.push(bytes[i]);
i += 1;
}
String::from_utf8(decoded).unwrap_or_else(|_| rest.to_owned())
}
fn string<const N: usize>(value: &Value, keys: [&str; N]) -> Option<String> {
@ -277,7 +372,24 @@ fn find_file(building: &Building, path: &str) -> Option<String> {
{
return None;
}
normalized
// When the path exists here, follow the filesystem: `link/../x` names what
// the OS resolves through `link`, not the lexical `x`. Paths absent from this
// checkout (a report from another machine) keep lexical matching.
let on_disk = Path::new(&building.root).join(path.replace('\\', "/"));
if path.contains("..")
&& let (Ok(canonical), Ok(root)) = (
on_disk.canonicalize(),
Path::new(&building.root).canonicalize(),
)
{
canonical
.strip_prefix(&root)
.ok()?
.to_str()?
.replace('\\', "/")
} else {
normalized
}
};
building
.nodes
@ -327,9 +439,10 @@ fn normalize_lexical_path(path: &str) -> String {
}
}
fn fold_lane(backend: &str, confidence: Option<&str>) -> EvidenceLane {
fn fold_lane(backend: &str, confidence: Option<&str>, from_fold: bool) -> EvidenceLane {
const SYMBOLIC_CONFIDENCE: [&str; 3] = ["concrete-witness", "proved", "latent"];
if backend == "source"
if from_fold
&& backend == "source"
&& confidence.is_some_and(|confidence| SYMBOLIC_CONFIDENCE.contains(&confidence))
{
EvidenceLane::Symbolic
@ -362,6 +475,8 @@ mod tests {
fs::write(dir.path().join("main.cpp"), "int main() { return 0; }\n").unwrap();
let report = dir.path().join("fold.json");
let value = serde_json::json!({
"output_version": 1,
"tool": {"name": "fold", "version": "0.1.0"},
"findings": [{
"id": "f1",
"kind": "null-deref",
@ -414,7 +529,10 @@ mod tests {
})).collect();
fs::write(
&report,
serde_json::to_vec(&serde_json::json!({"findings": findings})).unwrap(),
serde_json::to_vec(
&serde_json::json!({"tool": {"name": "fold"}, "findings": findings}),
)
.unwrap(),
)
.unwrap();
let mut model = scan(&root).unwrap();
@ -439,6 +557,7 @@ mod tests {
&report,
r#"{
"runs": [{
"tool": {"driver": {"name": "fold"}},
"results": [
{
"ruleId": "fold.valid",
@ -485,6 +604,7 @@ mod tests {
let outside = dir.path().parent().unwrap().join("other/main.cpp");
let report = dir.path().join("fold.json");
let value = serde_json::json!({
"tool": {"name": "fold"},
"findings": [
{
"id": "absolute-outside",
@ -634,4 +754,131 @@ mod tests {
.any(|evidence| evidence.provenance.contains("second.json"))
);
}
#[test]
fn rejects_a_file_that_is_not_a_fold_report() {
// `{"hello":1}` imported 0 findings with no diagnostic, indistinguishable
// from a clean Fold run.
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
let report = dir.path().join("fold.json");
fs::write(&report, r#"{"hello": 1}"#).unwrap();
let mut building = scan(dir.path()).unwrap();
let error = import_findings(&mut building, &report).unwrap_err();
assert!(
format!("{error:#}").contains("not a Fold report"),
"{error:#}"
);
}
#[test]
fn symbolic_lane_requires_a_fold_producer_not_a_self_declared_label() {
// Any JSON claiming backend "source" and confidence "proved" got the Symbolic
// lane. Only a report produced by Fold may carry Fold's proved tier.
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("main.cpp"), "int main() { return 0; }\n").unwrap();
let report = dir.path().join("other.json");
let value = serde_json::json!({
"tool": {"name": "someone-else"},
"findings": [{
"id": "x", "kind": "null-deref",
"locations": [{"file": "main.cpp", "line": 1}],
"confidence": "proved", "evidence": {"backend": "source"}
}]
});
fs::write(&report, serde_json::to_vec(&value).unwrap()).unwrap();
let mut building = scan(dir.path()).unwrap();
assert_eq!(import_findings(&mut building, &report).unwrap(), 1);
let flags = building
.edges
.iter()
.find(|edge| edge.kind == EdgeKind::Flags)
.unwrap();
assert_eq!(flags.evidence[0].lane, crate::model::EvidenceLane::Imported);
assert!(
building
.diagnostics
.iter()
.any(|d| d.message.contains("not produced by Fold"))
);
}
#[test]
fn sarif_file_uris_match_and_unlocated_findings_are_diagnosed() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("my file.cpp"), "int main() { return 0; }\n").unwrap();
let mut building = scan(dir.path()).unwrap();
let uri = format!(
"file://{}",
dir.path()
.join("my file.cpp")
.to_str()
.unwrap()
.replace(' ', "%20")
);
let report = dir.path().join("fold.sarif");
let value = serde_json::json!({
"version": "2.1.0",
"runs": [{
"tool": {"driver": {"name": "fold"}},
"results": [
{"ruleId": "div-by-zero", "message": {"text": "x"},
"locations": [{"physicalLocation": {"artifactLocation": {"uri": uri},
"region": {"startLine": 1}}}],
"properties": {"confidence": "proved", "backend": "source"}},
{"ruleId": "no-location", "message": {"text": "y"},
"properties": {"confidence": "heuristic", "backend": "source"}}
]
}]
});
fs::write(&report, serde_json::to_vec(&value).unwrap()).unwrap();
assert_eq!(import_findings(&mut building, &report).unwrap(), 1);
assert!(
building
.diagnostics
.iter()
.any(|d| d.message.contains("no file location")),
"{:?}",
building.diagnostics
);
}
#[cfg(unix)]
#[test]
fn parent_steps_after_a_symlinked_directory_follow_the_filesystem() {
// `linkout/../src/main.rs` with `linkout` pointing outside the root was
// normalized lexically to `src/main.rs`, a file the OS path does not name.
use std::os::unix::fs::symlink;
let workspace = tempfile::tempdir().unwrap();
let root = workspace.path().join("repo");
fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
let outside = workspace.path().join("elsewhere/deeper");
fs::create_dir_all(&outside).unwrap();
fs::create_dir_all(workspace.path().join("elsewhere/src")).unwrap();
fs::write(
workspace.path().join("elsewhere/src/main.rs"),
"fn other() {}\n",
)
.unwrap();
symlink(&outside, root.join("linkout")).unwrap();
let report = workspace.path().join("fold.json");
let value = serde_json::json!({
"tool": {"name": "fold"},
"findings": [{
"id": "through-link", "kind": "test",
"locations": [{"file": "linkout/../src/main.rs", "line": 1}],
"confidence": "heuristic", "evidence": {"backend": "source"}
}]
});
fs::write(&report, serde_json::to_vec(&value).unwrap()).unwrap();
let mut model = scan(&root).unwrap();
assert_eq!(import_findings(&mut model, &report).unwrap(), 0);
assert!(
model
.diagnostics
.iter()
.any(|d| d.message.contains("did not match a scanned file"))
);
}
}

View file

@ -182,16 +182,28 @@ fn run(cli: Cli) -> Result<()> {
.count();
write_json(&building, &json)?;
render::render(&building, &html, &cytoscape_url)?;
let warnings = building
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.level == "warning")
.count();
println!(
"built {} nodes / {} edges; score {:.1}; imported {} Fold findings / {} p4c diagnostics\nJSON: {}\nHTML: {}",
"built {} nodes / {} edges; score {:.1}; imported {} Fold findings / {} p4c diagnostics; {} diagnostics ({} warnings)\nJSON: {}\nHTML: {}",
building.metrics.node_count,
building.metrics.edge_count,
building.metrics.structural_health.score,
imported_fold,
imported_p4c,
building.diagnostics.len(),
warnings,
json.display(),
html.display()
);
if !has_source_files(&building) {
eprintln!(
"brick: warning: no supported source files were found; the model is empty and its score describes nothing"
);
}
}
Command::Mcp { input } => {
let building = read_json(&input)?;
@ -227,8 +239,24 @@ fn run(cli: Cli) -> Result<()> {
bail!("--min-score must be a finite number");
}
let building = read_json(&input)?;
let metrics = &building.metrics;
// Gate on metrics recomputed from the graph, never on the stored copy:
// editing `metrics` in the JSON must not be able to pass a gate.
let mut recomputed = building.clone();
brick::metrics::calculate(&mut recomputed);
let mut failures = Vec::new();
if serde_json::to_value(&recomputed.metrics)?
!= serde_json::to_value(&building.metrics)?
{
failures.push(
"stored metrics do not match the graph; gating on recomputed metrics".into(),
);
}
let metrics = &recomputed.metrics;
if !has_source_files(&building) {
failures.push(
"the model contains no scanned source files; nothing was analysed".into(),
);
}
if metrics.structural_health.score < min_score {
failures.push(format!(
"score {:.1} is below {:.1}",
@ -402,6 +430,14 @@ fn write_json(building: &Building, path: &PathBuf) -> Result<()> {
.with_context(|| format!("cannot write graph {}", path.display()))
}
/// At least one source file was scanned into the model.
fn has_source_files(building: &Building) -> bool {
building
.nodes
.iter()
.any(|node| node.kind == brick::model::NodeKind::File)
}
fn read_json(path: &PathBuf) -> Result<Building> {
let raw = fs::read_to_string(path)
.with_context(|| format!("cannot read graph {}", path.display()))?;

View file

@ -100,6 +100,14 @@ pub fn scan_with_options(root: &Path, options: &ScanOptions) -> Result<Building>
state.walk(&root)?;
state.resolve_relationships();
state.annotate_indirect_uses();
// An analysis of nothing must not read as a healthy repository.
if !state.nodes.iter().any(|node| node.kind == NodeKind::File) {
state.diagnostics.push(Diagnostic {
level: "warning".into(),
message: "no supported source files were found; the model is empty".into(),
path: None,
});
}
let mut building = Building {
schema_version: SCHEMA_VERSION,
@ -140,16 +148,21 @@ struct RustCall {
receiver: Option<crate::source::Receiver>,
}
/// A method node id and the trait it implements, if any.
type RustMethod = (String, Option<String>);
/// A method node id, the trait it implements (if any), and the file of its `impl`.
type RustMethod = (String, Option<String>, String);
/// Crate-wide Rust impl/trait facts keyed by leaf type names.
/// Crate-wide Rust impl/trait facts keyed by leaf type names. Leaf names are not
/// identities: when several files declare the same name, `rust_type_file` settles
/// which one a use site means (declaring file, then `use` binding) or reports it
/// as ambiguous.
#[derive(Default)]
struct RustIndex {
/// (type or trait, method) -> candidate methods.
methods: HashMap<(String, String), Vec<RustMethod>>,
/// Struct, enum, and trait names declared in the repository.
types: HashSet<String>,
/// Leaf type/trait name -> files declaring it.
type_files: HashMap<String, Vec<String>>,
/// (struct, field) -> declared type.
fields: HashMap<(String, String), crate::source::CppType>,
/// Method node id -> declared return type.
@ -233,11 +246,25 @@ struct ScanState {
rust: RustIndex,
/// Language -> identifiers used outside call/declaration-name positions.
references: HashMap<String, HashSet<String>>,
/// Lexical-language file -> repository files it imports (resolved edges).
imported_paths: HashMap<String, Rc<HashSet<String>>>,
/// `name:` of the root pubspec.yaml, so `package:<name>/x.dart` maps to lib/x.dart.
dart_package: Option<String>,
}
impl ScanState {
fn new(root: PathBuf, root_id: String, options: &ScanOptions) -> Self {
let dart_package = fs::read_to_string(root.join("pubspec.yaml"))
.ok()
.and_then(|pubspec| {
pubspec.lines().find_map(|line| {
line.strip_prefix("name:")
.map(|name| name.trim().trim_matches(['\'', '"']).to_owned())
.filter(|name| !name.is_empty())
})
});
Self {
dart_package,
root,
root_id,
nodes: Vec::new(),
@ -255,6 +282,7 @@ impl ScanState {
cpp: CppIndex::default(),
rust: RustIndex::default(),
references: HashMap::new(),
imported_paths: HashMap::new(),
}
}
@ -340,17 +368,39 @@ impl ScanState {
return;
}
};
let content = match fs::read_to_string(path) {
let bytes = match fs::read(path) {
Ok(value) => value,
Err(error) => {
self.diagnostics.push(Diagnostic {
level: "warning".into(),
message: format!("skipped non-UTF-8 or unreadable source: {error}"),
message: format!("skipped unreadable source: {error}"),
path: Some(relative),
});
return;
}
};
// NUL bytes mean a binary file, not source. Any other invalid UTF-8 (a
// Latin-1 byte in a comment) is decoded lossily: dropping the whole file hid
// every symbol in it.
if bytes.contains(&0) {
self.diagnostics.push(Diagnostic {
level: "warning".into(),
message: "skipped binary file with a source extension".into(),
path: Some(relative),
});
return;
}
let content = match String::from_utf8(bytes) {
Ok(value) => value,
Err(error) => {
self.diagnostics.push(Diagnostic {
level: "warning".into(),
message: "decoded non-UTF-8 bytes lossily (U+FFFD)".into(),
path: Some(relative.clone()),
});
String::from_utf8_lossy(error.as_bytes()).into_owned()
}
};
let file_id = stable_id("file", &relative);
let parent = path
.parent()
@ -408,9 +458,14 @@ impl ScanState {
let line = strip_line_comment(raw_line, language);
let call_line = mask_quoted_strings(line);
let indent = raw_line.len() - raw_line.trim_start().len();
if language == "python" && !line.trim().is_empty() {
while scopes.last().is_some_and(|scope| scope.indent >= indent) {
scopes.pop();
if language == "python" {
// Blank and comment-only lines say nothing about Python scope. They
// used to fall through to the brace-depth rule below, which popped
// every open scope: calls after a blank line left their function.
if !line.trim().is_empty() {
while scopes.last().is_some_and(|scope| scope.indent >= indent) {
scopes.pop();
}
}
} else {
while scopes
@ -421,9 +476,11 @@ impl ScanState {
}
}
let mut class_name = None;
if let Some(captures) = patterns.class.captures(line)
&& let Some(name) = captures.name("name")
{
class_name = Some(name.as_str().to_owned());
let owner_id = scopes
.last()
.map(|scope| scope.id.clone())
@ -525,7 +582,11 @@ impl ScanState {
let target = target_match.as_str();
let prefix = call_line[..target_match.start()].trim_end();
let member_call = prefix.ends_with('.') || prefix.ends_with("->");
if definition_name.as_deref() != Some(target) && !is_keyword(target) {
// `class Child(Base):` names the class being declared, not a call.
if definition_name.as_deref() != Some(target)
&& class_name.as_deref() != Some(target)
&& !is_keyword(target)
{
self.pending.push(PendingRelationship {
source: source.clone(),
target_name: clean_target(target),
@ -611,6 +672,12 @@ impl ScanState {
if declaration.is_virtual {
attributes.insert("source.virtual".into(), serde_json::json!(true));
}
if declaration.preprocessor_alternative {
attributes.insert(
"source.preprocessor-branch".into(),
serde_json::json!("alternative"),
);
}
if document.parse_incomplete && declaration.kind == NodeKind::Function {
attributes.insert("source.parse-incomplete".into(), serde_json::json!(true));
}
@ -657,6 +724,14 @@ impl ScanState {
let Some(id) = id else { continue };
if declaration.kind == NodeKind::Class {
self.rust.types.insert(declaration.name.clone());
let files = self
.rust
.type_files
.entry(declaration.name.clone())
.or_default();
if !files.iter().any(|file| file == path) {
files.push(path.to_owned());
}
continue;
}
let Some(owner) = declaration
@ -676,7 +751,7 @@ impl ScanState {
.methods
.entry((owner, declaration.name.clone()))
.or_default()
.push((id.clone(), implemented));
.push((id.clone(), implemented, path.to_owned()));
if let Some(returns) = &declaration.return_type {
self.rust.returns.insert(id.clone(), returns.clone());
}
@ -699,6 +774,9 @@ impl ScanState {
}
}
for (declaration, id) in document.declarations.iter().zip(&ids) {
if declaration.preprocessor_alternative {
continue;
}
if declaration.kind == NodeKind::Class
&& let Some(name) = &declaration.qualified_name
{
@ -1226,6 +1304,32 @@ impl ScanState {
}
}
/// Repository files that `path` imports through already-resolved edges.
/// Imports resolve before calls, so the cached set is complete once built.
fn imported_paths(&mut self, path: &str) -> Rc<HashSet<String>> {
if let Some(found) = self.imported_paths.get(path) {
return Rc::clone(found);
}
let paths: HashSet<String> = self
.nodes
.iter()
.find(|n| n.kind == NodeKind::File && n.path.as_deref() == Some(path))
.map(|file| {
self.edges
.iter()
.filter(|e| e.kind == EdgeKind::Imports && e.source == file.id)
.filter_map(|e| self.node(&e.target))
.filter(|n| n.kind == NodeKind::File)
.filter_map(|n| n.path.clone())
.collect()
})
.unwrap_or_default();
let paths = Rc::new(paths);
self.imported_paths
.insert(path.to_owned(), Rc::clone(&paths));
paths
}
/// Mark functions that may be used without a resolvable call: named outside
/// a call position anywhere in the language, or C++ virtual via a prototype.
fn annotate_indirect_uses(&mut self) {
@ -1304,6 +1408,11 @@ impl ScanState {
if let Some(id) = self.resolve_qualified_symbol(relationship) {
return (id, None, None);
}
let imported = if language == "python" || language == "dart" {
self.imported_paths(&relationship.path)
} else {
Rc::default()
};
let candidates: Vec<_> = self
.symbols_by_name
.get(name)
@ -1339,10 +1448,49 @@ impl ScanState {
if let Some(id) = self.resolve_imported_symbol(relationship) {
return (id, None, None);
}
} else if candidates.len() == 1 {
return (candidates[0].id.clone(), None, None);
} else if candidates.len() > 1 {
reason = "ambiguous";
// A Dart import exposes the library's public top-level names with no
// per-name binding: resolve through the libraries this file imports.
if language == "dart" && reason != "ambiguous" {
let exposed: Vec<_> = candidates
.iter()
.filter(|n| {
!n.name.starts_with('_')
&& n.path.as_ref().is_some_and(|p| imported.contains(p))
&& n.parent
.as_deref()
.and_then(|parent| self.node(parent))
.is_some_and(|parent| parent.kind == NodeKind::File)
})
.collect();
match exposed.as_slice() {
[only] => return (only.id.clone(), None, None),
[] => {}
_ => reason = "ambiguous",
}
}
} else {
// Lexical languages: the caller's own file first, then (Python)
// only files it imports. Swift files in one module see each other
// without imports, so Swift keeps a module-wide unique name.
let same_file: Vec<_> = candidates
.iter()
.filter(|n| n.path.as_deref() == Some(&relationship.path))
.collect();
let visible: Vec<_> = if !same_file.is_empty() {
same_file
} else if language == "python" {
candidates
.iter()
.filter(|n| n.path.as_ref().is_some_and(|p| imported.contains(p)))
.collect()
} else {
candidates.iter().collect()
};
match visible.as_slice() {
[only] => return (only.id.clone(), None, None),
[] => {}
_ => reason = "ambiguous",
}
}
}
if !relationship.member_call
@ -1467,11 +1615,24 @@ impl ScanState {
}
}
if !absolute {
for qualified in self.cpp_using_candidates(name, &call.lookup_scope, &closure) {
if let Some(result) = self.cpp_lookup(&qualified, &closure) {
return result.map(|id| (id, Some("using-directive")));
// Every directive is consulted: two that nominate different
// declarations make the call ambiguous rather than first-come.
let mut nominated: Vec<(String, Result<String, &'static str>)> = Vec::new();
for group in self.cpp_using_candidate_groups(name, &call.lookup_scope, &closure) {
for qualified in group {
if let Some(result) = self.cpp_lookup(&qualified, &closure) {
if !nominated.iter().any(|(seen, _)| seen == &qualified) {
nominated.push((qualified, result));
}
break;
}
}
}
match nominated.as_slice() {
[] => {}
[(_, result)] => return result.clone().map(|id| (id, Some("using-directive"))),
_ => return Err("ambiguous"),
}
}
Err("not-visible")
}
@ -1588,7 +1749,27 @@ impl ScanState {
scope: &str,
closure: &HashSet<String>,
) -> Vec<String> {
let mut candidates = Vec::new();
let mut candidates: Vec<String> = self
.cpp_using_candidate_groups(name, scope, closure)
.into_iter()
.flatten()
.collect();
let mut seen = HashSet::new();
candidates.retain(|candidate| seen.insert(candidate.clone()));
candidates
}
/// `cpp_using_candidates` grouped by directive: one group per `using N::x;`
/// and per `using namespace N;`, whose members are the scope levels that
/// directive may name (innermost first). Separate groups are rival
/// nominations, so more than one resolving group is ambiguous.
fn cpp_using_candidate_groups(
&self,
name: &str,
scope: &str,
closure: &HashSet<String>,
) -> Vec<Vec<String>> {
let mut groups = Vec::new();
let (head, rest) = match name.split_once("::") {
Some((head, rest)) => (head, Some(rest)),
None => (name, None),
@ -1598,21 +1779,24 @@ impl ScanState {
for file in files {
for (leaf, target) in self.cpp.using_names.get(file).into_iter().flatten() {
if leaf == head {
candidates.push(match rest {
groups.push(vec![match rest {
Some(rest) => qualify(target, rest),
None => target.clone(),
});
}]);
}
}
for namespace in self.cpp.using_namespaces.get(file).into_iter().flatten() {
for level in scope_levels(scope) {
candidates.push(qualify(&qualify(&level, namespace), name));
}
groups.push(
scope_levels(scope)
.into_iter()
.map(|level| qualify(&qualify(&level, namespace), name))
.collect(),
);
}
}
let mut seen = HashSet::new();
candidates.retain(|candidate| seen.insert(candidate.clone()));
candidates
groups.retain(|group| seen.insert(group.clone()));
groups
}
/// Method lookup in a class, then breadth-first through in-repository bases.
@ -1690,7 +1874,7 @@ impl ScanState {
(field.accessed(true), "declared-receiver-type")
}
Some(Receiver::Returned { ty, func, fallible }) => (
self.rust_constructed_type(ty, func, *fallible, call)
self.rust_constructed_type(ty, func, *fallible, call, path)
.ok_or("receiver-type-unknown")?,
"declared-receiver-type",
),
@ -1699,7 +1883,7 @@ impl ScanState {
if self.rust_type_is_foreign(&ty, path) {
return Err("receiver-type-external");
}
self.rust_method(&ty, method).map(|id| (id, label))
self.rust_method(&ty, method, path).map(|id| (id, label))
}
/// A type name the caller's file imports from outside the crate.
@ -1746,30 +1930,86 @@ impl ScanState {
if !self.rust.types.contains(&ty) {
return Err("qualifier-unknown");
}
self.rust_method(&ty, method)
self.rust_method(&ty, method, path)
}
fn rust_method(&self, ty: &str, method: &str) -> Result<String, &'static str> {
fn rust_method(&self, ty: &str, method: &str, path: &str) -> Result<String, &'static str> {
let candidates = self
.rust
.methods
.get(&(ty.to_owned(), method.to_owned()))
.map(Vec::as_slice)
.unwrap_or_default();
let inherent: Vec<_> = candidates.iter().filter(|(_, t)| t.is_none()).collect();
let chosen: Vec<_> = if inherent.is_empty() {
// Several repository types share this leaf name: keep only the impls of the
// one this use site means, or refuse to guess.
let candidates: Vec<&RustMethod> = if self
.rust
.type_files
.get(ty)
.is_some_and(|files| files.len() > 1)
{
let Some(meant) = self.rust_type_file(ty, path) else {
return Err("ambiguous");
};
candidates
.iter()
.filter(|(_, _, impl_path)| {
self.rust_type_file(ty, impl_path).as_deref() == Some(meant.as_str())
})
.collect()
} else {
candidates.iter().collect()
};
let inherent: Vec<_> = candidates
.iter()
.copied()
.filter(|(_, t, _)| t.is_none())
.collect();
let chosen: Vec<_> = if inherent.is_empty() {
candidates
} else {
inherent
};
match chosen.as_slice() {
[(id, _)] => Ok(id.clone()),
[(id, _, _)] => Ok(id.clone()),
[] if self.rust.types.contains(ty) => Err("member-not-found"),
[] => Err("receiver-type-external"),
_ => Err("ambiguous"),
}
}
/// The file declaring the repository type `leaf` as seen from `path`: the file
/// itself, then a `use` binding that names it, then the only declaration. `None`
/// when several files declare `leaf` and nothing in `path` says which.
fn rust_type_file(&self, leaf: &str, path: &str) -> Option<String> {
let files = self.rust.type_files.get(leaf)?;
if files.iter().any(|file| file == path) {
return Some(path.to_owned());
}
for binding in self
.source_bindings
.get(path)
.into_iter()
.flatten()
.filter(|binding| binding.local == leaf)
{
let Some(file) = self
.resolve_import(&binding.module, "rust", path)
.and_then(|id| self.node(&id))
.and_then(|node| node.path.clone())
else {
continue;
};
if files.contains(&file) {
return Some(file);
}
}
match files.as_slice() {
[only] => Some(only.clone()),
_ => None,
}
}
/// `Type::func(..)` yields `Type` when `func` is declared to return `Self`
/// or `Type` (behind `?`, also `Result<..>`/`Option<..>` of either).
fn rust_constructed_type(
@ -1778,13 +2018,14 @@ impl ScanState {
func: &str,
fallible: bool,
call: &RustCall,
path: &str,
) -> Option<String> {
let ty = if ty == "Self" {
call.self_type.clone()?
} else {
ty.to_owned()
};
let id = self.rust_method(&ty, func).ok()?;
let id = self.rust_method(&ty, func, path).ok()?;
let returns = self.rust.returns.get(&id)?;
let returns_type = |value: &str| value == "Self" || value == ty;
let direct = returns_type(returns);
@ -1986,10 +2227,18 @@ impl ScanState {
}
}
"dart" => {
if name.contains(':') {
if let Some(rest) = name.strip_prefix("package:") {
// Only this repository's own package maps into it: `lib/<rest>`.
let (package, rest) = rest.split_once('/')?;
if self.dart_package.as_deref() != Some(package) {
return None;
}
paths.push(normalize_import_path(&format!("lib/{rest}"))?);
} else if name.contains(':') {
return None;
} else {
paths.push(local()?);
}
paths.push(local()?);
}
"cpp" | "p4" => {
if language == "cpp" && !name.contains('/') && Path::new(name).extension().is_none()

View file

@ -30,6 +30,9 @@ pub(crate) struct Declaration {
/// Rust outer attributes that may register the item with a framework
/// (`test`, `tokio::main`, `tool`, ...), excluding lint/doc/cfg attributes.
pub attributes: Vec<String>,
/// C/C++: inside an `#elif`/`#else` branch. Which branch is compiled is
/// unknown, so such declarations are structure, never resolution targets.
pub preprocessor_alternative: bool,
}
pub(crate) struct ImportBinding {
@ -129,8 +132,61 @@ pub(crate) fn supports(language: &str) -> bool {
)
}
/// Inside an `#elif`/`#else` branch of a conditional. Brick does not evaluate
/// preprocessor conditions, so only the first branch is treated as live. The
/// directive lines are tracked textually: conditions such as
/// `#if __has_include(<x>)` leave tree-sitter with an error-recovered tree
/// that has no `preproc_else` node to find.
fn in_preprocessor_alternative(node: Node<'_>, alternative_lines: &[bool]) -> bool {
alternative_lines
.get(node.start_position().row)
.copied()
.unwrap_or(false)
}
/// Per 0-based line: inside a non-first branch of some enclosing conditional.
fn preprocessor_alternative_lines(source: &str) -> Vec<bool> {
// One entry per open conditional: the index of the branch being read.
let mut branches: Vec<usize> = Vec::new();
source
.lines()
.map(|line| {
let directive = line.trim_start();
let directive = directive
.strip_prefix('#')
.map(|rest| rest.trim_start())
.unwrap_or("");
let word = directive
.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
.next()
.unwrap_or("");
match word {
"if" | "ifdef" | "ifndef" => {
let alternative = branches.iter().any(|&branch| branch > 0);
branches.push(0);
alternative
}
"elif" | "elifdef" | "elifndef" | "else" => {
if let Some(branch) = branches.last_mut() {
*branch += 1;
}
true
}
"endif" => {
branches.pop();
branches.iter().any(|&branch| branch > 0)
}
_ => branches.iter().any(|&branch| branch > 0),
}
})
.collect()
}
pub(crate) fn extract(language: &str, path: &str, source: &str) -> Result<Document> {
let (grammar, version) = match language {
// `.c` shares the C/C++ extraction path (and header index) but must parse
// as C: identifiers such as `new`, `delete` or `class` are valid C.
"cpp" if path.ends_with(".c") => (tree_sitter_c::LANGUAGE, "c:0.24.2"),
"cpp" => (tree_sitter_cpp::LANGUAGE, "cpp:0.23.4"),
"rust" => (tree_sitter_rust::LANGUAGE, "rust:0.24.2"),
"javascript" => (tree_sitter_javascript::LANGUAGE, "javascript:0.25.0"),
@ -144,6 +200,11 @@ pub(crate) fn extract(language: &str, path: &str, source: &str) -> Result<Docume
"dart" => (tree_sitter_dart::LANGUAGE, "dart:0.2.0"),
_ => anyhow::bail!("unsupported grammar: {language}"),
};
let alternative_lines = if language == "cpp" {
preprocessor_alternative_lines(source)
} else {
Vec::new()
};
let mut parser = Parser::new();
parser
.set_language(&grammar.into())
@ -181,6 +242,9 @@ pub(crate) fn extract(language: &str, path: &str, source: &str) -> Result<Docume
// Iterative traversal avoids a Rust stack overflow on deeply nested source.
// Error subtrees are omitted; complete sibling declarations remain usable.
let mut nodes = Vec::new();
// Parent of every traversed node. tree-sitter's `Node::parent` re-descends from
// the root (O(depth)), so ancestor walks through it were quadratic in nesting.
let mut parents: HashMap<usize, Node<'_>> = HashMap::new();
let mut errors: Vec<(usize, usize)> = Vec::new();
let mut stack = vec![tree.root_node()];
while let Some(node) = stack.pop() {
@ -251,6 +315,9 @@ pub(crate) fn extract(language: &str, path: &str, source: &str) -> Result<Docume
}
let mut cursor = node.walk();
let children: Vec<_> = node.named_children(&mut cursor).collect();
for child in &children {
parents.insert(child.id(), node);
}
stack.extend(children.into_iter().rev());
}
// Anonymous MISSING tokens (`#endif`, `;`, `}`) are not named children; find
@ -304,7 +371,11 @@ pub(crate) fn extract(language: &str, path: &str, source: &str) -> Result<Docume
),
None => (name.clone(), String::new(), None, false),
};
if let Some(qualified) = &qualified_name {
let preprocessor_alternative =
language == "cpp" && in_preprocessor_alternative(node, &alternative_lines);
if let Some(qualified) = &qualified_name
&& !preprocessor_alternative
{
doc.declared.push(qualified.clone());
}
let (impl_type, impl_trait) = if language == "rust" {
@ -323,13 +394,14 @@ pub(crate) fn extract(language: &str, path: &str, source: &str) -> Result<Docume
Vec::new()
};
doc.declarations.push(Declaration {
preprocessor_alternative,
is_virtual,
attributes,
exports: exported_names(node, &name),
name,
kind,
owner: None,
scope: lexical_scope(node, source),
scope: lexical_scope(node, source, &parents),
line: node.start_position().row + 1,
identity,
qualifier,
@ -352,7 +424,7 @@ pub(crate) fn extract(language: &str, path: &str, source: &str) -> Result<Docume
let ty = ancestor
.child_by_field_name("type")
.map(|n| base_type(n, source));
let scope = lexical_scope(ancestor, source);
let scope = lexical_scope(ancestor, source, &parents);
let candidates: Vec<_> = doc
.declarations
.iter()
@ -365,7 +437,13 @@ pub(crate) fn extract(language: &str, path: &str, source: &str) -> Result<Docume
declaration_nodes[*i],
&declarations,
&doc.declarations,
) == enclosing_function(ancestor, &declarations, &doc.declarations)
&parents,
) == enclosing_function(
ancestor,
&declarations,
&doc.declarations,
&parents,
)
})
.map(|(i, _)| i)
.collect();
@ -408,8 +486,8 @@ pub(crate) fn extract(language: &str, path: &str, source: &str) -> Result<Docume
{
continue;
}
let owner = enclosing_function(node, &declarations, &doc.declarations);
let scope = lexical_scope(node, source);
let owner = enclosing_function(node, &declarations, &doc.declarations, &parents);
let scope = lexical_scope(node, source, &parents);
if language == "rust" && node.kind() == "use_declaration" && !has_error(node) {
for (target, binding) in rust_imports(node, source) {
if owner.is_none() {
@ -440,6 +518,7 @@ pub(crate) fn extract(language: &str, path: &str, source: &str) -> Result<Docume
}
if language == "cpp" {
if !has_error(node)
&& !in_preprocessor_alternative(node, &alternative_lines)
&& let Some(prototype) = cpp_prototype(node, source)
{
if cpp_is_virtual(node) {
@ -472,7 +551,7 @@ pub(crate) fn extract(language: &str, path: &str, source: &str) -> Result<Docume
for (target, line) in cpp_vexing_calls(node, source) {
doc.relationships.push(Relationship {
owner,
shadowed: parameter_shadows(node, &target, source),
shadowed: parameter_shadows(node, &target, source, &parents),
target,
kind: EdgeKind::Calls,
member: false,
@ -513,6 +592,7 @@ pub(crate) fn extract(language: &str, path: &str, source: &str) -> Result<Docume
if node.kind() == "macro_invocation" {
let context = MacroContext {
position: node,
parents: &parents,
owner,
scope: &scope,
self_type: &self_type,
@ -565,7 +645,7 @@ pub(crate) fn extract(language: &str, path: &str, source: &str) -> Result<Docume
None
};
doc.relationships.push(Relationship {
shadowed: !member && parameter_shadows(node, &target, source),
shadowed: !member && parameter_shadows(node, &target, source, &parents),
owner,
target,
member,
@ -765,8 +845,20 @@ fn cpp_leaf_name(name: Node<'_>, source: &str) -> Option<String> {
Some(text(name, source).to_owned())
}
fn lexical_scope(node: Node<'_>, source: &str) -> String {
let mut parent = node.parent();
/// The parent recorded during traversal, else tree-sitter's (O(depth)) lookup.
fn parent_of<'tree>(
node: Node<'tree>,
parents: &HashMap<usize, Node<'tree>>,
) -> Option<Node<'tree>> {
parents.get(&node.id()).copied().or_else(|| node.parent())
}
fn lexical_scope<'tree>(
node: Node<'tree>,
source: &str,
parents: &HashMap<usize, Node<'tree>>,
) -> String {
let mut parent = parent_of(node, parents);
let mut names = Vec::new();
while let Some(n) = parent {
if matches!(
@ -779,25 +871,26 @@ fn lexical_scope(node: Node<'_>, source: &str) -> String {
.unwrap_or("<anonymous>"),
);
}
parent = n.parent();
parent = parent_of(n, parents);
}
names.reverse();
names.join("::")
}
fn enclosing_function(
node: Node<'_>,
fn enclosing_function<'tree>(
node: Node<'tree>,
ids: &HashMap<usize, usize>,
declarations: &[Declaration],
parents: &HashMap<usize, Node<'tree>>,
) -> Option<usize> {
let mut parent = node.parent();
let mut parent = parent_of(node, parents);
while let Some(n) = parent {
if let Some(&index) = ids.get(&n.id())
&& declarations[index].kind == NodeKind::Function
{
return Some(index);
}
parent = n.parent();
parent = parent_of(n, parents);
}
None
}
@ -869,7 +962,24 @@ fn bases(node: Node<'_>, source: &str) -> Vec<String> {
child.kind(),
"base_class_clause" | "class_heritage" | "superclass" | "extends_type_clause"
) {
let mut stack = vec![child];
let mut stack = Vec::new();
if child.kind() == "superclass" {
// tree-sitter-dart gives `extends Bloc<Ev, St>` as sibling `type`
// fields: the first is the base, later ones are its type arguments.
let mut first_type = true;
let mut c = child.walk();
for part in child.named_children(&mut c) {
if part.kind() == "type" {
if !first_type {
continue;
}
first_type = false;
}
stack.push(part);
}
} else {
stack.push(child);
}
while let Some(base) = stack.pop() {
if matches!(
base.kind(),
@ -882,8 +992,20 @@ fn bases(node: Node<'_>, source: &str) -> Vec<String> {
result.push(base_type(base, source));
continue;
}
// Type arguments name parameters of the base, not further bases:
// `extends Bloc<Ev, St>` / `: public Base<Arg>` inherit only Bloc/Base.
if matches!(
base.kind(),
"type_arguments" | "template_argument_list" | "type_parameters"
) {
continue;
}
let mut c = base.walk();
stack.extend(base.named_children(&mut c));
let nested_type_arguments = base.kind() == "type";
stack.extend(
base.named_children(&mut c)
.filter(|part| !(nested_type_arguments && part.kind() == "type")),
);
}
}
}
@ -927,8 +1049,13 @@ fn exported_names(node: Node<'_>, name: &str) -> Vec<String> {
Vec::new()
}
fn parameter_shadows(node: Node<'_>, name: &str, source: &str) -> bool {
let mut parent = node.parent();
fn parameter_shadows<'tree>(
node: Node<'tree>,
name: &str,
source: &str,
parents: &HashMap<usize, Node<'tree>>,
) -> bool {
let mut parent = parent_of(node, parents);
while let Some(n) = parent {
let signature = n
.child_by_field_name("signature")
@ -962,7 +1089,7 @@ fn parameter_shadows(node: Node<'_>, name: &str, source: &str) -> bool {
);
}
}
parent = n.parent();
parent = parent_of(n, parents);
}
false
}
@ -994,7 +1121,7 @@ fn import_bindings(node: Node<'_>, module: &str, source: &str) -> Vec<ImportBind
local: local.into(),
exported: exported.into(),
module: module.into(),
scope: lexical_scope(node, source),
scope: lexical_scope(node, source, &HashMap::new()),
});
}
let mut cursor = n.walk();
@ -1064,7 +1191,7 @@ fn rust_imports(node: Node<'_>, source: &str) -> Vec<(String, Option<ImportBindi
local,
exported: exported.into(),
module: module.into(),
scope: lexical_scope(node, source),
scope: lexical_scope(node, source, &HashMap::new()),
})
};
output.push((target, binding));
@ -1751,6 +1878,7 @@ fn rust_fields(node: Node<'_>, source: &str) -> Vec<(String, String, CppType)> {
struct MacroContext<'a, 'tree> {
position: Node<'tree>,
parents: &'a HashMap<usize, Node<'tree>>,
owner: Option<usize>,
scope: &'a str,
self_type: &'a Option<String>,
@ -1847,7 +1975,12 @@ fn rust_macro_calls(
});
relationships.push(Relationship {
shadowed: !member
&& parameter_shadows(context.position, &target, context.source),
&& parameter_shadows(
context.position,
&target,
context.source,
context.parents,
),
owner: context.owner,
target,
member,

View file

@ -326,3 +326,110 @@ fn brace_default_arguments_do_not_hide_prototypes_or_report_errors() {
assert_eq!(reason, None);
assert!(model.diagnostics.is_empty(), "{:?}", model.diagnostics);
}
#[test]
fn two_using_directives_naming_the_same_function_are_ambiguous() {
// With `using namespace a; using namespace b;` and both `a::run` and `b::run`
// defined, the call resolved to whichever directive came first. C++ makes that
// ambiguous (or an overload choice Brick cannot make), so it must stay external.
let model = scan_files(&[
("src/a.h", "namespace a { int run(int x); }\n"),
("src/b.h", "namespace b { int run(int x); }\n"),
(
"src/defs.cpp",
"#include \"a.h\"\n#include \"b.h\"\nnamespace a { int run(int x) { return x; } }\nnamespace b { int run(int x) { return -x; } }\n",
),
(
"src/main.cpp",
"#include \"a.h\"\n#include \"b.h\"\nusing namespace a;\nusing namespace b;\nint main() { return run(1); }\n",
),
// Clean negative: one directive still resolves with its label.
(
"src/only_a.cpp",
"#include \"a.h\"\nusing namespace a;\nint only() { return run(1); }\n",
),
]);
let (target, reason) = single_call(&model, function(&model, "src/main.cpp", "main"));
assert_eq!(target.kind, NodeKind::External);
assert_eq!(reason.as_deref(), Some("ambiguous"));
let (target, reason) = single_call(&model, function(&model, "src/only_a.cpp", "only"));
assert_eq!(target.path.as_deref(), Some("src/defs.cpp"));
assert_eq!(reason, None);
}
#[test]
fn preprocessor_alternative_branches_are_not_call_targets() {
// An `#else` fallback (yams' spdlog stubs in InternalEventBus.h) was indexed as
// live, so every spdlog::warn call site in the repository resolved to the stub and
// the stubs became false hubs. Brick cannot know which branch is active, so only
// the first branch may be a resolution target.
let model = scan_files(&[
(
"include/log.h",
"#if __has_include(<spdlog/spdlog.h>)\n#include <spdlog/spdlog.h>\n#elif defined(ALT)\nnamespace spdlog { inline void info(const char*) {} }\n#else\nnamespace spdlog { inline void warn(const char*) {} }\n#endif\n#ifndef LOG_H\n#define LOG_H\nnamespace app { inline int level() { return 1; } }\n#endif\n",
),
(
"src/main.cpp",
"#include \"log.h\"\nint main() { spdlog::warn(\"x\"); spdlog::info(\"y\"); return app::level(); }\n",
),
]);
let found = calls(&model, function(&model, "src/main.cpp", "main"));
let mut by_name: Vec<(String, NodeKind, Option<String>)> = found
.iter()
.map(|(node, reason)| (node.name.clone(), node.kind, reason.clone()))
.collect();
by_name.sort_by(|a, b| a.0.cmp(&b.0));
// First-branch (include-guard) code still resolves; the alternatives do not.
assert!(
by_name
.iter()
.any(|(name, kind, _)| name == "level" && *kind == NodeKind::Function)
);
for (name, kind, _) in &by_name {
if name.ends_with("warn") || name.ends_with("info") {
assert_eq!(
*kind,
NodeKind::External,
"{name} resolved into an #else/#elif branch"
);
}
}
let stub = model
.nodes
.iter()
.find(|n| n.kind == NodeKind::Function && n.name == "warn")
.expect("alternative branches stay visible as structure");
assert_eq!(
stub.attributes
.get("source.preprocessor-branch")
.and_then(|v| v.as_str()),
Some("alternative")
);
}
#[test]
fn c_sources_parse_with_the_c_grammar() {
// `.c` files went through the C++ grammar, so valid C using C++ keywords as
// identifiers produced grammar-error diagnostics and damaged extraction.
let model = scan_files(&[
("src/util.h", "int twice(int new);\n"),
(
"src/util.c",
"#include \"util.h\"\nint twice(int new) { int delete = new; return delete * 2; }\n",
),
(
"src/main.c",
"#include \"util.h\"\nint main(void) { int class = 1; return twice(class); }\n",
),
]);
let grammar_errors: Vec<_> = model
.diagnostics
.iter()
.filter(|d| d.path.as_deref().is_some_and(|p| p.ends_with(".c")))
.map(|d| d.message.clone())
.collect();
assert!(grammar_errors.is_empty(), "{grammar_errors:?}");
let (target, reason) = single_call(&model, function(&model, "src/main.c", "main"));
assert_eq!(target.path.as_deref(), Some("src/util.c"));
assert_eq!(reason, None);
}

View file

@ -259,3 +259,97 @@ fn relocated_artifacts_compare_after_checkouts_are_deleted() {
);
}
}
#[test]
fn check_recomputes_metrics_instead_of_trusting_the_file() {
// `check` gated on the metrics stored in the JSON, so editing `cycle_count` and
// `score` turned a failing gate into a pass without changing the graph.
let directory = tempfile::tempdir().unwrap();
let project = directory.path().join("project");
fs::create_dir(&project).unwrap();
fs::write(
project.join("lib.rs"),
"pub fn alpha() { beta(); }\nfn beta() { alpha(); }\n",
)
.unwrap();
let model = scan::scan(&project).unwrap();
assert!(model.metrics.cycle_count > 0);
let honest = directory.path().join("honest.json");
write_model(&honest, &model);
let gate = |path: &Path| {
Command::new(env!("CARGO_BIN_EXE_brick"))
.args([
"check",
path.to_str().unwrap(),
"--max-cycles",
"0",
"--min-score",
"0",
])
.output()
.unwrap()
};
assert!(!gate(&honest).status.success());
let mut value: Value = serde_json::to_value(&model).unwrap();
value["metrics"]["cycle_count"] = 0.into();
value["metrics"]["structural_health"]["score"] = 100.0.into();
let tampered = directory.path().join("tampered.json");
fs::write(&tampered, serde_json::to_vec_pretty(&value).unwrap()).unwrap();
let output = gate(&tampered);
assert!(!output.status.success(), "tampered metrics passed the gate");
let text = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(text.contains("stored metrics"), "{text}");
}
#[test]
fn a_scan_with_no_supported_source_is_reported_and_fails_check() {
// A Go/Java-only directory built "1 nodes / 0 edges; score 100.0" with no
// diagnostic, and `check --min-score 90 --max-cycles 0` passed: an analysis of
// nothing looked like a perfectly healthy repository.
let directory = tempfile::tempdir().unwrap();
let project = directory.path().join("project");
fs::create_dir(&project).unwrap();
fs::write(project.join("main.go"), "package main\nfunc main() {}\n").unwrap();
fs::write(project.join("App.java"), "class App {}\n").unwrap();
let json = directory.path().join("model.json");
let html = directory.path().join("model.html");
let build = Command::new(env!("CARGO_BIN_EXE_brick"))
.args([
"build",
project.to_str().unwrap(),
"--json",
json.to_str().unwrap(),
"--html",
html.to_str().unwrap(),
])
.output()
.unwrap();
assert!(build.status.success());
let stderr = String::from_utf8_lossy(&build.stderr);
assert!(stderr.contains("no supported source files"), "{stderr}");
let model: Value = serde_json::from_slice(&fs::read(&json).unwrap()).unwrap();
assert!(
model["diagnostics"]
.as_array()
.is_some_and(|d| d.iter().any(|d| d["message"]
.as_str()
.is_some_and(|m| m.contains("no supported source files"))))
);
let check = Command::new(env!("CARGO_BIN_EXE_brick"))
.args([
"check",
json.to_str().unwrap(),
"--min-score",
"90",
"--max-cycles",
"0",
])
.output()
.unwrap();
assert!(!check.status.success(), "an empty model passed check");
}

View file

@ -539,3 +539,193 @@ fn cpp_parent_relative_includes_resolve_without_basename_fallback() {
.any(|n| n.kind == NodeKind::External && n.name == "../missing/base.h")
);
}
#[test]
fn generic_type_arguments_are_not_base_classes() {
// `extends Bloc<Ev, St>` produced inherits edges to Ev and St: the base walker
// descended into the type-argument list. On pipe 39 of 145 Dart inherits edges
// (27%) pointed at type arguments. C++ `: public Base<Arg>` shared the walker.
let (_, model) = project(&[
(
"lib/bloc.dart",
"class Ev {}\nclass St {}\nclass Bloc<E, S> {}\nclass MyBloc extends Bloc<Ev, St> {}\n",
),
(
"src/base.cpp",
"template <class T> class Base {};\nclass Arg {};\nclass Derived : public Base<Arg> {};\n",
),
]);
let inherits = |source: &Node| {
let mut names: Vec<String> = model
.edges
.iter()
.filter(|e| e.kind == EdgeKind::Inherits && e.source == source.id)
.map(|e| {
model
.nodes
.iter()
.find(|n| n.id == e.target)
.unwrap()
.name
.clone()
})
.collect();
names.sort();
names
};
assert_eq!(
inherits(symbol(&model, "lib/bloc.dart", "MyBloc")),
["Bloc"]
);
assert_eq!(
inherits(symbol(&model, "src/base.cpp", "Derived")),
["Base"]
);
}
#[test]
fn python_calls_keep_their_function_and_resolve_only_what_is_visible() {
let (_, model) = project(&[
(
"pkg/a.py",
"def process(x):\n return x\n\ndef unrelated():\n pass\n",
),
(
"pkg/b.py",
"class Base:\n pass\n\ndef helper():\n pass\n",
),
(
"pkg/c.py",
// A blank line and a comment inside the body used to pop the function
// scope, attributing the later calls to the file.
"from pkg.b import Base\n\nclass Child(Base):\n def go(self):\n x = 1\n\n # comment\n helper()\n process(1)\n unrelated()\n",
),
(
"pkg/d.py",
"from pkg.a import process\n\ndef use():\n return process(2)\n",
),
]);
let go = symbol(&model, "pkg/c.py", "go");
let outgoing = |source: &Node| {
let mut found: Vec<(String, 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();
(target.name.clone(), target.path.clone().unwrap_or_default())
})
.collect();
found.sort();
found
};
// helper comes from the imported pkg/b.py; process and unrelated are never
// imported by c.py, so they stay external instead of resolving by unique name.
assert_eq!(
outgoing(go),
[
("helper".to_owned(), "pkg/b.py".to_owned()),
("process".to_owned(), String::new()),
("unrelated".to_owned(), String::new()),
]
);
// An imported name still resolves across files.
assert_eq!(
outgoing(symbol(&model, "pkg/d.py", "use")),
[("process".to_owned(), "pkg/a.py".to_owned())]
);
// A class header is not a call.
let file_c = model
.nodes
.iter()
.find(|n| n.kind == NodeKind::File && n.path.as_deref() == Some("pkg/c.py"))
.unwrap();
assert!(
!model
.edges
.iter()
.any(|e| e.kind == EdgeKind::Calls && e.source == file_c.id),
"file-level call edges in pkg/c.py"
);
}
#[test]
fn dart_calls_resolve_through_imported_libraries() {
// Dart imports expose a library's public top-level names without a binding per
// name, so no cross-file Dart call ever resolved even when its import did.
let (_, model) = project(&[
("pubspec.yaml", "name: demo\n"),
(
"lib/util.dart",
"int helper() => 1;\nint _secret() => 2;\nclass Tool { int use() => 3; }\n",
),
("lib/other.dart", "int shared() => 4;\n"),
("lib/twin.dart", "int shared() => 5;\n"),
(
"lib/main.dart",
"import 'util.dart';\nimport 'package:demo/other.dart';\nimport 'twin.dart';\nvoid run() {\n helper();\n _secret();\n shared();\n}\n",
),
// Clean negative: no import, no edge.
("lib/stranger.dart", "void stray() {\n helper();\n}\n"),
]);
let targets = |source: &Node| {
let mut found: Vec<(String, String, 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();
(
target.name.clone(),
target.path.clone().unwrap_or_default(),
e.attributes
.get("source.unresolved-reason")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned(),
)
})
.collect();
found.sort();
found
};
assert_eq!(
targets(symbol(&model, "lib/main.dart", "run")),
[
("_secret".into(), String::new(), "not-visible".into()),
("helper".into(), "lib/util.dart".into(), String::new()),
("shared".into(), String::new(), "ambiguous".into()),
]
);
assert_eq!(
targets(symbol(&model, "lib/stranger.dart", "stray")),
[("helper".into(), String::new(), "not-visible".into())]
);
}
#[test]
fn a_non_utf8_byte_does_not_drop_the_whole_file() {
// One Latin-1 `é` in a comment made the whole file (and its functions) vanish.
let dir = tempfile::tempdir().unwrap();
let mut source = b"// caf".to_vec();
source.push(0xE9);
source.extend_from_slice(b"\nfn kept() {}\nfn caller() { kept(); }\n");
fs::write(dir.path().join("latin1.rs"), source).unwrap();
fs::write(dir.path().join("blob.rs"), [0u8, 159, 146, 150]).unwrap();
let model = scan::scan(dir.path()).unwrap();
let caller = symbol(&model, "latin1.rs", "caller");
assert!(calls(&model, caller, symbol(&model, "latin1.rs", "kept")));
assert!(
model
.diagnostics
.iter()
.any(|d| d.path.as_deref() == Some("latin1.rs") && d.message.contains("non-UTF-8"))
);
// A file with NUL bytes is binary, not source: still skipped with a diagnostic.
assert!(
!model
.nodes
.iter()
.any(|n| n.path.as_deref() == Some("blob.rs"))
);
}

View file

@ -153,3 +153,52 @@ fn foreign_imports_generics_and_competing_trait_impls_never_guess() {
);
}
}
#[test]
fn same_named_types_in_different_modules_resolve_through_use_bindings() {
// Types were keyed by leaf name, so `b::Config` (method from a trait impl) and
// `a::Config` (inherent method) shared one key and the inherent one won: a call on
// `b::Config` was attributed to `a::Config::load`.
let model = scan_files(&[
("src/lib.rs", "pub mod a;\npub mod b;\npub mod user;\n"),
(
"src/a.rs",
"pub struct Config;\nimpl Config {\n pub fn load(&self) -> u32 { 1 }\n}\n",
),
(
"src/b.rs",
"pub struct Config;\npub trait Loader { fn load(&self) -> u32; }\nimpl Loader for Config {\n fn load(&self) -> u32 { 2 }\n}\n",
),
(
"src/user.rs",
"use crate::b::Config;\nuse crate::b::Loader;\npub fn run(c: &Config) -> u32 { c.load() }\n",
),
(
"src/other.rs",
"pub fn guess(c: &Config) -> u32 { c.load() }\n",
),
]);
let targets = |source: &Node| {
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();
(
target.path.clone().unwrap_or_default(),
e.attributes
.get("source.unresolved-reason")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_owned(),
)
})
.collect::<Vec<_>>()
};
let run = function(&model, "src/user.rs", "run");
assert_eq!(targets(run), [("src/b.rs".to_owned(), String::new())]);
// Without a binding, two same-named types leave the call ambiguous, not guessed.
let guess = function(&model, "src/other.rs", "guess");
assert_eq!(targets(guess), [(String::new(), "ambiguous".to_owned())]);
}