fix/audit-findings #5

Merged
trevon merged 14 commits from fix/audit-findings into main 2026-09-25 02:39:30 +00:00
3 changed files with 124 additions and 1 deletions
Showing only changes of commit 2b19142557 - Show all commits

fix(cpp): never resolve calls into #elif/#else branches

Every preprocessor branch was indexed as live. In yams,
InternalEventBus.h guards spdlog with
`#if __has_include(<spdlog/spdlog.h>) ... #else namespace spdlog { stubs }`,
so 1,243 spdlog::warn/info/error call sites resolved to the fallback
stubs, which became the 4th and 5th highest fan-in nodes.

Brick does not evaluate conditions, so only the first branch is treated
as live: declarations and prototypes inside `#elif`/`#else` stay in the
graph (marked `source.preprocessor-branch: alternative`) but are not
definitions, declarations or classes for call resolution. Branches are
tracked textually from the directive lines, because conditions like
`__has_include(<x>)` leave tree-sitter-cpp with an error-recovered tree
that has no `preproc_else` node to find. Include-guard (`#ifndef`) code
is a first branch and still resolves.

Test: cpp_xtu::preprocessor_alternative_branches_are_not_call_targets
fails before this change (spdlog::info resolves into the #elif stub).
trvon 2026-09-24 19:58:55 -06:00
Signed by: trevon
GPG key ID: 38686EA797D7A8A7

View file

@ -616,6 +616,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));
}
@ -712,6 +718,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
{

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,6 +132,56 @@ 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 {
"cpp" => (tree_sitter_cpp::LANGUAGE, "cpp:0.23.4"),
@ -144,6 +197,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())
@ -304,7 +362,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,6 +385,7 @@ 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),
@ -440,6 +503,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) {

View file

@ -356,3 +356,53 @@ fn two_using_directives_naming_the_same_function_are_ambiguous() {
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")
);
}