use crate::dual_branch::{PredictionReason, PredictionReasonKind};
use crate::graph::store_models::NodeKind;
use crate::graph::GraphQuery;
use crate::models::Finding;
fn node_kind_label(kind: NodeKind) -> &'static str {
match kind {
NodeKind::File => "File",
NodeKind::Function => "Function",
NodeKind::Class => "Class",
NodeKind::Module => "Module",
NodeKind::Variable => "Variable",
NodeKind::Commit => "Commit",
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct GraphEnrichmentStats {
pub examined: usize,
pub enclosing_scope_hits: usize,
pub file_node_hits: usize,
pub import_reasons_attached: usize,
}
pub fn enrich_graph_evidence(findings: &mut [Finding], graph: &dyn GraphQuery) {
let mut stats = GraphEnrichmentStats {
examined: findings.len(),
..Default::default()
};
for finding in findings.iter_mut() {
enrich_one(finding, graph, &mut stats);
}
if stats.examined > 0 {
tracing::debug!(
"Graph evidence enrichment: {} findings examined, \
{} got EnclosingScope, {} resolved to a file node, \
{} ImportPresence reasons attached",
stats.examined,
stats.enclosing_scope_hits,
stats.file_node_hits,
stats.import_reasons_attached,
);
}
}
fn enrich_one(finding: &mut Finding, graph: &dyn GraphQuery, stats: &mut GraphEnrichmentStats) {
let Some(file_path_buf) = finding.affected_files.first() else {
return;
};
let file_path = file_path_buf.to_string_lossy();
let file_path_str = file_path.as_ref();
if let Some(line) = finding.line_start {
if let Some(fn_idx) = graph.function_at_idx(file_path_str, line) {
if let Some(node) = graph.node_idx(fn_idx) {
let qn = graph.interner().resolve(node.qualified_name).to_string();
let scope_kind = node_kind_label(node.kind).to_string();
let note = format!(
"inside {} {} (lines {}-{})",
scope_kind, qn, node.line_start, node.line_end
);
finding.prediction_reasons.push(PredictionReason {
kind: PredictionReasonKind::EnclosingScope {
scope_kind,
name: qn,
},
weight: 0.0,
note,
});
stats.enclosing_scope_hits += 1;
}
}
}
if let Some((file_idx, _)) = graph.node_by_name_idx(file_path_str) {
stats.file_node_hits += 1;
let mut import_names: Vec<String> = graph
.importees_idx(file_idx)
.iter()
.filter_map(|&idx| graph.node_idx(idx))
.map(|node| graph.interner().resolve(node.qualified_name).to_string())
.collect();
import_names.sort_unstable();
for module in import_names {
let note = format!("file imports `{}`", module);
finding.prediction_reasons.push(PredictionReason {
kind: PredictionReasonKind::ImportPresence { module },
weight: 0.0,
note,
});
stats.import_reasons_attached += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::builder::GraphBuilder;
use crate::graph::store_models::{CodeEdge, NodeKind};
use crate::graph::CodeNode;
use crate::models::Severity;
use std::path::PathBuf;
fn build_test_graph() -> crate::graph::CodeGraph {
let mut b = GraphBuilder::new();
let py = b.interner().intern("python");
let empty = b.interner().empty_key();
let file_qn = "src/foo.py";
let file_qn_key = b.interner().intern(file_qn);
b.add_node(CodeNode {
kind: NodeKind::File,
name: file_qn_key,
qualified_name: file_qn_key,
file_path: file_qn_key,
language: py,
line_start: 1,
line_end: 100,
complexity: 0,
param_count: 0,
method_count: 0,
field_count: 0,
max_nesting: 0,
return_count: 0,
commit_count: 0,
flags: 0,
});
let fn_qn = "src/foo.py::do_thing";
let fn_qn_key = b.interner().intern(fn_qn);
let fn_name_key = b.interner().intern("do_thing");
b.add_node(CodeNode {
kind: NodeKind::Function,
name: fn_name_key,
qualified_name: fn_qn_key,
file_path: file_qn_key,
language: py,
line_start: 10,
line_end: 20,
complexity: 5,
param_count: 2,
method_count: 0,
field_count: 0,
max_nesting: 0,
return_count: 0,
commit_count: 0,
flags: 0,
});
let imp_qn = "requests";
let imp_qn_key = b.interner().intern(imp_qn);
b.add_node(CodeNode {
kind: NodeKind::Module,
name: imp_qn_key,
qualified_name: imp_qn_key,
file_path: empty,
language: empty,
line_start: 0,
line_end: 0,
complexity: 0,
param_count: 0,
method_count: 0,
field_count: 0,
max_nesting: 0,
return_count: 0,
commit_count: 0,
flags: 0,
});
b.add_edge_by_name(file_qn, fn_qn, CodeEdge::contains());
b.add_edge_by_name(file_qn, imp_qn, CodeEdge::imports());
b.freeze()
}
fn make_finding_at(file: &str, line: u32) -> Finding {
Finding {
id: "f".into(),
detector: "TestDetector".into(),
severity: Severity::Medium,
affected_files: vec![PathBuf::from(file)],
line_start: Some(line),
..Default::default()
}
}
#[test]
fn enclosing_scope_attached_when_finding_inside_function() {
let graph = build_test_graph();
let mut findings = vec![make_finding_at("src/foo.py", 15)];
enrich_graph_evidence(&mut findings, &graph);
let has_enclosing = findings[0].prediction_reasons.iter().any(|r| {
matches!(
&r.kind,
PredictionReasonKind::EnclosingScope { name, .. } if name == "src/foo.py::do_thing"
)
});
assert!(
has_enclosing,
"finding at src/foo.py:15 must get EnclosingScope=do_thing; got reasons: {:?}",
findings[0].prediction_reasons,
);
}
#[test]
fn no_enclosing_scope_when_line_outside_any_function() {
let graph = build_test_graph();
let mut findings = vec![make_finding_at("src/foo.py", 99)];
enrich_graph_evidence(&mut findings, &graph);
let has_enclosing = findings[0]
.prediction_reasons
.iter()
.any(|r| matches!(r.kind, PredictionReasonKind::EnclosingScope { .. }));
assert!(
!has_enclosing,
"line outside any function must not get EnclosingScope; got reasons: {:?}",
findings[0].prediction_reasons,
);
}
#[test]
fn no_enclosing_scope_when_finding_has_no_line_start() {
let graph = build_test_graph();
let mut f = make_finding_at("src/foo.py", 0);
f.line_start = None;
let mut findings = vec![f];
enrich_graph_evidence(&mut findings, &graph);
let has_enclosing = findings[0]
.prediction_reasons
.iter()
.any(|r| matches!(r.kind, PredictionReasonKind::EnclosingScope { .. }));
assert!(
!has_enclosing,
"finding without line_start must not get EnclosingScope (no anchor to walk from)",
);
}
#[test]
fn import_presence_attached_for_each_imported_module() {
let graph = build_test_graph();
let mut findings = vec![make_finding_at("src/foo.py", 15)];
enrich_graph_evidence(&mut findings, &graph);
let has_import = findings[0].prediction_reasons.iter().any(|r| {
matches!(
&r.kind,
PredictionReasonKind::ImportPresence { module } if module == "requests"
)
});
assert!(
has_import,
"src/foo.py imports `requests`; expected ImportPresence reason. Got: {:?}",
findings[0].prediction_reasons,
);
}
#[test]
fn finding_with_no_affected_files_is_left_untouched() {
let graph = build_test_graph();
let mut f = make_finding_at("src/foo.py", 15);
f.affected_files.clear();
let before = f.prediction_reasons.len();
let mut findings = vec![f];
enrich_graph_evidence(&mut findings, &graph);
assert_eq!(
findings[0].prediction_reasons.len(),
before,
"finding with no affected_files must be left untouched",
);
}
#[test]
fn file_not_in_graph_skips_silently() {
let graph = build_test_graph();
let mut findings = vec![make_finding_at("src/not_in_graph.py", 5)];
let before = findings[0].prediction_reasons.len();
enrich_graph_evidence(&mut findings, &graph);
assert_eq!(
findings[0].prediction_reasons.len(),
before,
"file not in graph must produce no reasons (silent skip); got: {:?}",
findings[0].prediction_reasons,
);
}
#[test]
fn weight_is_zero_for_all_phase_1c_reasons() {
let graph = build_test_graph();
let mut findings = vec![make_finding_at("src/foo.py", 15)];
enrich_graph_evidence(&mut findings, &graph);
for reason in &findings[0].prediction_reasons {
assert_eq!(
reason.weight, 0.0,
"Phase 1c reason {:?} must have weight 0.0; got {}",
reason.kind, reason.weight,
);
}
}
#[test]
fn enrich_is_additive_preserves_existing_reasons() {
let graph = build_test_graph();
let mut f = make_finding_at("src/foo.py", 15);
let pre_existing = PredictionReason {
kind: PredictionReasonKind::Custom {
description: "from-earlier-pass".into(),
},
weight: 0.5,
note: "must survive".into(),
};
f.prediction_reasons.push(pre_existing.clone());
let mut findings = vec![f];
enrich_graph_evidence(&mut findings, &graph);
assert_eq!(
findings[0].prediction_reasons[0], pre_existing,
"earlier reasons must be preserved; got: {:?}",
findings[0].prediction_reasons,
);
assert!(
findings[0].prediction_reasons.len() > 1,
"graph enrichment must add at least one reason on top of the existing",
);
}
fn build_graph_with_unsorted_imports() -> crate::graph::CodeGraph {
let mut b = GraphBuilder::new();
let py = b.interner().intern("python");
let empty = b.interner().empty_key();
let file_qn = "src/bar.py";
let file_qn_key = b.interner().intern(file_qn);
b.add_node(CodeNode {
kind: NodeKind::File,
name: file_qn_key,
qualified_name: file_qn_key,
file_path: file_qn_key,
language: py,
line_start: 1,
line_end: 50,
complexity: 0,
param_count: 0,
method_count: 0,
field_count: 0,
max_nesting: 0,
return_count: 0,
commit_count: 0,
flags: 0,
});
for name in ["zebra", "alpha"] {
let key = b.interner().intern(name);
b.add_node(CodeNode {
kind: NodeKind::Module,
name: key,
qualified_name: key,
file_path: empty,
language: empty,
line_start: 0,
line_end: 0,
complexity: 0,
param_count: 0,
method_count: 0,
field_count: 0,
max_nesting: 0,
return_count: 0,
commit_count: 0,
flags: 0,
});
b.add_edge_by_name(file_qn, name, CodeEdge::imports());
}
b.freeze()
}
#[test]
fn import_presence_emitted_in_alphabetical_order() {
let graph = build_graph_with_unsorted_imports();
let mut findings = vec![make_finding_at("src/bar.py", 1)];
enrich_graph_evidence(&mut findings, &graph);
let import_modules: Vec<&str> = findings[0]
.prediction_reasons
.iter()
.filter_map(|r| match &r.kind {
PredictionReasonKind::ImportPresence { module } => Some(module.as_str()),
_ => None,
})
.collect();
assert_eq!(
import_modules,
vec!["alpha", "zebra"],
"ImportPresence reasons must be alphabetically sorted; \
got {:?}",
import_modules,
);
}
#[test]
fn enclosing_scope_kind_label_is_stable_string() {
let graph = build_test_graph();
let mut findings = vec![make_finding_at("src/foo.py", 15)];
enrich_graph_evidence(&mut findings, &graph);
let scope_kind = findings[0]
.prediction_reasons
.iter()
.find_map(|r| match &r.kind {
PredictionReasonKind::EnclosingScope { scope_kind, .. } => Some(scope_kind.clone()),
_ => None,
})
.expect("EnclosingScope reason should be present");
assert_eq!(
scope_kind, "Function",
"scope_kind for a function-typed node must be the stable string \
\"Function\" (decoupled from NodeKind's Debug derive); got {:?}",
scope_kind,
);
}
#[test]
fn node_kind_label_covers_all_variants() {
for kind in [
NodeKind::File,
NodeKind::Function,
NodeKind::Class,
NodeKind::Module,
NodeKind::Variable,
NodeKind::Commit,
] {
let label = node_kind_label(kind);
assert!(
!label.is_empty(),
"node_kind_label({:?}) must return non-empty string; got {:?}",
kind,
label,
);
}
}
}