use scc_indexer::conflicts::{self, UpgradeRecord};
use scc_store::Store;
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
pub const DEFAULT_MIN_AGREEMENT: f64 = 1.0;
#[derive(Debug, Clone, Default)]
pub struct RepoResolution {
pub repo: String,
pub native_resolved: usize,
pub native_external: usize,
pub lsp_upgrades: usize,
pub lsp_unresolved: usize,
pub agreement: usize,
pub conflicts: usize,
}
#[derive(Debug, Clone, Default)]
pub struct ResolutionSummary {
pub repos: Vec<RepoResolution>,
pub total_resolved: usize,
pub total_external: usize,
pub total_upgrades: usize,
pub total_unresolved: usize,
pub total_agreement: usize,
pub total_conflicts: usize,
}
#[derive(Debug, Clone)]
pub struct ExternalCallEdge {
pub file: String,
pub subject: String,
pub object: String,
pub callee: String,
pub line: u32,
pub evidence_id: String,
}
pub fn collect_external_edges(store: &Store) -> Result<Vec<ExternalCallEdge>, String> {
let all = store.all_relationships().map_err(|e| e.to_string())?;
let mut out = Vec::new();
for (file, _hash, _lang, _kind, _size) in store.all_files().map_err(|e| e.to_string())? {
let ids: HashSet<String> = store
.relationship_ids_with_source(&file, scc_core::predicates::CALLS)
.map_err(|e| e.to_string())?
.into_iter()
.collect();
if ids.is_empty() {
continue;
}
for r in all.iter().filter(|r| {
r.predicate == scc_core::predicates::CALLS
&& r.provenance == scc_core::Provenance::Extracted
&& ids.contains(&r.id)
}) {
let evidence_id = r.evidence.first().cloned().unwrap_or_default();
let (callee, line) = match store.get_evidence(&evidence_id).map_err(|e| e.to_string())? {
Some(ev) => (ev.symbol.unwrap_or_default(), ev.start_line.unwrap_or(0)),
None => (String::new(), 0),
};
out.push(ExternalCallEdge {
file: file.clone(),
subject: r.subject.clone(),
object: r.object.clone(),
callee,
line,
evidence_id,
});
}
}
Ok(out)
}
pub fn diff_upgrades(
store: &Store,
pre: &[ExternalCallEdge],
) -> Result<Vec<(String, UpgradeRecord)>, String> {
let rels = store.all_relationships().map_err(|e| e.to_string())?;
let mut out = Vec::new();
for r in rels.iter().filter(|r| {
r.predicate == scc_core::predicates::CALLS
&& r.provenance == scc_core::Provenance::Resolved
}) {
let Some(ev_id) = r.evidence.first() else {
continue;
};
let Some(p) = pre.iter().find(|p| &p.evidence_id == ev_id) else {
continue;
};
out.push((
p.file.clone(),
UpgradeRecord {
callee: p.callee.clone(),
old_object: p.object.clone(),
new_object: r.object.clone(),
line: p.line,
},
));
}
Ok(out)
}
pub fn diff_repo(root: &Path) -> Result<RepoResolution, String> {
crate::commands::cmd_index(root, true).map_err(|e| format!("index: {e}"))?;
let store = crate::open_store(root).map_err(|e| e.to_string())?;
let repo_name = store.repo_name.clone();
let pre = collect_external_edges(&store)?;
let all = store.all_relationships().map_err(|e| e.to_string())?;
let pre_resolved: HashSet<String> = all
.iter()
.filter(|r| {
r.predicate == scc_core::predicates::CALLS
&& r.provenance == scc_core::Provenance::Resolved
})
.map(|r| r.id.clone())
.collect();
let native_resolved = pre_resolved.len();
let native_external = pre.len();
if !pre.is_empty() && scc_indexer::lsp::pyright_version().is_some() {
let mut files: Vec<String> = Vec::new();
for p in &pre {
if !files.contains(&p.file) {
files.push(p.file.clone());
}
}
let mut resolver = scc_indexer::lsp::start_pyright(root)?;
for file in &files {
resolver
.resolve_call_definitions(&store, file)
.map_err(|e| format!("lsp {file}: {e}"))?;
}
drop(resolver);
}
let upgrades = diff_upgrades(&store, &pre)?;
let lsp_upgrades = upgrades.len();
let mut conflicts = 0usize;
let mut by_file: BTreeMap<String, Vec<UpgradeRecord>> = BTreeMap::new();
for (file, rec) in upgrades {
by_file.entry(file).or_default().push(rec);
}
for (file, recs) in &by_file {
let report = conflicts::record_resolution_conflicts(&store, file, recs)?;
conflicts += report.conflicts;
}
let after = store.all_relationships().map_err(|e| e.to_string())?;
let lsp_unresolved = after
.iter()
.filter(|r| {
r.predicate == scc_core::predicates::CALLS
&& r.provenance == scc_core::Provenance::Extracted
})
.count();
let agreement = after
.iter()
.filter(|r| {
r.predicate == scc_core::predicates::CALLS
&& r.provenance == scc_core::Provenance::Resolved
&& pre_resolved.contains(&r.id)
})
.count();
Ok(RepoResolution {
repo: repo_name,
native_resolved,
native_external,
lsp_upgrades,
lsp_unresolved,
agreement,
conflicts,
})
}
pub fn check_gate(summary: &ResolutionSummary, min_agreement: f64) -> Result<(), String> {
if summary.total_conflicts > 0 {
return Err(format!(
"resolution benchmark gate failed: {} resolution conflict(s) — LSP and native disagree; inspect `scc drift`",
summary.total_conflicts
));
}
let ratio = if summary.total_external == 0 {
0.0
} else {
summary.total_unresolved as f64 / summary.total_external as f64
};
if min_agreement < 1.0 && ratio >= min_agreement {
return Err(format!(
"resolution benchmark gate failed: {:.1}% of external call candidates ({}/{}) remain unresolved (limit {:.0}%, min_agreement {min_agreement})",
ratio * 100.0,
summary.total_unresolved,
summary.total_external,
min_agreement * 100.0
));
}
Ok(())
}
pub fn run_resolution_benchmark(min_agreement: f64) -> Result<ResolutionSummary, String> {
let fixtures = crate::benchctx::locate_fixtures_dir()
.ok_or("cannot locate fixtures/ directory")?;
let repos = corpus_repos()?;
let mut summary = ResolutionSummary::default();
for repo in &repos {
let repo_dir = fixtures.join(repo);
if !repo_dir.is_dir() {
return Err(format!("fixture repo missing: {repo}"));
}
let tmp = tempfile::TempDir::new().map_err(|e| e.to_string())?;
let root = tmp.path().join(repo);
copy_fixture(&repo_dir, &root);
match diff_repo(&root) {
Ok(r) => summary.repos.push(r),
Err(e) => return Err(format!("repo {repo} failed: {e}")),
}
}
for r in &summary.repos {
summary.total_resolved += r.native_resolved;
summary.total_external += r.native_external;
summary.total_upgrades += r.lsp_upgrades;
summary.total_unresolved += r.lsp_unresolved;
summary.total_agreement += r.agreement;
summary.total_conflicts += r.conflicts;
}
if let Err(e) = check_gate(&summary, min_agreement) {
return Err(format!("{}{e}", format_table(&summary)));
}
Ok(summary)
}
fn format_table(s: &ResolutionSummary) -> String {
let mut out = String::new();
out.push_str(&format!(
" {:<24} {:>9} {:>10} {:>9} {:>10} {:>9} {:>8}\n",
"repo", "native-res", "native-ext", "lsp-upgr", "lsp-unres", "agreement", "conflicts"
));
for r in &s.repos {
out.push_str(&format!(
" {:<24} {:>9} {:>10} {:>9} {:>10} {:>9} {:>8}\n",
r.repo, r.native_resolved, r.native_external, r.lsp_upgrades, r.lsp_unresolved,
r.agreement, r.conflicts
));
}
out
}
pub fn print_summary(s: &ResolutionSummary) {
println!("scc bench resolution — native vs LSP differential (SCC-126)");
print!("{}", format_table(s));
println!(
" totals: resolved {} external {} upgrades {} unresolved {} agreement {} conflicts {}",
s.total_resolved,
s.total_external,
s.total_upgrades,
s.total_unresolved,
s.total_agreement,
s.total_conflicts
);
}
fn corpus_path() -> Option<PathBuf> {
let mut dir = std::env::current_dir().ok()?;
loop {
let p = dir.join("benchmarks").join("tasks.json");
if p.is_file() {
return Some(p);
}
if !dir.pop() {
break;
}
}
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
manifest
.parent()
.and_then(|p| p.parent())
.map(|p| p.join("benchmarks").join("tasks.json"))
.filter(|p| p.is_file())
}
fn corpus_repos() -> Result<Vec<String>, String> {
let path = corpus_path().ok_or("cannot locate benchmarks/tasks.json")?;
let text = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
let v: serde_json::Value = serde_json::from_str(&text).map_err(|e| e.to_string())?;
let tasks = v
.get("tasks")
.and_then(|t| t.as_array())
.ok_or("tasks.json: missing tasks array")?;
let mut repos: Vec<String> = Vec::new();
for t in tasks {
let repo = t
.get("repo")
.and_then(|r| r.as_str())
.ok_or("tasks.json: task missing repo")?;
if !repos.iter().any(|r| r == repo) {
repos.push(repo.to_string());
}
}
if repos.is_empty() {
return Err("tasks.json: no repos".to_string());
}
Ok(repos)
}
fn copy_fixture(src: &Path, dst: &Path) {
std::fs::create_dir_all(dst).unwrap();
for entry in std::fs::read_dir(src).unwrap() {
let entry = entry.unwrap();
let name = entry.file_name();
if name == ".scc" {
continue;
}
let from = entry.path();
let to = dst.join(&name);
if from.is_dir() {
std::fs::create_dir_all(&to).unwrap();
copy_fixture(&from, &to);
} else {
std::fs::copy(&from, &to).unwrap();
}
}
}