use crate::graph::types::{CodeGraph, Edge, FileFacts, Symbol};
use super::incremental::{FileSubgraph, GlobalIndex, build_subgraph, stitch};
use super::{Resolver, dedup_files_last_wins};
#[derive(Debug, Default, Clone, Copy)]
pub struct ScopeGraphResolver;
impl Resolver for ScopeGraphResolver {
fn resolve(&self, files: &[FileFacts]) -> CodeGraph {
let files = dedup_files_last_wins(files);
let subs: Vec<FileSubgraph> = files.iter().copied().map(build_subgraph).collect();
let symbols: Vec<Symbol> = subs
.iter()
.flat_map(|s| s.symbols.iter().cloned())
.collect();
let index = GlobalIndex::from_symbols(&symbols);
let mut edges: Vec<Edge> = Vec::new();
let mut all_pending = Vec::new();
for sub in subs {
edges.extend(sub.intra_edges);
all_pending.extend(sub.pending);
}
edges.extend(stitch(&all_pending, &index));
CodeGraph { symbols, edges }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extract::Extractor;
use crate::extract::PythonExtractor;
use crate::extract::RustExtractor;
use crate::graph::types::{Confidence, Provenance};
#[test]
fn python_import_disambiguates_ambiguous_call() {
use crate::graph::types::RefRole;
let alpha = PythonExtractor
.extract("def process():\n pass\n", "alpha.py")
.unwrap();
let beta = PythonExtractor
.extract("def process():\n pass\n", "beta.py")
.unwrap();
let main = PythonExtractor
.extract(
"from alpha import process\n\ndef run():\n process()\n",
"main.py",
)
.unwrap();
let graph = ScopeGraphResolver.resolve(&[alpha, beta, main]);
let calls: Vec<_> = graph
.edges
.iter()
.filter(|e| e.role == RefRole::Call)
.collect();
assert_eq!(
calls.len(),
1,
"expected exactly one call edge (no fan-out)"
);
assert_eq!(calls[0].provenance, Provenance::ScopeGraph);
assert!(
calls[0].to.to_scip_string().contains("alpha"),
"call must bind to alpha's process, got {}",
calls[0].to.to_scip_string()
);
}
#[test]
fn typescript_import_disambiguates_ambiguous_call() {
use crate::extract::TypeScriptExtractor;
use crate::graph::types::RefRole;
let alpha = TypeScriptExtractor
.extract("export function process() {}\n", "alpha.ts")
.unwrap();
let beta = TypeScriptExtractor
.extract("export function process() {}\n", "beta.ts")
.unwrap();
let main = TypeScriptExtractor
.extract(
"import { process } from \"./alpha\";\n\nexport function run() {\n process();\n}\n",
"main.ts",
)
.unwrap();
let graph = ScopeGraphResolver.resolve(&[alpha, beta, main]);
let calls: Vec<_> = graph
.edges
.iter()
.filter(|e| e.role == RefRole::Call)
.collect();
assert_eq!(
calls.len(),
1,
"expected exactly one call edge (no fan-out)"
);
assert_eq!(calls[0].provenance, Provenance::ScopeGraph);
assert!(
calls[0].to.to_scip_string().contains("alpha"),
"call must bind to alpha's process, got {}",
calls[0].to.to_scip_string()
);
}
fn local_edges(graph: &CodeGraph) -> Vec<&Edge> {
graph
.edges
.iter()
.filter(|e| e.to.to_scip_string().starts_with("local "))
.collect()
}
#[test]
fn resolves_local_binding() {
let facts = RustExtractor
.extract(
"pub fn run() { let helper = make(); helper() }",
"src/main.rs",
)
.unwrap();
let graph = ScopeGraphResolver.resolve(&[facts]);
let locals = local_edges(&graph);
assert_eq!(
locals.len(),
1,
"expected exactly one local edge, got {:?}",
locals.len()
);
let e = locals[0];
assert_eq!(e.confidence, Confidence::Exact);
assert_eq!(e.provenance, Provenance::ScopeGraph);
assert!(
e.from.to_scip_string().ends_with("run()."),
"from was: {}",
e.from.to_scip_string()
);
}
#[test]
fn shadowing_latest_binding_wins() {
let src = "pub fn run() { let val = make(); let val = other(); val() }";
let facts = RustExtractor.extract(src, "src/main.rs").unwrap();
let first_let = src.find("let val").unwrap();
let second_let = src[first_let + 1..].find("let val").unwrap() + first_let + 1;
assert!(second_let > first_let);
let graph = ScopeGraphResolver.resolve(&[facts]);
let locals = local_edges(&graph);
assert_eq!(
locals.len(),
1,
"expected one local edge, got {:?}",
locals.len()
);
let second_intro = second_let + "let ".len();
let id = locals[0].to.to_scip_string();
assert!(
id.ends_with(&format!("@{}", second_intro)),
"local id {id} should encode the second binding intro {second_intro}"
);
}
#[test]
fn resolves_param_binding() {
let facts = RustExtractor
.extract("pub fn run(callback: u32) { callback() }", "src/main.rs")
.unwrap();
let graph = ScopeGraphResolver.resolve(&[facts]);
let locals = local_edges(&graph);
assert_eq!(
locals.len(),
1,
"expected one local edge, got {:?}",
locals.len()
);
assert_eq!(locals[0].confidence, Confidence::Exact);
}
#[test]
fn unbound_name_produces_no_edge() {
let facts = RustExtractor
.extract("pub fn run() { nothing_here() }", "src/main.rs")
.unwrap();
let graph = ScopeGraphResolver.resolve(&[facts]);
assert!(
local_edges(&graph).is_empty(),
"unbound name must not bind to a local"
);
}
#[test]
fn non_scope_language_is_graceful_noop() {
let facts = PythonExtractor
.extract("def f():\n pass\n", "src/m.py")
.unwrap();
let sym_count = facts.symbols.len();
let graph = ScopeGraphResolver.resolve(&[facts]);
assert_eq!(graph.symbols.len(), sym_count);
assert!(local_edges(&graph).is_empty());
}
#[test]
fn block_local_not_visible_to_outer_ref() {
let facts = RustExtractor
.extract(
"pub fn run() { { let val = make(); } val() }",
"src/main.rs",
)
.unwrap();
let graph = ScopeGraphResolver.resolve(&[facts]);
assert!(
local_edges(&graph).is_empty(),
"outer ref must not bind to a block-scoped local"
);
}
#[test]
fn ignores_role_noise_only_local_edges_counted() {
let facts = RustExtractor
.extract("pub fn run() { helper() }", "src/main.rs")
.unwrap();
let graph = ScopeGraphResolver.resolve(&[facts]);
assert!(
local_edges(&graph).is_empty(),
"unbound name must not produce a local edge"
);
}
#[test]
fn resolves_same_file_definition() {
let facts = RustExtractor
.extract(
"pub fn helper() {} pub fn run() { helper() }",
"src/main.rs",
)
.unwrap();
let graph = ScopeGraphResolver.resolve(&[facts]);
let def_edges: Vec<&Edge> = graph
.edges
.iter()
.filter(|e| {
e.from.to_scip_string().ends_with("run().")
&& e.to.to_scip_string().ends_with("helper().")
&& !e.to.to_scip_string().starts_with("local ")
})
.collect();
assert_eq!(
def_edges.len(),
1,
"expected exactly one run→helper definition edge, got {:?}",
def_edges
.iter()
.map(|e| format!("{} → {}", e.from.to_scip_string(), e.to.to_scip_string()))
.collect::<Vec<_>>()
);
assert_eq!(
def_edges[0].confidence,
Confidence::Scoped,
"definition edge must carry Scoped confidence"
);
assert!(
local_edges(&graph).is_empty(),
"definition call must not produce a local edge"
);
}
#[test]
fn same_file_definition_wins_over_cross_file_fan_out() {
let facts_a = RustExtractor
.extract("pub fn helper() {}", "src/a.rs")
.unwrap();
let facts_b = RustExtractor
.extract("pub fn helper() {}", "src/b.rs")
.unwrap();
let facts_caller = RustExtractor
.extract(
"pub fn helper() {} pub fn run() { helper() }",
"src/caller.rs",
)
.unwrap();
let graph = ScopeGraphResolver.resolve(&[facts_a, facts_b, facts_caller]);
let run_edges: Vec<&Edge> = graph
.edges
.iter()
.filter(|e| e.from.to_scip_string().ends_with("run()."))
.collect();
assert_eq!(
run_edges.len(),
1,
"expected exactly one edge from run, not a cross-file fan-out; got: {:?}",
run_edges
.iter()
.map(|e| e.to.to_scip_string())
.collect::<Vec<_>>()
);
let edge = run_edges[0];
assert_eq!(edge.confidence, Confidence::Scoped);
let to_scip = edge.to.to_scip_string();
assert!(
to_scip.ends_with("caller/helper()."),
"run→helper edge must target caller.rs's own helper, got: {to_scip}"
);
}
#[test]
fn local_shadows_same_name_definition() {
let src = "pub fn process() {} pub fn run() { let process = make(); process() }";
let facts = RustExtractor.extract(src, "src/main.rs").unwrap();
let graph = ScopeGraphResolver.resolve(&[facts]);
let run_edges: Vec<&Edge> = graph
.edges
.iter()
.filter(|e| e.from.to_scip_string().ends_with("run()."))
.collect();
assert_eq!(
run_edges.len(),
1,
"expected exactly one edge from run, got {:?}",
run_edges
.iter()
.map(|e| e.to.to_scip_string())
.collect::<Vec<_>>()
);
let to_scip = run_edges[0].to.to_scip_string();
assert!(
to_scip.starts_with("local "),
"let-binding must shadow top-level definition: target should be a local, got: {to_scip}"
);
}
fn import_edges(graph: &CodeGraph) -> Vec<&Edge> {
graph
.edges
.iter()
.filter(|e| e.role == crate::graph::types::RefRole::Import)
.collect()
}
#[test]
fn resolves_unique_cross_file_import_exact() {
let conf = RustExtractor
.extract("pub struct Config {}", "src/conf.rs")
.unwrap();
let app = RustExtractor
.extract("use conf::Config;\npub fn run() {}", "src/app.rs")
.unwrap();
let graph = ScopeGraphResolver.resolve(&[conf, app]);
let imports = import_edges(&graph);
assert_eq!(
imports.len(),
1,
"expected exactly one Import edge, got: {:?}",
imports
.iter()
.map(|e| e.to.to_scip_string())
.collect::<Vec<_>>()
);
let e = imports[0];
assert_eq!(
e.confidence,
Confidence::Exact,
"cross-file import edge must be Exact"
);
assert!(
e.to.to_scip_string().ends_with("conf/Config#"),
"import edge must target conf::Config, got: {}",
e.to.to_scip_string()
);
assert!(
e.from.to_scip_string().ends_with("app/"),
"import edge source should be app's module symbol, got: {}",
e.from.to_scip_string()
);
}
#[test]
fn go_same_package_cross_file_call_resolves_scoped() {
use crate::extract::GoExtractor;
use crate::graph::types::RefRole;
let util = GoExtractor
.extract("package main\nfunc Helper() {}\n", "util.go")
.unwrap();
let main = GoExtractor
.extract("package main\nfunc Run() {\n\tHelper()\n}\n", "main.go")
.unwrap();
let graph = ScopeGraphResolver.resolve(&[util, main]);
let edges: Vec<&Edge> = graph
.edges
.iter()
.filter(|e| {
e.role == RefRole::Call
&& e.from.to_scip_string().ends_with("main/Run().")
&& e.to.to_scip_string().ends_with("main/Helper().")
})
.collect();
assert_eq!(
edges.len(),
1,
"expected exactly one Run→Helper cross-file edge, got: {:?}",
graph
.edges
.iter()
.map(|e| format!("{} → {}", e.from.to_scip_string(), e.to.to_scip_string()))
.collect::<Vec<_>>()
);
assert_eq!(
edges[0].confidence,
Confidence::Scoped,
"same-package cross-file call must be Scoped"
);
assert_eq!(edges[0].provenance, Provenance::ScopeGraph);
}
#[test]
fn ambiguous_import_becomes_precise_single_exact_edge() {
let conf = RustExtractor
.extract("pub struct Config {}", "src/conf.rs")
.unwrap();
let other = RustExtractor
.extract("pub struct Config {}", "src/other.rs")
.unwrap();
let app = RustExtractor
.extract("use conf::Config;\npub fn run() {}", "src/app.rs")
.unwrap();
let graph = ScopeGraphResolver.resolve(&[conf, other, app]);
let imports = import_edges(&graph);
assert_eq!(
imports.len(),
1,
"expected exactly one precise Import edge (not a fan-out), got: {:?}",
imports
.iter()
.map(|e| e.to.to_scip_string())
.collect::<Vec<_>>()
);
let e = imports[0];
assert_eq!(e.confidence, Confidence::Exact);
assert!(
e.to.to_scip_string().ends_with("conf/Config#"),
"must resolve to conf::Config, got: {}",
e.to.to_scip_string()
);
assert!(
!e.to.to_scip_string().ends_with("other/Config#"),
"must NOT resolve to the decoy other::Config"
);
}
#[test]
fn unmatched_import_yields_no_edge() {
let conf = RustExtractor
.extract("pub struct Config {}", "src/conf.rs")
.unwrap();
let app = RustExtractor
.extract("use missing::Config;\npub fn run() {}", "src/app.rs")
.unwrap();
let graph = ScopeGraphResolver.resolve(&[conf, app]);
assert!(
import_edges(&graph).is_empty(),
"import whose path matches no definition must yield no Tier-B edge"
);
}
#[test]
fn same_file_recursion_emits_no_self_edge() {
let facts = RustExtractor
.extract(
"pub fn countdown(n: u32) { countdown(n - 1) }",
"src/rec.rs",
)
.unwrap();
let graph = ScopeGraphResolver.resolve(&[facts]);
let self_edges: Vec<_> = graph
.edges
.iter()
.filter(|e| e.from == e.to)
.map(|e| e.from.to_scip_string())
.collect();
assert!(
self_edges.is_empty(),
"recursion must not produce a from==to self-edge, got: {self_edges:?}"
);
}
fn call_edges_from_run(graph: &CodeGraph) -> Vec<&Edge> {
graph
.edges
.iter()
.filter(|e| {
e.from.to_scip_string().ends_with("run().")
&& !e.to.to_scip_string().starts_with("local ")
&& e.role != crate::graph::types::RefRole::Import
})
.collect()
}
#[test]
fn qualified_call_unique_match_emits_exact_edge() {
let mod_a = RustExtractor
.extract("pub fn process() {}", "src/mod_a.rs")
.unwrap();
let mod_b = RustExtractor
.extract("pub fn process() {}", "src/mod_b.rs")
.unwrap();
let caller = RustExtractor
.extract("pub fn run() { mod_a::process() }", "src/caller.rs")
.unwrap();
let graph = ScopeGraphResolver.resolve(&[mod_a, mod_b, caller]);
let run_edges = call_edges_from_run(&graph);
assert_eq!(
run_edges.len(),
1,
"expected exactly one edge from run (qualifier disambiguates), got: {:?}",
run_edges
.iter()
.map(|e| format!(
"{} → {} ({:?})",
e.from.to_scip_string(),
e.to.to_scip_string(),
e.confidence
))
.collect::<Vec<_>>()
);
let edge = run_edges[0];
assert_eq!(
edge.confidence,
Confidence::Exact,
"qualified-call edge must carry Exact confidence"
);
assert!(
edge.to.to_scip_string().ends_with("mod_a/process()."),
"edge must target mod_a::process, got: {}",
edge.to.to_scip_string()
);
assert!(
!edge.to.to_scip_string().ends_with("mod_b/process()."),
"edge must NOT target mod_b::process (the decoy)"
);
}
#[test]
fn type_qualified_call_resolves_to_enclosing_type_member() {
use crate::extract::RubyExtractor;
let alpha = RubyExtractor
.extract(
"module Alpha\n def self.compute\n 1\n end\nend\n",
"alpha.rb",
)
.unwrap();
let beta = RubyExtractor
.extract(
"module Beta\n def self.compute\n 2\n end\nend\n",
"beta.rb",
)
.unwrap();
let main = RubyExtractor
.extract("def run\n Alpha.compute\nend\n", "main.rb")
.unwrap();
let graph = ScopeGraphResolver.resolve(&[alpha, beta, main]);
let call_edges: Vec<_> = graph
.edges
.iter()
.filter(|e| {
e.role == crate::graph::types::RefRole::Call
&& e.from.to_scip_string().ends_with("run().")
})
.collect();
assert_eq!(
call_edges.len(),
1,
"type qualifier must disambiguate to exactly one edge, got: {:?}",
call_edges
.iter()
.map(|e| e.to.to_scip_string())
.collect::<Vec<_>>()
);
assert!(
call_edges[0]
.to
.to_scip_string()
.ends_with("Alpha#compute()."),
"must target Alpha#compute, got: {}",
call_edges[0].to.to_scip_string()
);
assert_eq!(call_edges[0].confidence, Confidence::Exact);
}
#[test]
fn qualified_call_unmatched_qualifier_yields_no_edge() {
let conf = RustExtractor
.extract("pub fn process() {}", "src/conf.rs")
.unwrap();
let caller = RustExtractor
.extract("pub fn run() { missing::process() }", "src/caller.rs")
.unwrap();
let graph = ScopeGraphResolver.resolve(&[conf, caller]);
let run_edges = call_edges_from_run(&graph);
assert!(
run_edges.is_empty(),
"unmatched qualifier must yield no edge, got: {:?}",
run_edges
.iter()
.map(|e| e.to.to_scip_string())
.collect::<Vec<_>>()
);
}
#[test]
fn unqualified_call_still_resolves_via_scope_walk() {
let facts = RustExtractor
.extract(
"pub fn helper() {} pub fn run() { helper() }",
"src/main.rs",
)
.unwrap();
let graph = ScopeGraphResolver.resolve(&[facts]);
let run_edges: Vec<&Edge> = graph
.edges
.iter()
.filter(|e| {
e.from.to_scip_string().ends_with("run().")
&& e.to.to_scip_string().ends_with("helper().")
&& !e.to.to_scip_string().starts_with("local ")
})
.collect();
assert_eq!(
run_edges.len(),
1,
"unqualified helper() must still resolve via scope_walk, got: {:?}",
run_edges
.iter()
.map(|e| e.to.to_scip_string())
.collect::<Vec<_>>()
);
assert_eq!(
run_edges[0].confidence,
Confidence::Scoped,
"unqualified same-file call must carry Scoped confidence"
);
}
#[test]
fn typeref_resolves_to_same_file_definition() {
use crate::graph::types::RefRole;
let facts = RustExtractor
.extract(
"pub struct Config {}\npub fn run(cfg: Config) {}",
"src/main.rs",
)
.unwrap();
let graph = ScopeGraphResolver.resolve(&[facts]);
let typeref_edges: Vec<&Edge> = graph
.edges
.iter()
.filter(|e| e.role == RefRole::TypeRef)
.collect();
assert_eq!(
typeref_edges.len(),
1,
"expected exactly one TypeRef edge, got {:?}: {:?}",
typeref_edges.len(),
typeref_edges
.iter()
.map(|e| format!(
"{} → {} ({:?})",
e.from.to_scip_string(),
e.to.to_scip_string(),
e.confidence
))
.collect::<Vec<_>>()
);
let e = typeref_edges[0];
assert!(
e.from.to_scip_string().ends_with("run()."),
"TypeRef edge from must end with 'run().', got: {}",
e.from.to_scip_string()
);
assert!(
e.to.to_scip_string().ends_with("Config#"),
"TypeRef edge to must end with 'Config#', got: {}",
e.to.to_scip_string()
);
assert_eq!(
e.confidence,
Confidence::Scoped,
"same-file definition resolution must carry Scoped confidence, got: {:?}",
e.confidence
);
}
#[test]
fn nested_qualifier_resolves_to_nested_namespace() {
let nested = RustExtractor
.extract("pub fn process() {}", "src/a/b.rs")
.unwrap();
let caller = RustExtractor
.extract("pub fn run() { a::b::process() }", "src/caller.rs")
.unwrap();
let graph = ScopeGraphResolver.resolve(&[nested, caller]);
let run_edges = call_edges_from_run(&graph);
assert_eq!(
run_edges.len(),
1,
"nested qualifier a::b::process() must resolve to src/a/b.rs::process, got: {:?}",
run_edges
.iter()
.map(|e| e.to.to_scip_string())
.collect::<Vec<_>>()
);
let edge = run_edges[0];
assert_eq!(edge.confidence, Confidence::Exact);
assert!(
edge.to.to_scip_string().ends_with("a/b/process()."),
"nested-namespace edge must target a/b/process, got: {}",
edge.to.to_scip_string()
);
}
#[test]
fn confidence_contract_per_resolution_kind() {
{
let facts = RustExtractor
.extract(
"pub fn run() { let buffer = make(); buffer() }",
"src/main.rs",
)
.unwrap();
let graph = ScopeGraphResolver.resolve(&[facts]);
let locals = local_edges(&graph);
assert_eq!(locals.len(), 1, "expected one local edge for 'buffer'");
assert_eq!(
locals[0].confidence,
Confidence::Exact,
"local binding must be Exact"
);
}
{
let facts = RustExtractor
.extract("pub fn run(handler: u32) { handler() }", "src/main.rs")
.unwrap();
let graph = ScopeGraphResolver.resolve(&[facts]);
let locals = local_edges(&graph);
assert_eq!(locals.len(), 1, "expected one local edge for 'handler'");
assert_eq!(
locals[0].confidence,
Confidence::Exact,
"param binding must be Exact"
);
}
{
let facts = RustExtractor
.extract(
"pub fn compute() {} pub fn run() { compute() }",
"src/main.rs",
)
.unwrap();
let graph = ScopeGraphResolver.resolve(&[facts]);
let def_edges: Vec<&Edge> = graph
.edges
.iter()
.filter(|e| {
e.from.to_scip_string().ends_with("run().")
&& e.to.to_scip_string().ends_with("compute().")
&& !e.to.to_scip_string().starts_with("local ")
})
.collect();
assert_eq!(
def_edges.len(),
1,
"expected one definition edge for 'compute'"
);
assert_eq!(
def_edges[0].confidence,
Confidence::Scoped,
"same-file definition must be Scoped"
);
}
{
let service = RustExtractor
.extract("pub struct Service {}", "src/service.rs")
.unwrap();
let app = RustExtractor
.extract("use service::Service;\npub fn run() {}", "src/app.rs")
.unwrap();
let graph = ScopeGraphResolver.resolve(&[service, app]);
let imports = import_edges(&graph);
assert_eq!(imports.len(), 1, "expected one import edge for 'Service'");
assert_eq!(
imports[0].confidence,
Confidence::Exact,
"cross-file import must be Exact"
);
}
{
let util = RustExtractor
.extract("pub fn validate() {}", "src/util.rs")
.unwrap();
let caller = RustExtractor
.extract("pub fn run() { util::validate() }", "src/caller.rs")
.unwrap();
let graph = ScopeGraphResolver.resolve(&[util, caller]);
let run_edges = call_edges_from_run(&graph);
assert_eq!(
run_edges.len(),
1,
"expected one qualified-call edge for 'util::validate'"
);
assert_eq!(
run_edges[0].confidence,
Confidence::Exact,
"qualified call must be Exact"
);
assert!(
run_edges[0]
.to
.to_scip_string()
.ends_with("util/validate()."),
"qualified call must target util::validate, got: {}",
run_edges[0].to.to_scip_string()
);
}
}
}