use crate::interop::InteropEdge;
use crate::rust_resolve::RustUseMap;
use crate::summary::{CalleeQuery, CalleeResolution, GlobalSummaries};
use crate::symbol::{FuncKey, Lang};
use petgraph::graph::NodeIndex;
use petgraph::prelude::*;
use smallvec::SmallVec;
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct CallEdge {
#[allow(dead_code)] pub call_site: String,
}
#[derive(Debug, Clone)]
pub struct UnresolvedCallee {
pub caller: FuncKey,
pub callee_name: String,
}
#[derive(Debug, Clone)]
pub struct AmbiguousCallee {
pub caller: FuncKey,
pub callee_name: String,
pub candidates: Vec<FuncKey>,
}
pub struct CallGraph {
pub graph: DiGraph<FuncKey, CallEdge>,
#[allow(dead_code)] pub index: HashMap<FuncKey, NodeIndex>,
pub unresolved_not_found: Vec<UnresolvedCallee>,
pub unresolved_ambiguous: Vec<AmbiguousCallee>,
}
pub struct CallGraphAnalysis {
pub sccs: Vec<Vec<NodeIndex>>,
#[allow(dead_code)] pub node_to_scc: HashMap<NodeIndex, usize>,
pub topo_scc_callee_first: Vec<usize>,
}
pub(crate) fn normalize_callee_name(raw: &str) -> &str {
if let Some(pos) = raw.rfind("::") {
let before_last = &raw[..pos];
if let Some(pos2) = before_last.rfind("::") {
return &raw[pos2 + 2..];
}
return raw;
}
if let Some(pos) = raw.rfind('.') {
let before_last = &raw[..pos];
if let Some(pos2) = before_last.rfind('.') {
return &raw[pos2 + 1..];
}
return raw;
}
raw
}
pub(crate) fn callee_leaf_name(raw: &str) -> &str {
let after_colons = raw.rsplit("::").next().unwrap_or(raw);
after_colons.rsplit('.').next().unwrap_or(after_colons)
}
pub(crate) fn callee_container_hint(raw: &str) -> &str {
if let Some(pos) = raw.rfind("::") {
let prefix = &raw[..pos];
return prefix.rsplit("::").next().unwrap_or(prefix);
}
if let Some(pos) = raw.rfind('.') {
let prefix = &raw[..pos];
return prefix.rsplit('.').next().unwrap_or(prefix);
}
""
}
#[derive(Debug, Default, Clone)]
pub struct ClassMethodIndex {
by_container: HashMap<(Lang, String, String), SmallVec<[FuncKey; 2]>>,
by_name: HashMap<(Lang, String), SmallVec<[FuncKey; 4]>>,
}
impl ClassMethodIndex {
pub fn build(summaries: &GlobalSummaries) -> Self {
let mut by_container: HashMap<(Lang, String, String), SmallVec<[FuncKey; 2]>> =
HashMap::new();
let mut by_name: HashMap<(Lang, String), SmallVec<[FuncKey; 4]>> = HashMap::new();
for (key, _) in summaries.iter() {
let name_key = (key.lang, key.name.clone());
by_name.entry(name_key).or_default().push(key.clone());
if !key.container.is_empty() {
let cont_key = (key.lang, key.container.clone(), key.name.clone());
by_container.entry(cont_key).or_default().push(key.clone());
}
}
ClassMethodIndex {
by_container,
by_name,
}
}
pub fn resolve(&self, lang: Lang, container: Option<&str>, method: &str) -> &[FuncKey] {
match container {
Some(c) if !c.is_empty() => self
.by_container
.get(&(lang, c.to_string(), method.to_string()))
.map(|v| v.as_slice())
.unwrap_or_default(),
_ => self
.by_name
.get(&(lang, method.to_string()))
.map(|v| v.as_slice())
.unwrap_or_default(),
}
}
#[allow(dead_code)]
pub fn container_keys_len(&self) -> usize {
self.by_container.len()
}
#[allow(dead_code)]
pub fn name_keys_len(&self) -> usize {
self.by_name.len()
}
}
#[derive(Debug, Default, Clone)]
pub struct TypeHierarchyIndex {
by_super: HashMap<(Lang, String), SmallVec<[String; 4]>>,
#[allow(dead_code)]
by_sub: HashMap<(Lang, String), SmallVec<[String; 2]>>,
}
impl TypeHierarchyIndex {
pub fn build(summaries: &GlobalSummaries) -> Self {
let mut by_super: HashMap<(Lang, String), SmallVec<[String; 4]>> = HashMap::new();
let mut by_sub: HashMap<(Lang, String), SmallVec<[String; 2]>> = HashMap::new();
for (key, summary) in summaries.iter() {
let lang = key.lang;
for (sub, sup) in &summary.hierarchy_edges {
if sub.is_empty() || sup.is_empty() {
continue;
}
let subs = by_super.entry((lang, sup.clone())).or_default();
if !subs.iter().any(|s| s == sub) {
subs.push(sub.clone());
}
let sups = by_sub.entry((lang, sub.clone())).or_default();
if !sups.iter().any(|s| s == sup) {
sups.push(sup.clone());
}
}
}
TypeHierarchyIndex { by_super, by_sub }
}
pub fn subs_of(&self, lang: Lang, super_type: &str) -> &[String] {
self.by_super
.get(&(lang, super_type.to_string()))
.map(|v| v.as_slice())
.unwrap_or_default()
}
#[allow(dead_code)]
pub fn supers_of(&self, lang: Lang, sub_type: &str) -> &[String] {
self.by_sub
.get(&(lang, sub_type.to_string()))
.map(|v| v.as_slice())
.unwrap_or_default()
}
#[allow(dead_code)]
pub fn super_keys_len(&self) -> usize {
self.by_super.len()
}
pub fn resolve_with_hierarchy(
&self,
method_index: &ClassMethodIndex,
lang: Lang,
container: Option<&str>,
method: &str,
) -> Vec<FuncKey> {
let Some(c) = container.filter(|s| !s.is_empty()) else {
return method_index.resolve(lang, None, method).to_vec();
};
let mut out: Vec<FuncKey> = Vec::new();
let push_unique = |dst: &mut Vec<FuncKey>, src: &[FuncKey]| {
for k in src {
if !dst.iter().any(|e| e == k) {
dst.push(k.clone());
}
}
};
push_unique(&mut out, method_index.resolve(lang, Some(c), method));
for sub in self.subs_of(lang, c) {
push_unique(
&mut out,
method_index.resolve(lang, Some(sub.as_str()), method),
);
}
out
}
}
pub fn build_call_graph(summaries: &GlobalSummaries, interop_edges: &[InteropEdge]) -> CallGraph {
let mut graph = DiGraph::new();
let mut index = HashMap::new();
for (key, _) in summaries.iter() {
let idx = graph.add_node(key.clone());
index.insert(key.clone(), idx);
}
let method_index = ClassMethodIndex::build(summaries);
let hierarchy = TypeHierarchyIndex::build(summaries);
let mut unresolved_not_found = Vec::new();
let mut unresolved_ambiguous = Vec::new();
for (caller_key, summary) in summaries.iter() {
let caller_node = index[caller_key];
let rust_use_map: Option<RustUseMap> = if caller_key.lang == Lang::Rust {
match (&summary.rust_use_map, &summary.rust_wildcards) {
(None, None) => None,
(a, w) => Some(RustUseMap {
aliases: a.clone().unwrap_or_default(),
wildcards: w.clone().unwrap_or_default(),
}),
}
} else {
None
};
let typed_receivers: HashMap<u32, &str> = summaries
.get_ssa(caller_key)
.map(|ssa| {
ssa.typed_call_receivers
.iter()
.map(|(ord, c)| (*ord, c.as_str()))
.collect()
})
.unwrap_or_default();
for site in &summary.callees {
let raw_callee = site.name.as_str();
let leaf = callee_leaf_name(raw_callee);
let qualified = normalize_callee_name(raw_callee);
let arity_hint: Option<usize> = site.arity;
let typed_container: Option<&str> = if site.receiver.is_some() {
typed_receivers.get(&site.ordinal).copied()
} else {
None
};
if let Some(container) = typed_container {
let widened: Vec<FuncKey> = hierarchy.resolve_with_hierarchy(
&method_index,
caller_key.lang,
Some(container),
leaf,
);
let arity_filtered: Vec<&FuncKey> = widened
.iter()
.filter(|k| match arity_hint {
Some(a) => k.arity == Some(a),
None => true,
})
.collect();
if arity_filtered.len() == 1 {
if let Some(&target_node) = index.get(arity_filtered[0]) {
graph.add_edge(
caller_node,
target_node,
CallEdge {
call_site: raw_callee.to_string(),
},
);
}
continue;
}
let direct_matches: Vec<&FuncKey> = method_index
.resolve(caller_key.lang, Some(container), leaf)
.iter()
.filter(|k| match arity_hint {
Some(a) => k.arity == Some(a),
None => true,
})
.collect();
if !arity_filtered.is_empty() && arity_filtered.len() > direct_matches.len() {
for &target_key in &arity_filtered {
if let Some(&target_node) = index.get(target_key) {
graph.add_edge(
caller_node,
target_node,
CallEdge {
call_site: raw_callee.to_string(),
},
);
}
}
continue;
}
if !arity_filtered.is_empty() {
let caller_container: Option<&str> = if caller_key.container.is_empty() {
None
} else {
Some(caller_key.container.as_str())
};
let resolution = summaries.resolve_callee(&CalleeQuery {
name: leaf,
caller_lang: caller_key.lang,
caller_namespace: &caller_key.namespace,
caller_container,
receiver_type: Some(container),
namespace_qualifier: site.qualifier.as_deref(),
receiver_var: site.receiver.as_deref(),
arity: arity_hint,
});
if let CalleeResolution::Resolved(key) = resolution
&& let Some(&target_node) = index.get(&key)
{
graph.add_edge(
caller_node,
target_node,
CallEdge {
call_site: raw_callee.to_string(),
},
);
continue;
}
}
}
let use_rust_path = caller_key.lang == Lang::Rust && site.receiver.is_none();
let resolution = if use_rust_path {
summaries.resolve_callee_key_rust(
leaf,
site.qualifier.as_deref(),
arity_hint,
&caller_key.namespace,
rust_use_map.as_ref(),
)
} else {
let parsed_container = {
let raw = callee_container_hint(raw_callee);
if raw.is_empty() {
None
} else {
Some(raw.to_string())
}
};
let namespace_qualifier = site.qualifier.clone().or_else(|| {
if site.receiver.is_none() {
parsed_container.clone()
} else {
None
}
});
let receiver_var = site.receiver.clone();
let caller_container: Option<&str> = if caller_key.container.is_empty() {
None
} else {
Some(caller_key.container.as_str())
};
summaries.resolve_callee(&CalleeQuery {
name: leaf,
caller_lang: caller_key.lang,
caller_namespace: &caller_key.namespace,
caller_container,
receiver_type: None,
namespace_qualifier: namespace_qualifier.as_deref(),
receiver_var: receiver_var.as_deref(),
arity: arity_hint,
})
};
match resolution {
CalleeResolution::Resolved(target_key) => {
if let Some(&target_node) = index.get(&target_key) {
graph.add_edge(
caller_node,
target_node,
CallEdge {
call_site: raw_callee.to_string(),
},
);
}
}
CalleeResolution::NotFound => {
if let Some(target_key) =
resolve_via_interop(raw_callee, caller_key, interop_edges)
&& let Some(&target_node) = index.get(&target_key)
{
graph.add_edge(
caller_node,
target_node,
CallEdge {
call_site: raw_callee.to_string(),
},
);
continue;
}
unresolved_not_found.push(UnresolvedCallee {
caller: caller_key.clone(),
callee_name: raw_callee.to_string(),
});
}
CalleeResolution::Ambiguous(candidates) => {
if qualified != leaf {
let qualifier =
&qualified[..qualified.len() - leaf.len()].trim_end_matches([':', '.']);
let narrowed: Vec<_> = candidates
.iter()
.filter(|k| k.namespace.contains(qualifier))
.cloned()
.collect();
if narrowed.len() == 1
&& let Some(&target_node) = index.get(&narrowed[0])
{
graph.add_edge(
caller_node,
target_node,
CallEdge {
call_site: raw_callee.to_string(),
},
);
continue;
}
}
unresolved_ambiguous.push(AmbiguousCallee {
caller: caller_key.clone(),
callee_name: raw_callee.to_string(),
candidates,
});
}
}
}
}
CallGraph {
graph,
index,
unresolved_not_found,
unresolved_ambiguous,
}
}
fn resolve_via_interop(
raw_callee: &str,
caller_key: &FuncKey,
interop_edges: &[InteropEdge],
) -> Option<FuncKey> {
for edge in interop_edges {
if edge.from.caller_lang == caller_key.lang
&& edge.from.caller_namespace == caller_key.namespace
&& edge.from.callee_symbol == raw_callee
&& (edge.from.caller_func.is_empty() || edge.from.caller_func == caller_key.name)
{
return Some(edge.to.clone());
}
}
None
}
pub fn analyse(cg: &CallGraph) -> CallGraphAnalysis {
let sccs = petgraph::algo::tarjan_scc(&cg.graph);
let mut node_to_scc = HashMap::with_capacity(cg.graph.node_count());
for (scc_idx, scc) in sccs.iter().enumerate() {
for &node in scc {
node_to_scc.insert(node, scc_idx);
}
}
let topo_scc_callee_first: Vec<usize> = (0..sccs.len()).collect();
CallGraphAnalysis {
sccs,
node_to_scc,
topo_scc_callee_first,
}
}
pub struct FileBatch<'a> {
pub files: Vec<&'a PathBuf>,
pub has_mutual_recursion: bool,
pub cross_file: bool,
}
pub fn callers_of(cg: &CallGraph, callee: &FuncKey) -> Vec<FuncKey> {
let Some(&node) = cg.index.get(callee) else {
return Vec::new();
};
cg.graph
.neighbors_directed(node, petgraph::Direction::Incoming)
.map(|caller_node| cg.graph[caller_node].clone())
.collect()
}
pub fn namespaces_for_callers(
cg: &CallGraph,
changed: &std::collections::HashSet<FuncKey>,
) -> std::collections::HashSet<String> {
let mut result = std::collections::HashSet::new();
for key in changed {
result.insert(key.namespace.clone());
for caller in callers_of(cg, key) {
result.insert(caller.namespace);
}
}
result
}
pub fn scc_spans_files(cg: &CallGraph, scc: &[NodeIndex]) -> bool {
if scc.len() < 2 {
return false;
}
let mut iter = scc.iter();
let first_ns = iter.next().map(|n| cg.graph[*n].namespace.as_str());
let Some(first_ns) = first_ns else {
return false;
};
iter.any(|n| cg.graph[*n].namespace.as_str() != first_ns)
}
pub fn scc_file_batches_with_metadata<'a>(
cg: &CallGraph,
analysis: &CallGraphAnalysis,
all_files: &'a [PathBuf],
root: &Path,
) -> (Vec<FileBatch<'a>>, Vec<&'a PathBuf>) {
let root_str = root.to_string_lossy();
let mut rel_to_path: HashMap<String, &'a PathBuf> = HashMap::with_capacity(all_files.len());
for p in all_files {
let abs = p.to_string_lossy();
let rel = crate::symbol::normalize_namespace(&abs, Some(&root_str));
rel_to_path.insert(rel, p);
}
let mut file_topo: HashMap<&str, (usize, bool, bool)> = HashMap::new();
for (topo_pos, &scc_idx) in analysis.topo_scc_callee_first.iter().enumerate() {
let scc_recursive = analysis.sccs[scc_idx].len() > 1;
let scc_cross_file = scc_spans_files(cg, &analysis.sccs[scc_idx]);
for &node in &analysis.sccs[scc_idx] {
let ns = &cg.graph[node].namespace;
file_topo
.entry(ns.as_str())
.and_modify(|(min_pos, recursive, cross_file)| {
if topo_pos < *min_pos {
*min_pos = topo_pos;
}
*recursive |= scc_recursive;
*cross_file |= scc_cross_file;
})
.or_insert((topo_pos, scc_recursive, scc_cross_file));
}
}
let mut topo_groups: BTreeMap<usize, (Vec<&'a PathBuf>, bool, bool)> = BTreeMap::new();
let mut orphans: Vec<&'a PathBuf> = Vec::new();
for p in all_files {
let abs = p.to_string_lossy();
let rel = crate::symbol::normalize_namespace(&abs, Some(&root_str));
if let Some(&(topo_pos, recursive, cross_file)) = file_topo.get(rel.as_str()) {
let entry = topo_groups
.entry(topo_pos)
.or_insert_with(|| (Vec::new(), false, false));
entry.0.push(p);
entry.1 |= recursive;
entry.2 |= cross_file;
} else {
orphans.push(p);
}
}
let batches: Vec<FileBatch<'a>> = topo_groups
.into_values()
.map(|(files, has_mutual_recursion, cross_file)| FileBatch {
files,
has_mutual_recursion,
cross_file,
})
.collect();
(batches, orphans)
}
#[allow(dead_code)] pub fn scc_file_batches<'a>(
cg: &CallGraph,
analysis: &CallGraphAnalysis,
all_files: &'a [PathBuf],
root: &Path,
) -> (Vec<Vec<&'a PathBuf>>, Vec<&'a PathBuf>) {
let root_str = root.to_string_lossy();
let mut rel_to_path: HashMap<String, &'a PathBuf> = HashMap::with_capacity(all_files.len());
for p in all_files {
let abs = p.to_string_lossy();
let rel = crate::symbol::normalize_namespace(&abs, Some(&root_str));
rel_to_path.insert(rel, p);
}
let mut file_min_topo: HashMap<&str, usize> = HashMap::new();
for (topo_pos, &scc_idx) in analysis.topo_scc_callee_first.iter().enumerate() {
for &node in &analysis.sccs[scc_idx] {
let ns = &cg.graph[node].namespace;
file_min_topo.entry(ns.as_str()).or_insert(topo_pos);
}
}
let mut topo_groups: BTreeMap<usize, Vec<&'a PathBuf>> = BTreeMap::new();
let mut orphans: Vec<&'a PathBuf> = Vec::new();
for p in all_files {
let abs = p.to_string_lossy();
let rel = crate::symbol::normalize_namespace(&abs, Some(&root_str));
if let Some(&topo_pos) = file_min_topo.get(rel.as_str()) {
topo_groups.entry(topo_pos).or_default().push(p);
} else {
orphans.push(p);
}
}
let batches: Vec<Vec<&'a PathBuf>> = topo_groups.into_values().collect();
(batches, orphans)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::interop::CallSiteKey;
use crate::summary::{CalleeSite, FuncSummary, merge_summaries};
use crate::symbol::Lang;
fn make_summary(
name: &str,
file_path: &str,
lang: &str,
param_count: usize,
callees: Vec<&str>,
) -> FuncSummary {
FuncSummary {
name: name.into(),
file_path: file_path.into(),
lang: lang.into(),
param_count,
param_names: vec![],
source_caps: 0,
sanitizer_caps: 0,
sink_caps: 0,
propagating_params: vec![],
propagates_taint: false,
tainted_sink_params: vec![],
callees: callees
.into_iter()
.map(crate::summary::CalleeSite::bare)
.collect(),
..Default::default()
}
}
#[test]
fn normalize_callee_two_segment() {
assert_eq!(normalize_callee_name("env::var"), "env::var");
assert_eq!(normalize_callee_name("std::env::var"), "env::var");
assert_eq!(
normalize_callee_name("std::process::Command"),
"process::Command"
);
assert_eq!(normalize_callee_name("a::b::c"), "b::c");
assert_eq!(normalize_callee_name("obj.method"), "obj.method");
assert_eq!(normalize_callee_name("pkg.mod.func"), "mod.func");
assert_eq!(
normalize_callee_name("http_client.send"),
"http_client.send"
);
assert_eq!(normalize_callee_name("send"), "send");
assert_eq!(normalize_callee_name("foo"), "foo");
assert_eq!(normalize_callee_name(""), "");
}
#[test]
fn callee_leaf_basic() {
assert_eq!(callee_leaf_name("env::var"), "var");
assert_eq!(callee_leaf_name("std::process::Command"), "Command");
assert_eq!(callee_leaf_name("obj.method"), "method");
assert_eq!(callee_leaf_name("pkg.mod.func"), "func");
assert_eq!(callee_leaf_name("foo"), "foo");
assert_eq!(callee_leaf_name(""), "");
}
#[test]
fn same_name_different_rust_modules() {
let helper_a = make_summary("helper", "src/a.rs", "rust", 0, vec![]);
let helper_b = make_summary("helper", "src/b.rs", "rust", 0, vec![]);
let caller = make_summary("caller", "src/a.rs", "rust", 0, vec!["helper"]);
let gs = merge_summaries(vec![helper_a, helper_b, caller], None);
let cg = build_call_graph(&gs, &[]);
assert_eq!(cg.graph.node_count(), 3);
let caller_key = FuncKey {
lang: Lang::Rust,
namespace: "src/a.rs".into(),
name: "caller".into(),
arity: Some(0),
..Default::default()
};
let helper_a_key = FuncKey {
lang: Lang::Rust,
namespace: "src/a.rs".into(),
name: "helper".into(),
arity: Some(0),
..Default::default()
};
let caller_node = cg.index[&caller_key];
let helper_a_node = cg.index[&helper_a_key];
let edges: Vec<_> = cg
.graph
.edges(caller_node)
.filter(|e| e.target() == helper_a_node)
.collect();
assert_eq!(edges.len(), 1);
assert!(cg.unresolved_not_found.is_empty());
assert!(cg.unresolved_ambiguous.is_empty());
}
#[test]
fn same_name_python_and_rust() {
let py_foo = make_summary("foo", "handler.py", "python", 0, vec![]);
let rs_foo = make_summary("foo", "handler.rs", "rust", 0, vec![]);
let py_caller = make_summary("main", "app.py", "python", 0, vec!["foo"]);
let gs = merge_summaries(vec![py_foo, rs_foo, py_caller], None);
let cg = build_call_graph(&gs, &[]);
assert_eq!(cg.graph.node_count(), 3);
let py_foo_key = FuncKey {
lang: Lang::Python,
namespace: "handler.py".into(),
name: "foo".into(),
arity: Some(0),
..Default::default()
};
let caller_key = FuncKey {
lang: Lang::Python,
namespace: "app.py".into(),
name: "main".into(),
arity: Some(0),
..Default::default()
};
let caller_node = cg.index[&caller_key];
let py_foo_node = cg.index[&py_foo_key];
let edges: Vec<_> = cg.graph.edges(caller_node).collect();
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].target(), py_foo_node);
}
#[test]
fn arity_differences_separate_nodes() {
let helper1 = make_summary("helper", "lib.rs", "rust", 1, vec![]);
let helper2 = make_summary("helper", "lib.rs", "rust", 2, vec![]);
let gs = merge_summaries(vec![helper1, helper2], None);
let cg = build_call_graph(&gs, &[]);
assert_eq!(cg.graph.node_count(), 2);
let key1 = FuncKey {
lang: Lang::Rust,
namespace: "lib.rs".into(),
name: "helper".into(),
arity: Some(1),
..Default::default()
};
let key2 = FuncKey {
lang: Lang::Rust,
namespace: "lib.rs".into(),
name: "helper".into(),
arity: Some(2),
..Default::default()
};
assert!(cg.index.contains_key(&key1));
assert!(cg.index.contains_key(&key2));
}
#[test]
fn recursive_scc_detection() {
let a = make_summary("a", "lib.rs", "rust", 0, vec!["b"]);
let b = make_summary("b", "lib.rs", "rust", 0, vec!["a"]);
let gs = merge_summaries(vec![a, b], None);
let cg = build_call_graph(&gs, &[]);
assert_eq!(cg.graph.edge_count(), 2);
let analysis = analyse(&cg);
let key_a = FuncKey {
lang: Lang::Rust,
namespace: "lib.rs".into(),
name: "a".into(),
arity: Some(0),
..Default::default()
};
let key_b = FuncKey {
lang: Lang::Rust,
namespace: "lib.rs".into(),
name: "b".into(),
arity: Some(0),
..Default::default()
};
let scc_a = analysis.node_to_scc[&cg.index[&key_a]];
let scc_b = analysis.node_to_scc[&cg.index[&key_b]];
assert_eq!(scc_a, scc_b);
assert_eq!(analysis.sccs[scc_a].len(), 2);
}
#[test]
fn unresolved_callee_recorded_as_not_found() {
let caller = make_summary("caller", "lib.rs", "rust", 0, vec!["nonexistent"]);
let gs = merge_summaries(vec![caller], None);
let cg = build_call_graph(&gs, &[]);
assert_eq!(cg.graph.edge_count(), 0);
assert_eq!(cg.unresolved_not_found.len(), 1);
assert_eq!(cg.unresolved_not_found[0].callee_name, "nonexistent");
assert!(cg.unresolved_ambiguous.is_empty());
}
#[test]
fn ambiguous_callee_recorded() {
let helper_a = make_summary("helper", "a.rs", "rust", 0, vec![]);
let helper_b = make_summary("helper", "b.rs", "rust", 0, vec![]);
let caller = make_summary("caller", "c.rs", "rust", 0, vec!["helper"]);
let gs = merge_summaries(vec![helper_a, helper_b, caller], None);
let cg = build_call_graph(&gs, &[]);
assert_eq!(cg.graph.edge_count(), 0); assert!(cg.unresolved_not_found.is_empty());
assert_eq!(cg.unresolved_ambiguous.len(), 1);
assert_eq!(cg.unresolved_ambiguous[0].callee_name, "helper");
assert_eq!(cg.unresolved_ambiguous[0].candidates.len(), 2);
}
#[test]
fn diamond_topo_callee_first() {
let d = make_summary("d", "lib.rs", "rust", 0, vec![]);
let b = make_summary("b", "lib.rs", "rust", 0, vec!["d"]);
let c = make_summary("c", "lib.rs", "rust", 0, vec!["d"]);
let a = make_summary("a", "lib.rs", "rust", 0, vec!["b", "c"]);
let gs = merge_summaries(vec![a, b, c, d], None);
let cg = build_call_graph(&gs, &[]);
assert_eq!(cg.graph.node_count(), 4);
let analysis = analyse(&cg);
let key = |name: &str| FuncKey {
lang: Lang::Rust,
namespace: "lib.rs".into(),
name: name.into(),
arity: Some(0),
..Default::default()
};
let scc_of = |name: &str| analysis.node_to_scc[&cg.index[&key(name)]];
let topo_pos = |name: &str| {
analysis
.topo_scc_callee_first
.iter()
.position(|&s| s == scc_of(name))
.unwrap()
};
assert!(topo_pos("d") < topo_pos("b"));
assert!(topo_pos("d") < topo_pos("c"));
assert!(topo_pos("b") < topo_pos("a"));
assert!(topo_pos("c") < topo_pos("a"));
}
#[test]
fn interop_edge_resolution() {
let py_caller = make_summary("process", "handler.py", "python", 0, vec!["js_func"]);
let js_target = make_summary("js_func", "util.js", "javascript", 1, vec![]);
let gs = merge_summaries(vec![py_caller, js_target], None);
let interop = vec![InteropEdge {
from: CallSiteKey {
caller_lang: Lang::Python,
caller_namespace: "handler.py".into(),
caller_func: String::new(), callee_symbol: "js_func".into(),
ordinal: 0,
},
to: FuncKey {
lang: Lang::JavaScript,
namespace: "util.js".into(),
name: "js_func".into(),
arity: Some(1),
..Default::default()
},
}];
let cg = build_call_graph(&gs, &interop);
let caller_key = FuncKey {
lang: Lang::Python,
namespace: "handler.py".into(),
name: "process".into(),
arity: Some(0),
..Default::default()
};
let target_key = FuncKey {
lang: Lang::JavaScript,
namespace: "util.js".into(),
name: "js_func".into(),
arity: Some(1),
..Default::default()
};
let caller_node = cg.index[&caller_key];
let target_node = cg.index[&target_key];
let edges: Vec<_> = cg
.graph
.edges(caller_node)
.filter(|e| e.target() == target_node)
.collect();
assert_eq!(edges.len(), 1);
assert!(cg.unresolved_not_found.is_empty());
}
#[test]
fn namespace_normalization_consistency() {
let summary = FuncSummary {
name: "my_func".into(),
file_path: "/home/user/proj/src/lib.rs".into(),
lang: "rust".into(),
param_count: 0,
param_names: vec![],
source_caps: 0,
sanitizer_caps: 0,
sink_caps: 0,
propagating_params: vec![],
propagates_taint: false,
tainted_sink_params: vec![],
callees: vec![],
..Default::default()
};
let root = "/home/user/proj";
let key = summary.func_key(Some(root));
let expected_ns = crate::symbol::normalize_namespace(&summary.file_path, Some(root));
assert_eq!(key.namespace, expected_ns);
assert_eq!(key.namespace, "src/lib.rs");
}
#[test]
fn raw_call_site_preserved_on_edge() {
let source = make_summary("var", "util.rs", "rust", 0, vec![]);
let caller = make_summary("main", "util.rs", "rust", 0, vec!["env::var"]);
let gs = merge_summaries(vec![source, caller], None);
let cg = build_call_graph(&gs, &[]);
let caller_key = FuncKey {
lang: Lang::Rust,
namespace: "util.rs".into(),
name: "main".into(),
arity: Some(0),
..Default::default()
};
let caller_node = cg.index[&caller_key];
let edges: Vec<_> = cg.graph.edges(caller_node).collect();
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].weight().call_site, "env::var");
}
fn build_batches<'a>(
summaries: Vec<FuncSummary>,
all_files: &'a [PathBuf],
root: &Path,
) -> (Vec<Vec<&'a PathBuf>>, Vec<&'a PathBuf>) {
let gs = merge_summaries(summaries, Some(&root.to_string_lossy()));
let cg = build_call_graph(&gs, &[]);
let analysis = analyse(&cg);
scc_file_batches(&cg, &analysis, all_files, root)
}
#[test]
fn scc_file_batches_linear_chain() {
let root = Path::new("/proj");
let c = make_summary("c_fn", "/proj/c.rs", "rust", 0, vec![]);
let b = make_summary("b_fn", "/proj/b.rs", "rust", 0, vec!["c_fn"]);
let a = make_summary("a_fn", "/proj/a.rs", "rust", 0, vec!["b_fn"]);
let files: Vec<PathBuf> = vec![
PathBuf::from("/proj/a.rs"),
PathBuf::from("/proj/b.rs"),
PathBuf::from("/proj/c.rs"),
];
let (batches, orphans) = build_batches(vec![a, b, c], &files, root);
assert!(orphans.is_empty());
assert_eq!(batches.len(), 3, "3 files in a linear chain → 3 batches");
let batch_of = |name: &str| {
batches
.iter()
.position(|batch: &Vec<&PathBuf>| {
batch.iter().any(|p| p.to_str().unwrap().ends_with(name))
})
.unwrap()
};
assert!(batch_of("c.rs") < batch_of("b.rs"));
assert!(batch_of("b.rs") < batch_of("a.rs"));
}
#[test]
fn scc_file_batches_orphan_files() {
let root = Path::new("/proj");
let a = make_summary("a_fn", "/proj/a.rs", "rust", 0, vec![]);
let files: Vec<PathBuf> = vec![
PathBuf::from("/proj/a.rs"),
PathBuf::from("/proj/orphan.rs"),
];
let (batches, orphans) = build_batches(vec![a], &files, root);
assert_eq!(orphans.len(), 1);
assert!(orphans[0].to_str().unwrap().ends_with("orphan.rs"));
let total_in_batches: usize = batches.iter().map(|b: &Vec<&PathBuf>| b.len()).sum();
assert_eq!(total_in_batches, 1);
}
#[test]
fn scc_file_batches_multi_scc_same_file() {
let root = Path::new("/proj");
let leaf = make_summary("leaf", "/proj/a.rs", "rust", 0, vec![]);
let mid = make_summary("mid", "/proj/b.rs", "rust", 0, vec!["leaf"]);
let caller = make_summary("caller", "/proj/a.rs", "rust", 0, vec!["mid"]);
let files: Vec<PathBuf> = vec![PathBuf::from("/proj/a.rs"), PathBuf::from("/proj/b.rs")];
let (batches, orphans) = build_batches(vec![leaf, mid, caller], &files, root);
assert!(orphans.is_empty());
let batch_of = |name: &str| {
batches
.iter()
.position(|batch: &Vec<&PathBuf>| {
batch.iter().any(|p| p.to_str().unwrap().ends_with(name))
})
.unwrap()
};
assert!(
batch_of("a.rs") < batch_of("b.rs"),
"a.rs has leaf fn so should be in earlier batch than b.rs"
);
}
#[test]
fn scc_file_batches_mutual_recursion() {
let root = Path::new("/proj");
let a = make_summary("ping", "/proj/a.rs", "rust", 0, vec!["pong"]);
let b = make_summary("pong", "/proj/b.rs", "rust", 0, vec!["ping"]);
let files: Vec<PathBuf> = vec![PathBuf::from("/proj/a.rs"), PathBuf::from("/proj/b.rs")];
let (batches, orphans) = build_batches(vec![a, b], &files, root);
assert!(orphans.is_empty());
assert_eq!(
batches.len(),
1,
"mutual recursion → single SCC → single batch"
);
assert_eq!(batches[0].len(), 2);
}
#[test]
fn scc_file_batches_empty_graph() {
let root = Path::new("/proj");
let files: Vec<PathBuf> = vec![PathBuf::from("/proj/a.rs"), PathBuf::from("/proj/b.rs")];
let gs = merge_summaries(vec![], None);
let cg = build_call_graph(&gs, &[]);
let analysis = analyse(&cg);
let (batches, orphans) = scc_file_batches(&cg, &analysis, &files, root);
assert!(batches.is_empty(), "empty graph → no batches");
assert_eq!(orphans.len(), 2, "all files are orphans");
}
fn build_metadata_batches<'a>(
summaries: Vec<FuncSummary>,
all_files: &'a [PathBuf],
root: &Path,
) -> (Vec<FileBatch<'a>>, Vec<&'a PathBuf>) {
let gs = merge_summaries(summaries, Some(&root.to_string_lossy()));
let cg = build_call_graph(&gs, &[]);
let analysis = analyse(&cg);
scc_file_batches_with_metadata(&cg, &analysis, all_files, root)
}
#[test]
fn scc_file_batches_with_metadata_marks_recursive() {
let root = Path::new("/proj");
let a = make_summary("ping", "/proj/a.rs", "rust", 0, vec!["pong"]);
let b = make_summary("pong", "/proj/b.rs", "rust", 0, vec!["ping"]);
let files: Vec<PathBuf> = vec![PathBuf::from("/proj/a.rs"), PathBuf::from("/proj/b.rs")];
let (batches, orphans) = build_metadata_batches(vec![a, b], &files, root);
assert!(orphans.is_empty());
assert_eq!(batches.len(), 1, "mutual recursion → single batch");
assert!(
batches[0].has_mutual_recursion,
"batch with mutual recursion should be marked"
);
assert_eq!(batches[0].files.len(), 2);
}
#[test]
fn scc_file_batches_with_metadata_marks_cross_file() {
let root = Path::new("/proj");
let a = make_summary("ping", "/proj/a.rs", "rust", 0, vec!["pong"]);
let b = make_summary("pong", "/proj/b.rs", "rust", 0, vec!["ping"]);
let files: Vec<PathBuf> = vec![PathBuf::from("/proj/a.rs"), PathBuf::from("/proj/b.rs")];
let (batches, _orphans) = build_metadata_batches(vec![a, b], &files, root);
assert_eq!(
batches.len(),
1,
"cross-file mutual recursion → single batch"
);
assert!(batches[0].has_mutual_recursion);
assert!(
batches[0].cross_file,
"batch whose SCC spans two namespaces should be marked cross_file"
);
}
#[test]
fn scc_file_batches_with_metadata_intra_file_scc_not_cross_file() {
let root = Path::new("/proj");
let a = make_summary("ping", "/proj/a.rs", "rust", 0, vec!["pong"]);
let b = make_summary("pong", "/proj/a.rs", "rust", 0, vec!["ping"]);
let files: Vec<PathBuf> = vec![PathBuf::from("/proj/a.rs")];
let (batches, _orphans) = build_metadata_batches(vec![a, b], &files, root);
assert_eq!(batches.len(), 1);
assert!(batches[0].has_mutual_recursion);
assert!(
!batches[0].cross_file,
"single-file SCC must not be flagged as cross_file"
);
}
#[test]
fn scc_spans_files_single_node() {
let root = Path::new("/proj");
let a = make_summary("f", "/proj/a.rs", "rust", 0, vec![]);
let gs = merge_summaries(vec![a], Some(&root.to_string_lossy()));
let cg = build_call_graph(&gs, &[]);
let analysis = analyse(&cg);
for scc in &analysis.sccs {
assert!(!scc_spans_files(&cg, scc));
}
}
#[test]
fn scc_file_batches_with_metadata_singleton_not_recursive() {
let root = Path::new("/proj");
let c = make_summary("c_fn", "/proj/c.rs", "rust", 0, vec![]);
let b = make_summary("b_fn", "/proj/b.rs", "rust", 0, vec!["c_fn"]);
let a = make_summary("a_fn", "/proj/a.rs", "rust", 0, vec!["b_fn"]);
let files: Vec<PathBuf> = vec![
PathBuf::from("/proj/a.rs"),
PathBuf::from("/proj/b.rs"),
PathBuf::from("/proj/c.rs"),
];
let (batches, orphans) = build_metadata_batches(vec![a, b, c], &files, root);
assert!(orphans.is_empty());
assert_eq!(batches.len(), 3, "3 files in linear chain → 3 batches");
for (i, batch) in batches.iter().enumerate() {
assert!(
!batch.has_mutual_recursion,
"batch {i} should not be marked as recursive"
);
}
}
#[test]
fn qualified_callee_disambiguates_ambiguous() {
let send_http = make_summary("send", "src/http.rs", "rust", 0, vec![]);
let send_mail = make_summary("send", "src/mail.rs", "rust", 0, vec![]);
let caller = make_summary("caller", "src/main.rs", "rust", 0, vec!["http::send"]);
let gs = merge_summaries(vec![send_http, send_mail, caller], None);
let cg = build_call_graph(&gs, &[]);
let caller_key = FuncKey {
lang: Lang::Rust,
namespace: "src/main.rs".into(),
name: "caller".into(),
arity: Some(0),
..Default::default()
};
let send_http_key = FuncKey {
lang: Lang::Rust,
namespace: "src/http.rs".into(),
name: "send".into(),
arity: Some(0),
..Default::default()
};
let caller_node = cg.index[&caller_key];
let send_http_node = cg.index[&send_http_key];
let edges: Vec<_> = cg.graph.edges(caller_node).collect();
assert_eq!(
edges.len(),
1,
"qualified name should resolve the ambiguity"
);
assert_eq!(edges[0].target(), send_http_node);
assert!(cg.unresolved_ambiguous.is_empty());
}
#[test]
fn unqualified_callee_stays_ambiguous() {
let send_http = make_summary("send", "src/http.rs", "rust", 0, vec![]);
let send_mail = make_summary("send", "src/mail.rs", "rust", 0, vec![]);
let caller = make_summary("caller", "src/main.rs", "rust", 0, vec!["send"]);
let gs = merge_summaries(vec![send_http, send_mail, caller], None);
let cg = build_call_graph(&gs, &[]);
let caller_key = FuncKey {
lang: Lang::Rust,
namespace: "src/main.rs".into(),
name: "caller".into(),
arity: Some(0),
..Default::default()
};
let caller_node = cg.index[&caller_key];
let edges: Vec<_> = cg.graph.edges(caller_node).collect();
assert_eq!(edges.len(), 0, "unqualified name should remain ambiguous");
assert_eq!(cg.unresolved_ambiguous.len(), 1);
}
#[test]
fn simple_unqualified_resolves_as_before() {
let helper = make_summary("helper", "src/lib.rs", "rust", 0, vec![]);
let caller = make_summary("caller", "src/lib.rs", "rust", 0, vec!["helper"]);
let gs = merge_summaries(vec![helper, caller], None);
let cg = build_call_graph(&gs, &[]);
assert_eq!(cg.graph.edge_count(), 1);
assert!(cg.unresolved_not_found.is_empty());
assert!(cg.unresolved_ambiguous.is_empty());
}
fn summary_with_sites(
name: &str,
file_path: &str,
lang: &str,
param_count: usize,
sites: Vec<CalleeSite>,
) -> FuncSummary {
FuncSummary {
name: name.into(),
file_path: file_path.into(),
lang: lang.into(),
param_count,
param_names: vec![],
source_caps: 0,
sanitizer_caps: 0,
sink_caps: 0,
propagating_params: vec![],
propagates_taint: false,
tainted_sink_params: vec![],
callees: sites,
..Default::default()
}
}
#[test]
fn arity_hint_disambiguates_same_name_overloads() {
let encode1 = make_summary("encode", "src/codec.rs", "rust", 1, vec![]);
let encode2 = make_summary("encode", "src/codec.rs", "rust", 2, vec![]);
let caller = summary_with_sites(
"driver",
"src/main.rs",
"rust",
0,
vec![CalleeSite {
name: "encode".into(),
arity: Some(2),
..Default::default()
}],
);
let gs = merge_summaries(vec![encode1, encode2, caller], None);
let cg = build_call_graph(&gs, &[]);
let caller_key = FuncKey {
lang: Lang::Rust,
namespace: "src/main.rs".into(),
name: "driver".into(),
arity: Some(0),
..Default::default()
};
let encode2_key = FuncKey {
lang: Lang::Rust,
namespace: "src/codec.rs".into(),
name: "encode".into(),
arity: Some(2),
..Default::default()
};
let caller_node = cg.index[&caller_key];
let encode2_node = cg.index[&encode2_key];
let edges: Vec<_> = cg.graph.edges(caller_node).collect();
assert_eq!(edges.len(), 1, "arity hint should pick the 2-arg overload");
assert_eq!(edges[0].target(), encode2_node);
assert!(cg.unresolved_ambiguous.is_empty());
}
#[test]
fn no_arity_hint_stays_ambiguous() {
let encode1 = make_summary("encode", "src/codec.rs", "rust", 1, vec![]);
let encode2 = make_summary("encode", "src/codec.rs", "rust", 2, vec![]);
let caller = summary_with_sites(
"driver",
"src/main.rs",
"rust",
0,
vec![CalleeSite::bare("encode")],
);
let gs = merge_summaries(vec![encode1, encode2, caller], None);
let cg = build_call_graph(&gs, &[]);
assert_eq!(cg.graph.edge_count(), 0, "no arity hint → ambiguous");
assert_eq!(cg.unresolved_ambiguous.len(), 1);
}
#[test]
fn receiver_field_disambiguates_methods() {
let mut fs_order = make_summary("process", "src/app.rs", "rust", 1, vec![]);
fs_order.container = "OrderService".into();
let mut fs_user = make_summary("process", "src/app.rs", "rust", 1, vec![]);
fs_user.container = "UserService".into();
let caller = summary_with_sites(
"main",
"src/main.rs",
"rust",
0,
vec![CalleeSite {
name: "process".into(),
arity: Some(1),
receiver: Some("OrderService".into()),
..Default::default()
}],
);
let gs = merge_summaries(vec![fs_order, fs_user, caller], None);
let cg = build_call_graph(&gs, &[]);
let caller_key = FuncKey {
lang: Lang::Rust,
namespace: "src/main.rs".into(),
name: "main".into(),
arity: Some(0),
..Default::default()
};
let order_key = FuncKey {
lang: Lang::Rust,
namespace: "src/app.rs".into(),
container: "OrderService".into(),
name: "process".into(),
arity: Some(1),
..Default::default()
};
let caller_node = cg.index[&caller_key];
let order_node = cg.index[&order_key];
let edges: Vec<_> = cg.graph.edges(caller_node).collect();
assert_eq!(
edges.len(),
1,
"structured receiver should route to OrderService::process"
);
assert_eq!(edges[0].target(), order_node);
}
#[test]
fn qualifier_field_disambiguates_non_method_calls() {
let var_env = make_summary("var", "src/env.rs", "rust", 1, vec![]);
let var_local = make_summary("var", "src/locals.rs", "rust", 1, vec![]);
let caller = summary_with_sites(
"main",
"src/main.rs",
"rust",
0,
vec![CalleeSite {
name: "env::var".into(),
arity: Some(1),
qualifier: Some("env".into()),
..Default::default()
}],
);
let gs = merge_summaries(vec![var_env, var_local, caller], None);
let cg = build_call_graph(&gs, &[]);
let caller_key = FuncKey {
lang: Lang::Rust,
namespace: "src/main.rs".into(),
name: "main".into(),
arity: Some(0),
..Default::default()
};
let env_key = FuncKey {
lang: Lang::Rust,
namespace: "src/env.rs".into(),
name: "var".into(),
arity: Some(1),
..Default::default()
};
let caller_node = cg.index[&caller_key];
let env_node = cg.index[&env_key];
let edges: Vec<_> = cg.graph.edges(caller_node).collect();
assert_eq!(edges.len(), 1);
assert_eq!(
edges[0].target(),
env_node,
"qualifier `env` should select src/env.rs::var"
);
}
#[test]
fn legacy_string_callees_still_resolve() {
let helper = make_summary("helper", "src/lib.rs", "rust", 0, vec![]);
let caller = make_summary("main", "src/lib.rs", "rust", 0, vec!["helper"]);
let gs = merge_summaries(vec![helper, caller], None);
let cg = build_call_graph(&gs, &[]);
assert_eq!(cg.graph.edge_count(), 1);
assert!(cg.unresolved_not_found.is_empty());
assert!(cg.unresolved_ambiguous.is_empty());
}
fn make_method_summary(
name: &str,
container: &str,
file_path: &str,
lang: &str,
param_count: usize,
) -> FuncSummary {
let mut s = make_summary(name, file_path, lang, param_count, vec![]);
s.container = container.into();
s
}
#[test]
fn class_method_index_disambiguates_same_name_across_containers() {
let repo = make_method_summary("findById", "Repository", "src/repo.rs", "rust", 1);
let cache = make_method_summary("findById", "Cache", "src/cache.rs", "rust", 1);
let gs = merge_summaries(vec![repo, cache], None);
let idx = ClassMethodIndex::build(&gs);
let repo_hits = idx.resolve(Lang::Rust, Some("Repository"), "findById");
assert_eq!(
repo_hits.len(),
1,
"Repository::findById should resolve to exactly one target"
);
assert_eq!(repo_hits[0].container, "Repository");
let cache_hits = idx.resolve(Lang::Rust, Some("Cache"), "findById");
assert_eq!(cache_hits.len(), 1);
assert_eq!(cache_hits[0].container, "Cache");
let bare_hits = idx.resolve(Lang::Rust, None, "findById");
assert_eq!(
bare_hits.len(),
2,
"bare-name lookup should keep both same-name candidates"
);
}
#[test]
fn class_method_index_falls_back_to_name_when_container_unknown() {
let svc = make_method_summary("process", "OrderService", "src/svc.rs", "rust", 1);
let helper = make_summary("process", "src/util.rs", "rust", 1, vec![]);
let gs = merge_summaries(vec![svc, helper], None);
let idx = ClassMethodIndex::build(&gs);
let none_hits = idx.resolve(Lang::Rust, None, "process");
assert_eq!(none_hits.len(), 2);
let empty_hits = idx.resolve(Lang::Rust, Some(""), "process");
assert_eq!(empty_hits.len(), 2);
let cont_hits = idx.resolve(Lang::Rust, Some("OrderService"), "process");
assert_eq!(cont_hits.len(), 1);
assert_eq!(cont_hits[0].container, "OrderService");
}
#[test]
fn class_method_index_empty_for_unknown_method() {
let svc = make_method_summary("findById", "Repository", "src/repo.rs", "rust", 1);
let gs = merge_summaries(vec![svc], None);
let idx = ClassMethodIndex::build(&gs);
assert!(
idx.resolve(Lang::Rust, Some("Repository"), "findByName")
.is_empty()
);
assert!(
idx.resolve(Lang::Rust, Some("OtherClass"), "findById")
.is_empty()
);
assert!(idx.resolve(Lang::Rust, None, "doesNotExist").is_empty());
}
#[test]
fn class_method_index_partitions_by_language() {
let java_repo = make_method_summary("findById", "Repository", "Repo.java", "java", 1);
let ts_repo = make_method_summary("findById", "Repository", "repo.ts", "typescript", 1);
let gs = merge_summaries(vec![java_repo, ts_repo], None);
let idx = ClassMethodIndex::build(&gs);
let java_hits = idx.resolve(Lang::Java, Some("Repository"), "findById");
assert_eq!(java_hits.len(), 1);
assert_eq!(java_hits[0].lang, Lang::Java);
let ts_hits = idx.resolve(Lang::TypeScript, Some("Repository"), "findById");
assert_eq!(ts_hits.len(), 1);
assert_eq!(ts_hits[0].lang, Lang::TypeScript);
}
#[test]
fn class_method_index_handles_arity_overloads() {
let one = make_method_summary("encode", "Codec", "src/codec.rs", "rust", 1);
let two = make_method_summary("encode", "Codec", "src/codec.rs", "rust", 2);
let gs = merge_summaries(vec![one, two], None);
let idx = ClassMethodIndex::build(&gs);
let hits = idx.resolve(Lang::Rust, Some("Codec"), "encode");
assert_eq!(
hits.len(),
2,
"arity overloads should both appear under the same container key"
);
}
#[test]
fn typed_call_receivers_devirtualises_method_call() {
use crate::summary::ssa_summary::SsaFuncSummary;
let repo = make_method_summary("findById", "Repository", "src/repo.rs", "rust", 1);
let cache = make_method_summary("findById", "Cache", "src/cache.rs", "rust", 1);
let caller = summary_with_sites(
"lookup",
"src/main.rs",
"rust",
0,
vec![CalleeSite {
name: "findById".into(),
arity: Some(1),
receiver: Some("repo".into()),
ordinal: 0,
..Default::default()
}],
);
let mut gs = merge_summaries(vec![repo, cache, caller], None);
let caller_key = FuncKey {
lang: Lang::Rust,
namespace: "src/main.rs".into(),
name: "lookup".into(),
arity: Some(0),
..Default::default()
};
gs.insert_ssa(
caller_key.clone(),
SsaFuncSummary {
typed_call_receivers: vec![(0, "Repository".to_string())],
..Default::default()
},
);
let cg = build_call_graph(&gs, &[]);
let repo_key = FuncKey {
lang: Lang::Rust,
namespace: "src/repo.rs".into(),
container: "Repository".into(),
name: "findById".into(),
arity: Some(1),
..Default::default()
};
let caller_node = cg.index[&caller_key];
let repo_node = cg.index[&repo_key];
let edges: Vec<_> = cg.graph.edges(caller_node).collect();
assert_eq!(
edges.len(),
1,
"typed receiver should resolve to exactly one target; got {edges:?}"
);
assert_eq!(
edges[0].target(),
repo_node,
"edge must point to Repository::findById, not Cache::findById"
);
assert!(cg.unresolved_ambiguous.is_empty());
}
#[test]
fn typed_call_receivers_falls_through_on_zero_match() {
use crate::summary::ssa_summary::SsaFuncSummary;
let worker = make_method_summary("process", "Worker", "src/worker.rs", "rust", 1);
let caller = summary_with_sites(
"drive",
"src/main.rs",
"rust",
0,
vec![CalleeSite {
name: "process".into(),
arity: Some(1),
receiver: Some("worker".into()),
ordinal: 0,
..Default::default()
}],
);
let mut gs = merge_summaries(vec![worker, caller], None);
let caller_key = FuncKey {
lang: Lang::Rust,
namespace: "src/main.rs".into(),
name: "drive".into(),
arity: Some(0),
..Default::default()
};
gs.insert_ssa(
caller_key.clone(),
SsaFuncSummary {
typed_call_receivers: vec![(0, "Other".to_string())],
..Default::default()
},
);
let cg = build_call_graph(&gs, &[]);
let caller_node = cg.index[&caller_key];
let edges: Vec<_> = cg.graph.edges(caller_node).collect();
assert_eq!(
edges.len(),
1,
"stale/wrong type fact must fall through to today's resolution; \
got {edges:?} (cf. ambiguous: {:?})",
cg.unresolved_ambiguous,
);
}
fn hierarchy_from_edges(edges: Vec<(Lang, &str, &str)>) -> TypeHierarchyIndex {
let mut summary = make_summary("dummy", "dummy.rs", "rust", 0, vec![]);
let mut by_lang: std::collections::HashMap<Lang, Vec<(String, String)>> =
std::collections::HashMap::new();
for (lang, sub, sup) in edges {
by_lang
.entry(lang)
.or_default()
.push((sub.to_string(), sup.to_string()));
}
let _ = &mut summary; let mut all: Vec<FuncSummary> = Vec::new();
for (lang, edges) in by_lang {
let slug = match lang {
Lang::Rust => "rust",
Lang::Java => "java",
Lang::Python => "python",
Lang::TypeScript => "typescript",
Lang::JavaScript => "javascript",
Lang::Go => "go",
Lang::Php => "php",
Lang::Ruby => "ruby",
Lang::C => "c",
Lang::Cpp => "cpp",
};
let mut s = make_summary("dummy", "dummy.x", slug, 0, vec![]);
s.hierarchy_edges = edges;
all.push(s);
}
let gs = merge_summaries(all, None);
TypeHierarchyIndex::build(&gs)
}
#[test]
fn b1_type_hierarchy_index_round_trip() {
let h = hierarchy_from_edges(vec![
(Lang::Java, "UserRepo", "Repository"),
(Lang::Java, "CacheRepo", "Repository"),
(Lang::Java, "Derived", "Base"),
]);
let mut subs: Vec<&str> = h
.subs_of(Lang::Java, "Repository")
.iter()
.map(|s| s.as_str())
.collect();
subs.sort();
assert_eq!(subs, vec!["CacheRepo", "UserRepo"]);
assert_eq!(h.subs_of(Lang::Java, "Base"), &["Derived".to_string()]);
assert_eq!(h.subs_of(Lang::Java, "Unknown"), &[] as &[String]);
assert_eq!(h.super_keys_len(), 2);
}
#[test]
fn b2_java_interface_dispatch_fans_out_to_all_impls() {
use crate::summary::ssa_summary::SsaFuncSummary;
let user_repo = make_method_summary("findById", "UserRepo", "src/UserRepo.java", "java", 1);
let cache_repo =
make_method_summary("findById", "CacheRepo", "src/CacheRepo.java", "java", 1);
let mut iface_marker = make_method_summary(
"__placeholder",
"Repository",
"src/Repository.java",
"java",
0,
);
iface_marker.hierarchy_edges = vec![
("UserRepo".to_string(), "Repository".to_string()),
("CacheRepo".to_string(), "Repository".to_string()),
];
let caller = summary_with_sites(
"lookup",
"src/main.java",
"java",
0,
vec![CalleeSite {
name: "findById".into(),
arity: Some(1),
receiver: Some("r".into()),
ordinal: 0,
..Default::default()
}],
);
let mut gs = merge_summaries(vec![user_repo, cache_repo, iface_marker, caller], None);
let caller_key = FuncKey {
lang: Lang::Java,
namespace: "src/main.java".into(),
name: "lookup".into(),
arity: Some(0),
..Default::default()
};
gs.insert_ssa(
caller_key.clone(),
SsaFuncSummary {
typed_call_receivers: vec![(0, "Repository".to_string())],
..Default::default()
},
);
let cg = build_call_graph(&gs, &[]);
let caller_node = cg.index[&caller_key];
let targets: Vec<&FuncKey> = cg
.graph
.edges(caller_node)
.map(|e| &cg.graph[e.target()])
.collect();
let containers: Vec<&str> = targets.iter().map(|k| k.container.as_str()).collect();
assert!(
containers.contains(&"UserRepo") && containers.contains(&"CacheRepo"),
"B-2: edges must reach BOTH UserRepo::findById and CacheRepo::findById; got {targets:?}"
);
assert_eq!(targets.len(), 2, "B-2: exactly two fan-out edges expected");
}
#[test]
fn b3_java_extends_fans_out_to_subclass() {
use crate::summary::ssa_summary::SsaFuncSummary;
let base = make_method_summary("foo", "Base", "src/Base.java", "java", 0);
let mut derived = make_method_summary("foo", "Derived", "src/Derived.java", "java", 0);
derived.hierarchy_edges = vec![("Derived".to_string(), "Base".to_string())];
let caller = summary_with_sites(
"go",
"src/main.java",
"java",
0,
vec![CalleeSite {
name: "foo".into(),
arity: Some(0),
receiver: Some("b".into()),
ordinal: 0,
..Default::default()
}],
);
let mut gs = merge_summaries(vec![base, derived, caller], None);
let caller_key = FuncKey {
lang: Lang::Java,
namespace: "src/main.java".into(),
name: "go".into(),
arity: Some(0),
..Default::default()
};
gs.insert_ssa(
caller_key.clone(),
SsaFuncSummary {
typed_call_receivers: vec![(0, "Base".to_string())],
..Default::default()
},
);
let cg = build_call_graph(&gs, &[]);
let caller_node = cg.index[&caller_key];
let targets: Vec<&FuncKey> = cg
.graph
.edges(caller_node)
.map(|e| &cg.graph[e.target()])
.collect();
let containers: Vec<&str> = targets.iter().map(|k| k.container.as_str()).collect();
assert!(
containers.contains(&"Base"),
"B-3: edge must reach Base::foo; got {targets:?}"
);
assert!(
containers.contains(&"Derived"),
"B-3: edge must reach Derived::foo; got {targets:?}"
);
}
#[test]
fn b4_rust_trait_dispatch_fans_out_to_impls() {
use crate::summary::ssa_summary::SsaFuncSummary;
let user_repo = make_method_summary("find", "UserRepo", "src/user_repo.rs", "rust", 1);
let cache_repo = make_method_summary("find", "CacheRepo", "src/cache_repo.rs", "rust", 1);
let mut hierarchy_carrier = make_method_summary("__h", "Repo", "src/repo.rs", "rust", 0);
hierarchy_carrier.hierarchy_edges = vec![
("UserRepo".to_string(), "Repo".to_string()),
("CacheRepo".to_string(), "Repo".to_string()),
];
let caller = summary_with_sites(
"lookup",
"src/main.rs",
"rust",
0,
vec![CalleeSite {
name: "find".into(),
arity: Some(1),
receiver: Some("r".into()),
ordinal: 0,
..Default::default()
}],
);
let mut gs = merge_summaries(vec![user_repo, cache_repo, hierarchy_carrier, caller], None);
let caller_key = FuncKey {
lang: Lang::Rust,
namespace: "src/main.rs".into(),
name: "lookup".into(),
arity: Some(0),
..Default::default()
};
gs.insert_ssa(
caller_key.clone(),
SsaFuncSummary {
typed_call_receivers: vec![(0, "Repo".to_string())],
..Default::default()
},
);
let cg = build_call_graph(&gs, &[]);
let caller_node = cg.index[&caller_key];
let targets: Vec<&FuncKey> = cg
.graph
.edges(caller_node)
.map(|e| &cg.graph[e.target()])
.collect();
let containers: Vec<&str> = targets.iter().map(|k| k.container.as_str()).collect();
assert!(
containers.contains(&"UserRepo") && containers.contains(&"CacheRepo"),
"B-4: edges must fan out to both impls; got {targets:?}"
);
}
#[test]
fn b7_empty_hierarchy_falls_back_to_single_container() {
use crate::summary::ssa_summary::SsaFuncSummary;
let repo = make_method_summary("findById", "Repository", "src/repo.rs", "rust", 1);
let cache = make_method_summary("findById", "Cache", "src/cache.rs", "rust", 1);
let caller = summary_with_sites(
"lookup",
"src/main.rs",
"rust",
0,
vec![CalleeSite {
name: "findById".into(),
arity: Some(1),
receiver: Some("repo".into()),
ordinal: 0,
..Default::default()
}],
);
let mut gs = merge_summaries(vec![repo, cache, caller], None);
let caller_key = FuncKey {
lang: Lang::Rust,
namespace: "src/main.rs".into(),
name: "lookup".into(),
arity: Some(0),
..Default::default()
};
gs.insert_ssa(
caller_key.clone(),
SsaFuncSummary {
typed_call_receivers: vec![(0, "Repository".to_string())],
..Default::default()
},
);
let cg = build_call_graph(&gs, &[]);
let caller_node = cg.index[&caller_key];
let targets: Vec<&FuncKey> = cg
.graph
.edges(caller_node)
.map(|e| &cg.graph[e.target()])
.collect();
assert_eq!(targets.len(), 1, "B-7: empty hierarchy → single edge");
assert_eq!(targets[0].container, "Repository");
}
#[test]
fn b8_concrete_subtype_does_not_widen() {
use crate::summary::ssa_summary::SsaFuncSummary;
let user_repo = make_method_summary("findById", "UserRepo", "src/user_repo.rs", "rust", 1);
let cache_repo =
make_method_summary("findById", "CacheRepo", "src/cache_repo.rs", "rust", 1);
let mut h = make_method_summary("__h", "Repo", "src/repo.rs", "rust", 0);
h.hierarchy_edges = vec![
("UserRepo".to_string(), "Repo".to_string()),
("CacheRepo".to_string(), "Repo".to_string()),
];
let caller = summary_with_sites(
"lookup",
"src/main.rs",
"rust",
0,
vec![CalleeSite {
name: "findById".into(),
arity: Some(1),
receiver: Some("r".into()),
ordinal: 0,
..Default::default()
}],
);
let mut gs = merge_summaries(vec![user_repo, cache_repo, h, caller], None);
let caller_key = FuncKey {
lang: Lang::Rust,
namespace: "src/main.rs".into(),
name: "lookup".into(),
arity: Some(0),
..Default::default()
};
gs.insert_ssa(
caller_key.clone(),
SsaFuncSummary {
typed_call_receivers: vec![(0, "UserRepo".to_string())],
..Default::default()
},
);
let cg = build_call_graph(&gs, &[]);
let caller_node = cg.index[&caller_key];
let targets: Vec<&FuncKey> = cg
.graph
.edges(caller_node)
.map(|e| &cg.graph[e.target()])
.collect();
assert_eq!(
targets.len(),
1,
"B-8: concrete sub-type must not fan out; got {targets:?}"
);
assert_eq!(targets[0].container, "UserRepo");
}
#[test]
fn b9_diamond_dedup_one_edge_per_funckey() {
use crate::summary::ssa_summary::SsaFuncSummary;
let a = make_method_summary("doIt", "A", "src/A.java", "java", 0);
let b = make_method_summary("doIt", "B", "src/B.java", "java", 0);
let mut h1 = make_method_summary("__h", "Iface", "src/I1.java", "java", 0);
h1.hierarchy_edges = vec![
("A".to_string(), "Iface".to_string()),
("B".to_string(), "Iface".to_string()),
];
let mut h2 = make_method_summary("__h2", "Iface", "src/I2.java", "java", 0);
h2.hierarchy_edges = vec![
("A".to_string(), "Iface".to_string()),
("B".to_string(), "Iface".to_string()),
];
let caller = summary_with_sites(
"go",
"src/main.java",
"java",
0,
vec![CalleeSite {
name: "doIt".into(),
arity: Some(0),
receiver: Some("x".into()),
ordinal: 0,
..Default::default()
}],
);
let mut gs = merge_summaries(vec![a, b, h1, h2, caller], None);
let caller_key = FuncKey {
lang: Lang::Java,
namespace: "src/main.java".into(),
name: "go".into(),
arity: Some(0),
..Default::default()
};
gs.insert_ssa(
caller_key.clone(),
SsaFuncSummary {
typed_call_receivers: vec![(0, "Iface".to_string())],
..Default::default()
},
);
let cg = build_call_graph(&gs, &[]);
let caller_node = cg.index[&caller_key];
let targets: Vec<&FuncKey> = cg
.graph
.edges(caller_node)
.map(|e| &cg.graph[e.target()])
.collect();
let containers: std::collections::HashSet<&str> =
targets.iter().map(|k| k.container.as_str()).collect();
assert_eq!(
containers.len(),
targets.len(),
"B-9: dedup must give one edge per FuncKey; got {targets:?}"
);
assert!(containers.contains("A") && containers.contains("B"));
}
#[test]
fn b13_stale_subtype_no_panic() {
use crate::summary::ssa_summary::SsaFuncSummary;
let base = make_method_summary("foo", "Base", "src/Base.java", "java", 0);
let mut h = make_method_summary("__h", "X", "src/X.java", "java", 0);
h.hierarchy_edges = vec![("Derived".to_string(), "Base".to_string())];
let caller = summary_with_sites(
"go",
"src/main.java",
"java",
0,
vec![CalleeSite {
name: "foo".into(),
arity: Some(0),
receiver: Some("b".into()),
ordinal: 0,
..Default::default()
}],
);
let mut gs = merge_summaries(vec![base, h, caller], None);
let caller_key = FuncKey {
lang: Lang::Java,
namespace: "src/main.java".into(),
name: "go".into(),
arity: Some(0),
..Default::default()
};
gs.insert_ssa(
caller_key.clone(),
SsaFuncSummary {
typed_call_receivers: vec![(0, "Base".to_string())],
..Default::default()
},
);
let cg = build_call_graph(&gs, &[]);
let caller_node = cg.index[&caller_key];
let targets: Vec<&FuncKey> = cg
.graph
.edges(caller_node)
.map(|e| &cg.graph[e.target()])
.collect();
assert!(
targets
.iter()
.any(|k| k.container == "Base" && k.name == "foo"),
"B-13: stale Derived must not block Base::foo edge; got {targets:?}"
);
}
#[test]
fn typed_call_receivers_skips_free_function_sites() {
use crate::summary::ssa_summary::SsaFuncSummary;
let helper = make_summary("helper", "src/lib.rs", "rust", 0, vec![]);
let caller = summary_with_sites(
"main",
"src/lib.rs",
"rust",
0,
vec![CalleeSite {
name: "helper".into(),
arity: Some(0),
receiver: None,
ordinal: 0,
..Default::default()
}],
);
let mut gs = merge_summaries(vec![helper, caller], None);
let caller_key = FuncKey {
lang: Lang::Rust,
namespace: "src/lib.rs".into(),
name: "main".into(),
arity: Some(0),
..Default::default()
};
gs.insert_ssa(
caller_key.clone(),
SsaFuncSummary {
typed_call_receivers: vec![(0, "FakeContainer".to_string())],
..Default::default()
},
);
let cg = build_call_graph(&gs, &[]);
assert_eq!(cg.graph.edge_count(), 1);
assert!(cg.unresolved_not_found.is_empty());
assert!(cg.unresolved_ambiguous.is_empty());
}
}