use std::path::Path;
use anyhow::Result;
use sinter_resolve::qualified_of;
use sinter_store::EdgeFilter;
use crate::lookup::{
SymbolLookupError, candidate_labels, candidates_in, ensure_snapshot, ensure_snapshot_token,
open_store,
};
const MAX_PAIRS: usize = 16;
const MAX_FRONTIER: usize = 5;
pub fn run(
repo: &Path,
from: &str,
to: &str,
filter: &EdgeFilter,
json: bool,
if_snapshot: Option<&str>,
) -> Result<bool> {
let store = open_store(repo)?;
let snapshot = ensure_snapshot(&store, if_snapshot)?;
let froms = candidates_in(&store, from, filter.scopes.as_ref())?;
let tos = candidates_in(&store, to, filter.scopes.as_ref())?;
let ambiguous = froms.len() > 1 || tos.len() > 1;
let mut pairs = froms
.iter()
.flat_map(|f| tos.iter().map(move |t| (f, t)))
.take(MAX_PAIRS);
let mut found = None;
let mut first = None;
for (f, t) in &mut pairs {
let path = store.shortest_path(&f.id, &t.id, filter)?;
if path.is_some() {
found = Some((f.clone(), t.clone(), path));
break;
}
first.get_or_insert((f.clone(), t.clone(), path));
}
let (from_node, to_node, path) = found.or(first).expect("candidate lists are non-empty");
if ambiguous && path.is_none() {
if json {
let (requested, candidates) = if froms.len() > 1 {
(from, froms)
} else {
(to, tos)
};
return Err(SymbolLookupError::Ambiguous {
requested: requested.to_string(),
candidates,
}
.into());
}
println!("not proven: no path {from} -> {to} observed for any candidate pair");
for (name, nodes) in [(from, &froms), (to, &tos)] {
if nodes.len() > 1 {
println!(" `{name}` candidates:");
for label in candidate_labels(nodes) {
println!(" {label}");
}
}
}
println!(" snapshot: {snapshot}");
let root = crate::pipeline::discover_root(repo);
print_actionable(&root, &explain_miss(&store, &from_node, &to_node, filter)?);
return Ok(false);
}
if ambiguous && !json {
println!(
"from: {}@{} to: {}@{}",
qualified_of(from_node.id.as_str()),
from_node.file,
qualified_of(to_node.id.as_str()),
to_node.file
);
}
let scopes = store.file_scopes()?;
let scope_of_id = |id: &sinter_core::NodeId| {
let file = id
.as_str()
.split_once('#')
.map_or(id.as_str(), |(file, _)| file);
scopes
.get(file)
.copied()
.unwrap_or_else(|| sinter_core::CorpusScope::classify_path(file))
};
let root = crate::pipeline::discover_root(repo);
let miss = path
.is_none()
.then(|| explain_miss(&store, &from_node, &to_node, filter))
.transpose()?;
let evidence = crate::coverage::TraversalEvidence::from_confidences(
path.iter().flatten().map(|edge| edge.confidence),
miss.as_ref()
.map_or(0, |miss| miss.unresolved_matching_target),
);
if json {
let mut out = serde_json::json!({
"status": if path.is_some() { "found" } else { "not_proven" },
"snapshot": snapshot,
"found": path.is_some(),
"steps": path.iter().flatten().map(|e| serde_json::json!({
"from": qualified_of(e.src.as_str()),
"to": qualified_of(e.dst.as_str()),
"from_scope": scope_of_id(&e.src).as_str(),
"to_scope": scope_of_id(&e.dst).as_str(),
"relation": e.relation.as_str(),
"evidence": e.evidence.as_str(),
"confidence": match e.confidence {
sinter_core::Confidence::Certain => "certain",
sinter_core::Confidence::Inferred => "possible",
},
"site": crate::render::site_json(&root, e),
})).collect::<Vec<_>>(),
});
if let Some(miss) = &miss {
out["miss"] = miss_json(&root, miss);
}
out["coverage"] =
crate::coverage::traversal_json(&root, &store, filter, evidence, path.is_some())?;
crate::agent_protocol::write_json(&out)?;
return Ok(path.is_some());
}
match path {
None => {
println!(
"not proven: no path {} -> {} observed",
qualified_of(from_node.id.as_str()),
qualified_of(to_node.id.as_str())
);
print_miss(
&root,
&from_node,
&to_node,
miss.as_ref().expect("a missing path has miss evidence"),
);
crate::coverage::print_footer(&root, &store, filter, evidence, false, Some(&snapshot))?;
Ok(false)
}
Some(edges) => {
print!("{}", qualified_of(from_node.id.as_str()));
for edge in &edges {
let site = crate::render::site_location(&root, edge)
.map(|s| format!(" at {s}"))
.unwrap_or_default();
print!(
" -[{}/{}{}]-> {}",
edge.relation.as_str(),
edge.evidence.as_str(),
site,
qualified_of(edge.dst.as_str())
);
}
println!();
crate::coverage::print_footer(&root, &store, filter, evidence, true, Some(&snapshot))?;
Ok(true)
}
}
}
pub fn run_workspace(
manifest: &std::path::Path,
from: &str,
to: &str,
filter: &EdgeFilter,
if_snapshot: Option<&str>,
) -> Result<bool> {
let ws = crate::workspace::load(manifest)?;
let snapshot = crate::workspace::snapshot_token(&ws)?;
ensure_snapshot_token(if_snapshot, &snapshot)?;
let (from_member, from_node) = crate::workspace::find_symbol(&ws, from)?;
let (to_member, to_node) = crate::workspace::find_symbol(&ws, to)?;
let path = crate::workspace::shortest_path(
&ws,
(&from_member, &from_node.id),
(&to_member, &to_node.id),
filter,
)?;
let evidence = crate::coverage::TraversalEvidence::from_confidences(
path.iter()
.flatten()
.map(|(_, _, _, evidence, _, _)| evidence.confidence()),
0,
);
match path {
None => {
println!(
"not proven: no path {from_member}:{} -> {to_member}:{} observed",
qualified_of(from_node.id.as_str()),
qualified_of(to_node.id.as_str())
);
println!(" snapshot: {snapshot}");
crate::coverage::print_workspace_traversal(&ws, filter, evidence, false)?;
Ok(false)
}
Some(steps) => {
print!("{from_member}:{}", qualified_of(from_node.id.as_str()));
for (_, _, rel, evid, dst_member, dst_id) in &steps {
print!(
" -[{}/{}]-> {dst_member}:{}",
rel.as_str(),
evid.as_str(),
qualified_of(dst_id)
);
}
println!();
println!(" snapshot: {snapshot}");
crate::coverage::print_workspace_traversal(&ws, filter, evidence, true)?;
Ok(true)
}
}
}
pub struct Miss {
pub forward_reached: usize,
pub reached_by: Vec<sinter_core::Edge>,
pub excluded_by_filter: usize,
pub unresolved_matching_target: usize,
pub closest_frontier: Vec<sinter_store::Reached>,
pub excluded_edges: std::collections::BTreeMap<&'static str, usize>,
pub suggested_retries: Vec<Retry>,
}
pub struct Retry {
pub arguments: [String; 2],
pub remove: Option<&'static str>,
pub add: Option<String>,
pub note: Option<&'static str>,
}
fn shared_prefix(a: &str, b: &str) -> usize {
a.split('/')
.zip(b.split('/'))
.take_while(|(x, y)| x == y)
.count()
}
fn refusal(
filter: &EdgeFilter,
edge: &sinter_core::Edge,
scope_blocked: bool,
) -> Option<(&'static str, &'static str)> {
if filter
.relations
.as_ref()
.is_some_and(|allowed| !allowed.contains(&edge.relation))
{
return Some((edge.relation.as_str(), "relations"));
}
if filter
.evidence
.as_ref()
.is_some_and(|allowed| !allowed.contains(&edge.evidence))
{
let reason = match edge.evidence {
sinter_core::Evidence::Scope => "scope-evidence",
other => other.as_str(),
};
return Some((reason, "evidence"));
}
if filter.min_confidence == Some(sinter_core::Confidence::Certain)
&& edge.confidence != sinter_core::Confidence::Certain
{
return Some(("inferred", "certain"));
}
scope_blocked.then_some(("scope", "scope"))
}
fn derive_retries(
from: &str,
to: &str,
blocking: &std::collections::BTreeSet<&'static str>,
target_scope: sinter_core::CorpusScope,
frontier: Option<&str>,
) -> Vec<Retry> {
let pair = || [from.to_string(), to.to_string()];
let mut out = Vec::new();
for flag in ["certain", "evidence", "relations"] {
if blocking.contains(flag) {
out.push(Retry {
arguments: pair(),
remove: Some(flag),
add: None,
note: None,
});
}
}
if blocking.contains("scope") {
out.push(Retry {
arguments: pair(),
remove: None,
add: Some(format!("--scope {}", target_scope.as_str())),
note: Some("target is out of the selected scope"),
});
}
if let Some(frontier) = frontier {
out.push(Retry {
arguments: [from.to_string(), frontier.to_string()],
remove: None,
add: None,
note: Some("reached the target's file, not the target"),
});
}
out
}
pub fn explain_miss(
store: &sinter_store::Store,
from: &sinter_core::Node,
to: &sinter_core::Node,
filter: &EdgeFilter,
) -> Result<Miss> {
let forward = store.dependencies(&from.id, filter, usize::MAX)?;
let forward_reached = forward.len();
let mut reachable = std::collections::HashSet::from([from.id.clone()]);
reachable.extend(forward.iter().map(|reached| reached.node.id.clone()));
let mut files = std::collections::BTreeSet::from([from.file.as_str()]);
files.extend(forward.iter().map(|reached| reached.node.file.as_str()));
let mut unresolved_matching_target = 0;
for file in files {
unresolved_matching_target += store
.references_in(file)?
.iter()
.filter(|reference| {
reference
.enclosing
.as_ref()
.is_some_and(|enclosing| reachable.contains(enclosing))
&& name_tail_matches(&reference.name, &to.name)
})
.count();
}
let inn = store.in_edges(&to.id)?;
let candidates: Vec<&sinter_core::Edge> = inn
.iter()
.filter(|e| e.relation != sinter_core::Relation::Contains)
.collect();
let excluded_by_filter = candidates.iter().filter(|e| !filter.admits(e)).count();
let target_scope = store.scope_index()?.scope_of(to);
let scope_blocked = !filter.admits_scope(target_scope);
let mut excluded_edges = std::collections::BTreeMap::new();
let mut blocking = std::collections::BTreeSet::new();
for edge in &candidates {
let Some((reason, flag)) = refusal(filter, edge, scope_blocked) else {
continue;
};
*excluded_edges.entry(reason).or_default() += 1;
blocking.insert(flag);
}
let reached_by = candidates
.into_iter()
.filter(|e| filter.admits(e))
.cloned()
.collect();
let mut forward = forward;
forward.sort_by(|a, b| {
shared_prefix(&b.node.file, &to.file)
.cmp(&shared_prefix(&a.node.file, &to.file))
.then(a.depth.cmp(&b.depth))
.then(a.node.name.cmp(&b.node.name))
});
forward.truncate(MAX_FRONTIER);
let frontier = forward
.first()
.filter(|reached| reached.node.file == to.file)
.map(|reached| qualified_of(reached.node.id.as_str()));
let suggested_retries = derive_retries(
qualified_of(from.id.as_str()),
qualified_of(to.id.as_str()),
&blocking,
target_scope,
frontier,
);
Ok(Miss {
forward_reached,
reached_by,
excluded_by_filter,
unresolved_matching_target,
closest_frontier: forward,
excluded_edges,
suggested_retries,
})
}
pub fn miss_json(root: &Path, miss: &Miss) -> serde_json::Value {
let mut out = serde_json::json!({
"forward_reached": miss.forward_reached,
"reached_by": miss.reached_by.iter().map(|e| serde_json::json!({
"from": qualified_of(e.src.as_str()),
"relation": e.relation.as_str(),
"evidence": e.evidence.as_str(),
"site": crate::render::site_json(root, e),
})).collect::<Vec<_>>(),
"excluded_by_filter": miss.excluded_by_filter,
"unresolved_matching_target": miss.unresolved_matching_target,
});
if !miss.closest_frontier.is_empty() {
out["closest_frontier"] = miss
.closest_frontier
.iter()
.map(|reached| {
serde_json::json!({
"symbol": qualified_of(reached.node.id.as_str()),
"site": match crate::render::line_of(root, &reached.node.file, reached.node.span.start) {
Some(line) => format!("{}:{line}", reached.node.file),
None => reached.node.file.clone(),
},
"depth": reached.depth,
})
})
.collect();
}
if !miss.excluded_edges.is_empty() {
out["excluded_edges"] = serde_json::json!(miss.excluded_edges);
}
if !miss.suggested_retries.is_empty() {
out["suggested_retries"] = miss
.suggested_retries
.iter()
.map(|retry| {
let mut entry = serde_json::json!({
"operation": "path",
"arguments": retry.arguments,
});
if let Some(remove) = retry.remove {
entry["remove"] = serde_json::json!([remove]);
}
if let Some(add) = &retry.add {
entry["add"] = serde_json::json!([add]);
}
if let Some(note) = retry.note {
entry["note"] = serde_json::json!(note);
}
entry
})
.collect();
}
out
}
fn print_miss(root: &Path, from: &sinter_core::Node, to: &sinter_core::Node, miss: &Miss) {
println!(
" forward search from {} reached {} symbol(s)",
qualified_of(from.id.as_str()),
miss.forward_reached
);
if miss.reached_by.is_empty() {
println!(
" nothing reaches {} under this filter",
qualified_of(to.id.as_str())
);
} else {
println!(
" {} is reached by ({}):",
qualified_of(to.id.as_str()),
miss.reached_by.len()
);
for e in miss.reached_by.iter().take(8) {
let site = crate::render::site_location(root, e)
.map(|s| format!(" at {s}"))
.unwrap_or_default();
println!(
" {} [{}/{}]{site}",
qualified_of(e.src.as_str()),
e.relation.as_str(),
e.evidence.as_str()
);
}
if miss.reached_by.len() > 8 {
println!(" … (+{})", miss.reached_by.len() - 8);
}
}
if miss.excluded_by_filter > 0 {
println!(
" {} incoming edge(s) excluded by --evidence/--certain",
miss.excluded_by_filter
);
}
if miss.unresolved_matching_target > 0 {
println!(
" {} unresolved ref(s) on the forward frontier name `{}` — the path may be missing; `sinter scip` would bind them",
miss.unresolved_matching_target, to.name,
);
}
print_actionable(root, miss);
}
fn print_actionable(root: &Path, miss: &Miss) {
if !miss.closest_frontier.is_empty() {
println!(" closest frontier ({}):", miss.closest_frontier.len());
for reached in &miss.closest_frontier {
let line = crate::render::line_of(root, &reached.node.file, reached.node.span.start);
println!(
" {} [d{}] at {}",
qualified_of(reached.node.id.as_str()),
reached.depth,
crate::render::location(root, &reached.node.file, line)
);
}
}
if !miss.excluded_edges.is_empty() {
let counts: Vec<String> = miss
.excluded_edges
.iter()
.map(|(reason, count)| format!("{reason}={count}"))
.collect();
println!(" excluded edges: {}", counts.join(" "));
}
for retry in &miss.suggested_retries {
let flag = match (retry.remove, &retry.add) {
(Some(remove), _) => format!(" drop={remove}"),
(None, Some(add)) => format!(" add={add}"),
(None, None) => String::new(),
};
let note = retry
.note
.map(|note| format!(" # {note}"))
.unwrap_or_default();
println!(
" retry: path {} {}{flag}{note}",
retry.arguments[0], retry.arguments[1]
);
}
}
fn name_tail_matches(written: &str, name: &str) -> bool {
let tail = written.rsplit("::").next().unwrap_or(written);
let tail = tail.rsplit(['/', '.']).next().unwrap_or(tail);
tail == name
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, BTreeSet};
use sinter_core::{Confidence, CorpusScope, Edge, Evidence, NodeId, Relation};
use sinter_store::EdgeFilter;
use super::{Retry, derive_retries, refusal, shared_prefix};
fn edge(evidence: Evidence, relation: Relation) -> Edge {
Edge {
src: NodeId::new("a.rs#a@0"),
dst: NodeId::new("b.rs#b@0"),
relation,
evidence,
confidence: evidence.confidence(),
site: None,
}
}
fn blocking(flags: &[&'static str]) -> BTreeSet<&'static str> {
flags.iter().copied().collect()
}
fn shapes(retries: &[Retry]) -> Vec<(String, String, Option<&'static str>, Option<String>)> {
retries
.iter()
.map(|r| {
(
r.arguments[0].clone(),
r.arguments[1].clone(),
r.remove,
r.add.clone(),
)
})
.collect()
}
#[test]
fn nothing_implied_suggests_nothing() {
let retries = derive_retries("A", "B", &blocking(&[]), CorpusScope::Production, None);
assert!(retries.is_empty());
}
#[test]
fn certain_refusing_an_incoming_edge_suggests_dropping_certain() {
let retries = derive_retries(
"A",
"B",
&blocking(&["certain"]),
CorpusScope::Production,
None,
);
assert_eq!(
shapes(&retries),
vec![("A".into(), "B".into(), Some("certain"), None)]
);
}
#[test]
fn out_of_scope_target_suggests_widening_to_its_scope() {
let retries = derive_retries("A", "B", &blocking(&["scope"]), CorpusScope::Test, None);
assert_eq!(
shapes(&retries),
vec![(
"A".into(),
"B".into(),
None,
Some("--scope test".to_string())
)]
);
}
#[test]
fn frontier_in_the_target_file_suggests_the_shorter_path() {
let retries = derive_retries(
"A",
"B",
&blocking(&[]),
CorpusScope::Production,
Some("Frontier"),
);
assert_eq!(
shapes(&retries),
vec![("A".into(), "Frontier".into(), None, None)]
);
assert!(retries[0].note.is_some());
}
#[test]
fn every_blocking_flag_yields_its_own_retry() {
let retries = derive_retries(
"A",
"B",
&blocking(&["certain", "evidence", "relations", "scope"]),
CorpusScope::Vendor,
Some("F"),
);
assert_eq!(retries.len(), 5);
}
#[test]
fn certain_refuses_inferred_edges_by_confidence() {
let filter = EdgeFilter {
min_confidence: Some(Confidence::Certain),
..Default::default()
};
assert_eq!(
refusal(&filter, &edge(Evidence::Dynamic, Relation::Calls), false),
Some(("inferred", "certain"))
);
assert_eq!(
refusal(&filter, &edge(Evidence::Scip, Relation::Calls), false),
None
);
}
#[test]
fn evidence_and_relation_refusals_name_the_edge_attribute() {
let filter = EdgeFilter {
evidence: Some(BTreeSet::from([Evidence::Structural])),
..Default::default()
};
assert_eq!(
refusal(&filter, &edge(Evidence::Dynamic, Relation::Calls), false),
Some(("dynamic", "evidence"))
);
assert_eq!(
refusal(&filter, &edge(Evidence::Scope, Relation::Calls), false),
Some(("scope-evidence", "evidence"))
);
let filter = EdgeFilter {
relations: Some(BTreeSet::from([Relation::Calls])),
..Default::default()
};
assert_eq!(
refusal(&filter, &edge(Evidence::Scip, Relation::Implements), false),
Some(("implements", "relations"))
);
}
#[test]
fn an_admitted_edge_into_an_out_of_scope_target_is_refused_by_scope() {
let filter = EdgeFilter::default();
assert_eq!(
refusal(&filter, &edge(Evidence::Scip, Relation::Calls), true),
Some(("scope", "scope"))
);
}
#[test]
fn frontier_ranks_by_shared_file_prefix() {
let target = "crates/a/src/x.rs";
let mut files = ["crates/b/src/y.rs", "crates/a/src/z.rs", "other.rs"];
files.sort_by_key(|f| std::cmp::Reverse(shared_prefix(f, target)));
assert_eq!(files[0], "crates/a/src/z.rs");
}
#[test]
fn excluded_counts_key_on_the_refusing_attribute() {
let filter = EdgeFilter {
min_confidence: Some(Confidence::Certain),
..Default::default()
};
let mut counts: BTreeMap<&'static str, usize> = BTreeMap::new();
for e in [
edge(Evidence::Dynamic, Relation::Calls),
edge(Evidence::Import, Relation::Uses),
edge(Evidence::Scip, Relation::Calls),
] {
if let Some((reason, _)) = refusal(&filter, &e, false) {
*counts.entry(reason).or_default() += 1;
}
}
assert_eq!(counts, BTreeMap::from([("inferred", 2)]));
}
}