use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use std::process::Command;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sinter_core::{Confidence, Evidence, Relation, UnresolvedReason, UnresolvedReference};
use sinter_store::{EdgeFilter, Store};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct TraversalEvidence {
pub certain: usize,
pub possible: usize,
pub unresolved: usize,
}
impl TraversalEvidence {
pub fn from_confidences(
confidences: impl IntoIterator<Item = Confidence>,
unresolved: usize,
) -> Self {
let mut evidence = Self {
unresolved,
..Self::default()
};
for confidence in confidences {
match confidence {
Confidence::Certain => evidence.certain += 1,
Confidence::Inferred => evidence.possible += 1,
}
}
evidence
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct GraphHealth {
syntax_error_files: BTreeSet<String>,
failed_files: BTreeMap<String, String>,
}
fn health_path(repo: &Path) -> std::path::PathBuf {
repo.join(".sinter").join("health.json")
}
fn read_health(repo: &Path) -> GraphHealth {
std::fs::read(health_path(repo))
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
pub fn record_health(
repo: &Path,
touched: &[&str],
removed: &[String],
syntax_errors: &[String],
failures: &[(String, String)],
) -> Result<()> {
let mut health = read_health(repo);
for file in touched
.iter()
.copied()
.chain(removed.iter().map(String::as_str))
{
health.syntax_error_files.remove(file);
health.failed_files.remove(file);
}
health
.syntax_error_files
.extend(syntax_errors.iter().cloned());
health.failed_files.extend(failures.iter().cloned());
let path = health_path(repo);
let bytes = serde_json::to_vec_pretty(&health)?;
if std::fs::read(&path).ok().as_deref() == Some(bytes.as_slice()) {
return Ok(());
}
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, bytes).with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
Ok(())
}
fn git_output(repo: &Path, args: &[&str]) -> Option<String> {
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.ok()?;
output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum UnresolvedCategory {
LikelyExternal,
MissingCompilerIndex,
MissingReceiverType,
AmbiguousInternalTarget,
UnsupportedSyntax,
ActionableAnchoredMiss,
}
impl UnresolvedCategory {
pub const fn as_str(self) -> &'static str {
match self {
Self::LikelyExternal => "likely_external",
Self::MissingCompilerIndex => "missing_compiler_index",
Self::MissingReceiverType => "missing_receiver_type",
Self::AmbiguousInternalTarget => "ambiguous_internal_target",
Self::UnsupportedSyntax => "unsupported_syntax",
Self::ActionableAnchoredMiss => "actionable_anchored_miss",
}
}
pub const fn is_actionable(self) -> bool {
matches!(
self,
Self::MissingReceiverType
| Self::AmbiguousInternalTarget
| Self::ActionableAnchoredMiss
)
}
}
pub struct Classifier {
definitions: std::collections::HashMap<String, usize>,
syntax_error_files: BTreeSet<String>,
unindexed_languages: Vec<String>,
}
impl Classifier {
pub fn new(repo: &Path, store: &Store, refs: &[UnresolvedReference]) -> Result<Self> {
let mut definitions = std::collections::HashMap::new();
for item in refs {
let name = item.reference.name.as_str();
if !definitions.contains_key(name) {
let count = store.nodes_named(name)?.len();
definitions.insert(name.to_owned(), count);
}
}
let unindexed_languages = match crate::scip::staleness(repo) {
crate::scip::Staleness::Fresh => Vec::new(),
_ => crate::scip::indexable_languages(repo),
};
Ok(Self {
definitions,
syntax_error_files: read_health(repo).syntax_error_files,
unindexed_languages,
})
}
pub fn classify(&self, item: &UnresolvedReference) -> UnresolvedCategory {
let reference = &item.reference;
let is_identifier = reference
.name
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '$');
if !is_identifier || self.syntax_error_files.contains(&reference.file) {
return UnresolvedCategory::UnsupportedSyntax;
}
let defined = self
.definitions
.get(reference.name.as_str())
.copied()
.unwrap_or(0);
if item.reason == UnresolvedReason::CompilerUnresolved || defined == 0 {
return UnresolvedCategory::LikelyExternal;
}
let has_receiver = reference.path.as_deref().is_some_and(|path| {
path.trim_end_matches(reference.name.as_str())
.ends_with(['.', ':', '>'])
});
if item.reason == UnresolvedReason::SyntaxAnchoredMiss && !has_receiver {
return UnresolvedCategory::ActionableAnchoredMiss;
}
let language = sinter_extract::spec_for_path(&reference.file).map(|spec| spec.name);
if language.is_some_and(|lang| self.unindexed_languages.iter().any(|l| l == lang)) {
return UnresolvedCategory::MissingCompilerIndex;
}
if reference.relation == Relation::Calls && has_receiver {
UnresolvedCategory::MissingReceiverType
} else if item.reason == UnresolvedReason::SyntaxAnchoredMiss {
UnresolvedCategory::ActionableAnchoredMiss
} else {
UnresolvedCategory::AmbiguousInternalTarget
}
}
}
pub fn category_counts(
classifier: &Classifier,
refs: &[UnresolvedReference],
) -> BTreeMap<&'static str, usize> {
let mut counts = BTreeMap::new();
for item in refs {
*counts
.entry(classifier.classify(item).as_str())
.or_default() += 1;
}
counts
}
fn repository_coverage(repo: &Path, store: &Store) -> Result<serde_json::Value> {
let repo = crate::pipeline::discover_root(repo);
let health = read_health(&repo);
let head = git_output(&repo, &["rev-parse", "HEAD"]);
let dirty = git_output(
&repo,
&["status", "--porcelain=v1", "--untracked-files=normal"],
)
.map(|status| {
status
.lines()
.any(|line| !line.get(3..).unwrap_or("").starts_with(".sinter/"))
});
let indexing_projects = crate::scip::indexing_projects(&repo);
let indexable_languages: BTreeSet<&str> = indexing_projects
.iter()
.flat_map(|project| project.languages.iter().map(String::as_str))
.collect();
let runnable_indexing = indexing_projects
.iter()
.any(|project| project.recommendation.is_some());
let unavailable_indexing = indexing_projects
.iter()
.any(|project| project.status == "indexer_unavailable");
let unconfigured_languages = crate::scip::unconfigured_indexable_languages(&repo);
let (scip_state, stale_inputs) = match crate::scip::staleness(&repo) {
crate::scip::Staleness::Fresh => ("fresh", 0),
crate::scip::Staleness::Missing => ("missing", 0),
crate::scip::Staleness::Stale(n) => ("stale", n),
};
let unresolved = store.all_unresolved_details()?;
let mut reasons = BTreeMap::<&str, usize>::new();
for item in &unresolved {
*reasons.entry(item.reason.as_str()).or_default() += 1;
}
let classifier = Classifier::new(&repo, store, &unresolved)?;
let categories = category_counts(&classifier, &unresolved);
let actionable = unresolved
.iter()
.filter(|item| classifier.classify(item).is_actionable())
.count();
let waiting_on_scip = categories
.get(UnresolvedCategory::MissingCompilerIndex.as_str())
.copied()
.unwrap_or(0);
let waiting_suffix = if waiting_on_scip > 0 {
format!(" · {waiting_on_scip} refs waiting on `sinter scip`")
} else {
String::new()
};
let mut limitations = vec![
"a missing graph edge is not proof that no runtime path exists".to_string(),
"dynamic dispatch edges are conservative candidates, not dependency-injection proof"
.to_string(),
];
if scip_state == "missing" && runnable_indexing {
limitations.push(format!(
"compiler index missing for configured {} project(s); run `sinter scip`{waiting_suffix}",
indexable_languages
.iter()
.copied()
.collect::<Vec<_>>()
.join(", ")
));
} else if scip_state == "missing" && !indexing_projects.is_empty() {
limitations.push(
"compiler index missing for configured projects, but their indexers are unavailable; inspect compiler_index.projects for install guidance"
.to_string(),
);
} else if scip_state == "missing" && !unconfigured_languages.is_empty() {
limitations.push(format!(
"compiler index missing for {} source files, but no configured SCIP project was detected; no indexing command is recommended",
unconfigured_languages.join(", ")
));
} else if scip_state == "stale" && runnable_indexing {
limitations.push(format!(
"compiler index is stale ({stale_inputs} newer source/config inputs); run `sinter scip`{waiting_suffix}"
));
} else if scip_state == "stale" && unavailable_indexing {
limitations.push(format!(
"compiler index is stale ({stale_inputs} newer source/config inputs), but the required indexers are unavailable; inspect compiler_index.projects for install guidance"
));
} else if scip_state == "stale" {
limitations.push(format!(
"compiler index is stale ({stale_inputs} newer source/config inputs), but no configured SCIP project needs a runnable refresh"
));
}
if !health.failed_files.is_empty() {
limitations.push("one or more files failed extraction and are unindexed".to_string());
}
if !health.syntax_error_files.is_empty() {
limitations.push("one or more files were indexed from partial syntax trees".to_string());
}
if actionable > 0 {
limitations.push(format!(
"{actionable} unresolved references point inside this repository; `sinter unresolved` lists them by category"
));
}
let completeness = if scip_state == "fresh"
&& health.failed_files.is_empty()
&& health.syntax_error_files.is_empty()
&& actionable == 0
{
"complete_for_indexed_snapshot"
} else {
"partial"
};
let available_sources = [
("structural", "available", "certain"),
("scope", "available", "possible"),
("import", "available", "possible"),
("dynamic", "available", "possible"),
(
"scip",
if scip_state == "fresh" {
"available"
} else {
scip_state
},
"certain",
),
]
.into_iter()
.map(|(kind, status, certainty)| {
serde_json::json!({
"kind": kind,
"status": status,
"certainty": certainty,
})
})
.collect::<Vec<_>>();
Ok(serde_json::json!({
"completeness": completeness,
"conclusive": false,
"snapshot": {
"head": head,
"dirty": dirty,
"working_tree_indexed": true,
"node_id_scope": "snapshot",
"graph_schema": Store::CURRENT_SCHEMA,
},
"compiler_index": {
"state": scip_state,
"indexable_languages": indexable_languages.into_iter().collect::<Vec<_>>(),
"stale_inputs": stale_inputs,
"projects": indexing_projects,
"unconfigured_languages": unconfigured_languages,
},
"graph": {
"unresolved_references": unresolved.len(),
"unresolved_by_reason": reasons,
"unresolved_by_category": categories,
"actionable_unresolved": actionable,
"missing_compiler_index": waiting_on_scip,
"syntax_error_files": health.syntax_error_files,
"unindexed_files": health.failed_files.keys().collect::<Vec<_>>(),
"excluded_derived_roots": crate::corpus::DERIVED_ROOTS,
},
"available_sources": available_sources,
"limitations": limitations,
}))
}
pub(crate) fn orientation_health_json(repo: &Path, store: &Store) -> Result<serde_json::Value> {
let coverage = repository_coverage(repo, store)?;
let graph = &coverage["graph"];
let count = |field: &str| graph[field].as_array().map_or(0, std::vec::Vec::len);
Ok(serde_json::json!({
"status": coverage["completeness"].clone(),
"snapshot": coverage["snapshot"].clone(),
"compiler_index": {
"state": coverage["compiler_index"]["state"].clone(),
"indexable_languages": coverage["compiler_index"]["indexable_languages"].clone(),
"stale_inputs": coverage["compiler_index"]["stale_inputs"].clone(),
},
"graph": {
"unresolved_references": graph["unresolved_references"].clone(),
"actionable_unresolved": graph["actionable_unresolved"].clone(),
"missing_compiler_index": graph["missing_compiler_index"].clone(),
"syntax_error_files": count("syntax_error_files"),
"unindexed_files": count("unindexed_files"),
},
"limitations": coverage["limitations"].clone(),
}))
}
fn filter_json(filter: &EdgeFilter) -> serde_json::Value {
let relation_values = filter
.relations
.as_ref()
.map(|relations| {
relations
.iter()
.map(|relation| relation.as_str())
.collect::<Vec<_>>()
})
.unwrap_or_else(|| {
[
Relation::Calls,
Relation::Uses,
Relation::Imports,
Relation::Implements,
Relation::Extends,
]
.into_iter()
.map(Relation::as_str)
.collect()
});
let evidence_values = filter
.evidence
.as_ref()
.map(|evidence| {
evidence
.iter()
.map(|item| item.as_str())
.collect::<Vec<_>>()
})
.unwrap_or_else(|| {
[
Evidence::Structural,
Evidence::Scope,
Evidence::Import,
Evidence::Scip,
Evidence::Declared,
Evidence::Dynamic,
]
.into_iter()
.map(Evidence::as_str)
.collect()
});
let scope_values = filter
.scopes
.as_ref()
.map(|scopes| {
scopes
.iter()
.map(|scope| scope.as_str())
.collect::<Vec<_>>()
})
.unwrap_or_else(|| {
sinter_core::CorpusScope::ALL
.into_iter()
.map(sinter_core::CorpusScope::as_str)
.collect()
});
serde_json::json!({
"relations": {
"mode": if filter.relations.is_some() { "restricted" } else { "all_dependencies" },
"values": relation_values,
},
"evidence": {
"mode": if filter.evidence.is_some() { "restricted" } else { "all_available" },
"values": evidence_values,
},
"min_confidence": if filter.min_confidence == Some(Confidence::Certain) {
"certain"
} else {
"any"
},
"scope": {
"mode": if filter.scopes.is_some() { "restricted" } else { "all" },
"values": scope_values,
},
})
}
pub fn traversal_json(
repo: &Path,
store: &Store,
filter: &EdgeFilter,
evidence: TraversalEvidence,
found: bool,
) -> Result<serde_json::Value> {
let mut coverage = repository_coverage(repo, store)?;
coverage["status"] = serde_json::json!(if found { "found" } else { "not_proven" });
coverage["filters"] = filter_json(filter);
coverage["evidence"] = serde_json::json!({
"count_scope": "all_matches_before_limit",
"certain": {"results": evidence.certain},
"possible": {"results": evidence.possible},
"unresolved": {
"matching_query": evidence.unresolved,
"repository_total": coverage["graph"]["unresolved_references"],
"actionable": coverage["graph"]["actionable_unresolved"],
"missing_compiler_index": coverage["graph"]["missing_compiler_index"],
},
});
Ok(coverage)
}
pub fn print_traversal(
repo: &Path,
store: &Store,
filter: &EdgeFilter,
evidence: TraversalEvidence,
found: bool,
) -> Result<()> {
let coverage = traversal_json(repo, store, filter, evidence, found)?;
println!(
" coverage: {} ({} certain, {} possible, {} unresolved matching query; never runtime proof)",
coverage["completeness"].as_str().unwrap_or("partial"),
coverage["evidence"]["certain"]["results"]
.as_u64()
.unwrap_or(0),
coverage["evidence"]["possible"]["results"]
.as_u64()
.unwrap_or(0),
coverage["evidence"]["unresolved"]["matching_query"]
.as_u64()
.unwrap_or(0),
);
let relations = coverage["filters"]["relations"]["values"]
.as_array()
.into_iter()
.flatten()
.filter_map(serde_json::Value::as_str)
.collect::<Vec<_>>()
.join(",");
println!(
" filters: relations={relations} min_confidence={} scope={}",
coverage["filters"]["min_confidence"]
.as_str()
.unwrap_or("any"),
coverage["filters"]["scope"]["values"]
.as_array()
.into_iter()
.flatten()
.filter_map(serde_json::Value::as_str)
.collect::<Vec<_>>()
.join(",")
);
if let Some(items) = coverage["limitations"].as_array() {
for item in items {
if let Some(text) = item.as_str() {
println!(" gap: {text}");
}
}
}
Ok(())
}
pub fn workspace_json(
workspace: &crate::workspace::Workspace,
filter: &EdgeFilter,
evidence: TraversalEvidence,
found: bool,
) -> Result<serde_json::Value> {
let mut members = serde_json::Map::new();
let mut gaps = Vec::new();
let mut partial = false;
for (name, repo) in &workspace.members {
let store = Store::open(crate::pipeline::db_path(repo))?;
let member = repository_coverage(repo, &store)?;
partial |= member["completeness"] == "partial";
if let Some(items) = member["limitations"].as_array() {
gaps.extend(items.iter().filter_map(|item| {
item.as_str()
.map(|text| serde_json::json!({"member": name, "message": text}))
}));
}
members.insert(name.clone(), member);
}
Ok(serde_json::json!({
"status": if found { "found" } else { "not_proven" },
"completeness": if partial { "partial" } else { "complete_for_indexed_snapshot" },
"conclusive": false,
"filters": filter_json(filter),
"evidence": {
"count_scope": "all_matches_before_limit",
"certain": {"results": evidence.certain},
"possible": {"results": evidence.possible},
"unresolved": {"matching_query": evidence.unresolved},
},
"available_sources": {
"member_graphs": "available",
"boundary_imports": "available",
"declared_manifest_links": "available",
},
"members": members,
"gaps": gaps,
"limitations": [
"a workspace graph path is bounded by member extraction/index coverage and declared boundary links",
"undeclared runtime coupling cannot be inferred as an exhaustive dependency path",
],
}))
}
pub fn print_workspace_traversal(
workspace: &crate::workspace::Workspace,
filter: &EdgeFilter,
evidence: TraversalEvidence,
found: bool,
) -> Result<()> {
let coverage = workspace_json(workspace, filter, evidence, found)?;
println!(
" coverage: {} ({} certain, {} possible, {} unresolved matching query; never runtime proof)",
coverage["completeness"].as_str().unwrap_or("partial"),
coverage["evidence"]["certain"]["results"]
.as_u64()
.unwrap_or(0),
coverage["evidence"]["possible"]["results"]
.as_u64()
.unwrap_or(0),
coverage["evidence"]["unresolved"]["matching_query"]
.as_u64()
.unwrap_or(0),
);
if let Some(gaps) = coverage["gaps"].as_array() {
for gap in gaps {
println!(
" gap: {}: {}",
gap["member"].as_str().unwrap_or("unknown"),
gap["message"].as_str().unwrap_or("coverage unavailable")
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use sinter_core::{
Confidence, Evidence, Reference, Relation, Span, UnresolvedReason, UnresolvedReference,
};
use sinter_store::{EdgeFilter, Store};
use super::{
Classifier, TraversalEvidence, UnresolvedCategory, orientation_health_json, traversal_json,
};
fn item(name: &str, path: Option<&str>, reason: UnresolvedReason) -> UnresolvedReference {
UnresolvedReference {
reference: Reference {
file: "src/lib.rs".into(),
name: name.into(),
path: path.map(str::to_owned),
relation: Relation::Calls,
span: Span { start: 0, end: 1 },
enclosing: None,
alias: None,
},
reason,
}
}
fn classifier(defined: &[(&str, usize)], unindexed: &[&str]) -> Classifier {
Classifier {
definitions: defined
.iter()
.map(|(name, count)| ((*name).to_owned(), *count))
.collect(),
syntax_error_files: Default::default(),
unindexed_languages: unindexed.iter().map(|l| (*l).to_owned()).collect(),
}
}
#[test]
fn undefined_names_are_external_and_anchored_misses_stay_actionable() {
let c = classifier(&[("walk", 2), ("run", 1)], &["rust"]);
assert_eq!(
c.classify(&item("unwrap", None, UnresolvedReason::SyntaxOnly)),
UnresolvedCategory::LikelyExternal
);
assert_eq!(
c.classify(&item("walk", None, UnresolvedReason::SyntaxAnchoredMiss)),
UnresolvedCategory::ActionableAnchoredMiss
);
assert_eq!(
c.classify(&item("walk", None, UnresolvedReason::SyntaxOnly)),
UnresolvedCategory::MissingCompilerIndex
);
assert_eq!(
c.classify(&item(":", None, UnresolvedReason::SyntaxOnly)),
UnresolvedCategory::UnsupportedSyntax
);
}
#[test]
fn receiver_calls_and_bare_names_split_when_no_index_applies() {
let c = classifier(&[("walk", 2), ("run", 1)], &[]);
assert_eq!(
c.classify(&item(
"run",
Some("self.job.run"),
UnresolvedReason::SyntaxOnly
)),
UnresolvedCategory::MissingReceiverType
);
assert_eq!(
c.classify(&item("walk", None, UnresolvedReason::SyntaxOnly)),
UnresolvedCategory::AmbiguousInternalTarget
);
}
#[test]
fn traversal_evidence_never_folds_possible_into_certain() {
let evidence = TraversalEvidence::from_confidences(
[
Confidence::Certain,
Confidence::Inferred,
Confidence::Inferred,
],
4,
);
assert_eq!(evidence.certain, 1);
assert_eq!(evidence.possible, 2);
assert_eq!(evidence.unresolved, 4);
}
#[test]
fn positive_scip_backed_result_is_certain_but_only_snapshot_complete() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
std::fs::create_dir_all(repo.join("src")).unwrap();
std::fs::create_dir_all(repo.join(".sinter")).unwrap();
std::fs::write(
repo.join("Cargo.toml"),
"[package]\nname='fixture'\nversion='0.1.0'\n",
)
.unwrap();
std::fs::write(repo.join("src/lib.rs"), "pub fn source() {}\n").unwrap();
std::fs::write(repo.join(".sinter/index.scip"), []).unwrap();
let store = Store::create(repo.join(".sinter/graph.redb")).unwrap();
let filter = EdgeFilter {
evidence: Some(BTreeSet::from([Evidence::Scip])),
min_confidence: Some(Confidence::Certain),
relations: Some(BTreeSet::from([Relation::Calls])),
scopes: None,
};
let coverage = traversal_json(
repo,
&store,
&filter,
TraversalEvidence::from_confidences([Confidence::Certain], 0),
true,
)
.unwrap();
assert_eq!(coverage["status"], "found");
assert_eq!(coverage["completeness"], "complete_for_indexed_snapshot");
assert_eq!(coverage["conclusive"], false);
assert_eq!(coverage["evidence"]["certain"]["results"], 1);
assert_eq!(coverage["evidence"]["possible"]["results"], 0);
assert_eq!(coverage["filters"]["evidence"]["values"][0], "scip");
assert!(
coverage["available_sources"]
.as_array()
.unwrap()
.iter()
.any(|source| source["kind"] == "scip" && source["status"] == "available")
);
}
#[test]
fn orientation_health_is_compact_and_names_complete_snapshot() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
std::fs::create_dir_all(repo.join("src")).unwrap();
std::fs::create_dir_all(repo.join(".sinter")).unwrap();
std::fs::write(
repo.join("Cargo.toml"),
"[package]\nname='fixture'\nversion='0.1.0'\n",
)
.unwrap();
std::fs::write(repo.join("src/lib.rs"), "pub fn source() {}\n").unwrap();
std::fs::write(repo.join(".sinter/index.scip"), []).unwrap();
let store = Store::create(repo.join(".sinter/graph.redb")).unwrap();
let health = orientation_health_json(repo, &store).unwrap();
assert_eq!(health["status"], "complete_for_indexed_snapshot");
assert_eq!(health["compiler_index"]["state"], "fresh");
assert_eq!(health["graph"]["actionable_unresolved"], 0);
assert!(health["compiler_index"].get("projects").is_none());
assert!(health.get("available_sources").is_none());
}
}