use std::collections::BTreeSet;
use std::path::Path;
use anyhow::Result;
use serde_json::{Value, json};
use sinter_core::{Confidence, Node, NodeId, Relation, SymbolKind};
use sinter_resolve::qualified_of;
use sinter_store::{EdgeFilter, Reached, Store};
use crate::ask::confidence::HIGH_MARGIN_PERMILLE;
use crate::corpus::ScopeSelection;
use crate::lookup::{Found, ensure_snapshot, find_symbol, open_store};
use crate::render::{ellipsize, line_of};
const ASK_LIMIT: usize = 5;
const MAX_FOCUS: usize = 3;
const EDGE_ROWS: usize = 8;
const TEST_ROWS: usize = crate::impact::DEFAULT_LIMIT;
const EXCERPT_LINES: usize = 12;
const PRINTED_TESTS: usize = 6;
const MIN_WORD_LEN: usize = 3;
const MAX_IDENTIFIERS: usize = 12;
const MAX_ANCHOR_NODES: usize = 3;
const RG_TERMS: usize = 4;
const FILE_CANDIDATES: usize = 10;
const SOURCE_EXTENSIONS: &[&str] = &[
"rs", "go", "py", "ts", "tsx", "js", "java", "cs", "c", "cc", "cpp", "h", "hpp", "sql",
"proto", "md", "sh",
];
const TASK_STOPWORDS: &[&str] = &[
"a", "an", "the", "in", "on", "for", "to", "of", "and", "or", "make", "add", "new", "use",
"it", "this", "that", "with", "from", "by", "per", "into", "honor", "account",
];
const MIN_FRAGMENT_LEN: usize = 4;
fn is_path_like(token: &str) -> bool {
token.contains('/')
|| token
.rsplit_once('.')
.is_some_and(|(stem, ext)| !stem.is_empty() && SOURCE_EXTENSIONS.contains(&ext))
}
fn is_task_stopword(word: &str) -> bool {
TASK_STOPWORDS.contains(&word) || crate::ask::query::is_stopword(word)
}
fn lexical_query(task: &str) -> String {
let mut words: Vec<&str> = Vec::new();
for raw in task.split_whitespace() {
let token = raw.trim_matches(|c: char| !c.is_alphanumeric() && c != '_');
if token.is_empty() || is_path_like(token) {
continue;
}
if token.contains('-') {
words.extend(
token
.split('-')
.filter(|part| part.chars().count() >= MIN_FRAGMENT_LEN)
.filter(|part| !is_task_stopword(&part.to_lowercase())),
);
continue;
}
if !is_task_stopword(&token.to_lowercase()) {
words.push(token);
}
}
if words.is_empty() {
task.to_owned()
} else {
words.join(" ")
}
}
fn is_contender(score: i64, top: i64) -> bool {
top > 0 && score * 1000 >= top * (1000 - HIGH_MARGIN_PERMILLE)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum Shape {
Explicit,
Shaped,
Bare,
}
fn shape_of(token: &str) -> Option<Shape> {
if token.is_empty() || token.starts_with(|c: char| c.is_ascii_digit()) {
return None;
}
if !token
.chars()
.all(|c| c.is_alphanumeric() || matches!(c, '_' | ':' | '/' | '.' | '-'))
{
return None;
}
if token.contains("::") || is_path_like(token) {
return Some(Shape::Explicit);
}
if token.contains('-') || token.contains('.') {
return None;
}
if token.contains('_') || token.contains(char::is_uppercase) {
return Some(Shape::Shaped);
}
if token.chars().count() < MIN_WORD_LEN || is_task_stopword(token) {
return None;
}
Some(Shape::Bare)
}
fn identifier_candidates(task: &str) -> Vec<(Shape, String)> {
let mut out: Vec<(Shape, String)> = Vec::new();
for raw in task.split_whitespace() {
let token = raw.trim_matches(|c: char| !c.is_alphanumeric() && c != '_');
let Some(shape) = shape_of(token) else {
continue;
};
if !out.iter().any(|(_, kept)| kept == token) {
out.push((shape, token.to_owned()));
}
if out.len() == MAX_IDENTIFIERS {
break;
}
}
out.sort_by_key(|(shape, _)| *shape);
out
}
struct Anchor {
term: String,
node: Node,
contained: Vec<Node>,
}
#[derive(Default)]
struct Grounding {
anchors: Vec<Anchor>,
unresolved: Vec<String>,
ambiguous_files: Vec<(String, Vec<String>)>,
}
fn handle_of(node: &Node) -> String {
if node.kind == SymbolKind::File {
node.file.clone()
} else {
format!("{}@{}", node.name, node.file)
}
}
fn file_anchor(store: &Store, term: &str) -> Result<Result<Option<Anchor>, Vec<String>>> {
let wanted = term.trim_start_matches("./");
let suffix = format!("/{wanted}");
let mut paths: Vec<String> = store
.file_hashes()?
.into_iter()
.map(|(path, _)| path)
.filter(|path| path == wanted || path.ends_with(&suffix))
.collect();
paths.sort();
let path = match paths.as_slice() {
[] => return Ok(Ok(None)),
[path] => path.clone(),
_ => return Ok(Err(paths)),
};
let Some(facts) = store.facts(&path)? else {
return Ok(Ok(None));
};
let (files, symbols): (Vec<Node>, Vec<Node>) = facts
.nodes
.into_iter()
.partition(|n| n.kind == SymbolKind::File);
let Some(node) = files.into_iter().next() else {
return Ok(Ok(None));
};
let ids: Vec<NodeId> = symbols.iter().map(|n| n.id.clone()).collect();
let in_edges = store.in_edges_many(&ids)?;
let degree = |n: &Node| {
in_edges.get(&n.id).map_or(0, |edges| {
edges
.iter()
.filter(|e| e.relation != Relation::Contains)
.count()
})
};
let mut contained: Vec<(usize, Node)> = symbols.into_iter().map(|n| (degree(&n), n)).collect();
contained.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.id.cmp(&b.1.id)));
Ok(Ok(Some(Anchor {
term: term.to_owned(),
node,
contained: contained
.into_iter()
.take(FILE_CANDIDATES)
.map(|(_, n)| n)
.collect(),
})))
}
fn anchors_of(store: &Store, task: &str) -> Result<Grounding> {
let scope_index = store.scope_index()?;
let preferred = ScopeSelection::agent_default().as_set();
let mut g = Grounding::default();
for (shape, term) in identifier_candidates(task) {
if g.anchors.len() == MAX_FOCUS {
break;
}
if is_path_like(&term) {
match file_anchor(store, &term)? {
Ok(Some(anchor)) => g.anchors.push(anchor),
Ok(None) => g.unresolved.push(term),
Err(paths) => g.ambiguous_files.push((term, paths)),
}
continue;
}
let nodes = match find_symbol(store, &term)? {
Found::Exact(nodes) if nodes.len() <= MAX_ANCHOR_NODES => nodes,
_ => {
g.unresolved.push(term);
continue;
}
};
let grounded: Vec<Node> = nodes
.into_iter()
.filter(|n| shape != Shape::Bare || qualified_of(n.id.as_str()) == term)
.filter(|n| preferred.contains(&scope_index.scope_of(n)))
.collect();
if grounded.is_empty() {
g.unresolved.push(term);
continue;
}
for node in grounded {
if g.anchors.len() == MAX_FOCUS {
break;
}
if g.anchors.iter().all(|a| a.node.id != node.id) {
g.anchors.push(Anchor {
term: term.clone(),
node,
contained: Vec::new(),
});
}
}
}
Ok(g)
}
fn anchor_hit(node: &Node, term: &str) -> Value {
json!({
"id": node.symbol_key().as_str(),
"snapshot_id": node.id.as_str(),
"doc": node.doc,
"matched": [term],
"roles": ["anchor"],
"channels": ["identifier"],
})
}
fn test_node(store: &Store, test: &crate::impact::SymbolRef) -> Option<Node> {
match find_symbol(store, &format!("{}@{}", test.qualified, test.file)) {
Ok(Found::Exact(mut nodes)) if !nodes.is_empty() => Some(nodes.remove(0)),
_ => None,
}
}
fn ranked_hits(ask: &Value) -> Vec<Value> {
let mut hits: Vec<Value> = ask["topics"]
.as_array()
.into_iter()
.flatten()
.flat_map(|topic| topic["hits"].as_array().into_iter().flatten().cloned())
.collect();
hits.sort_by(|a, b| b["score"].as_i64().cmp(&a["score"].as_i64()));
let mut seen = BTreeSet::new();
hits.retain(|hit| seen.insert(hit["id"].as_str().unwrap_or("").to_owned()));
hits
}
fn excerpt(repo: &Path, node: &Node) -> Option<String> {
let source = std::fs::read_to_string(repo.join(&node.file)).ok()?;
let start = (node.span.start as usize).min(source.len());
let end = (node.span.end as usize).min(source.len());
let body = source.get(start..end)?;
Some(
body.lines()
.take(EXCERPT_LINES)
.collect::<Vec<_>>()
.join("\n"),
)
}
fn edge_row(r: &Reached) -> Value {
json!({
"s": qualified_of(r.node.id.as_str()),
"k": r.node.kind.as_str(),
"f": r.node.file,
"e": format!("{}/{}", r.via.relation.as_str(), r.via.evidence.as_str()),
})
}
fn rows(reached: &[&Reached]) -> Vec<Value> {
reached
.iter()
.take(EDGE_ROWS)
.map(|r| edge_row(r))
.collect()
}
fn card(
repo: &Path,
store: &Store,
hit: &Value,
node: &Node,
filter: &EdgeFilter,
confidences: &mut Vec<Confidence>,
) -> Result<Value> {
let deps = store.dependencies(&node.id, filter, 1)?;
let dependents = store.dependents(&node.id, filter, 1)?;
confidences.extend(
deps.iter()
.chain(dependents.iter())
.map(|r| r.via.confidence),
);
let (callers, importers): (Vec<&Reached>, Vec<&Reached>) = dependents
.iter()
.partition(|r| r.via.relation != Relation::Imports);
let (direct, direct_files) = sinter_store::direct_summary(&dependents);
let dep_refs: Vec<&Reached> = deps.iter().collect();
Ok(json!({
"id": hit["id"],
"handle": handle_of(node),
"qualified": qualified_of(node.id.as_str()),
"name": node.name,
"kind": node.kind.as_str(),
"file": node.file,
"line": line_of(repo, &node.file, node.span.start),
"end_line": line_of(repo, &node.file, node.span.end),
"signature": node.signature,
"doc": hit["doc"],
"excerpt": excerpt(repo, node),
"why": {"matched": hit["matched"], "roles": hit["roles"], "channels": hit["channels"]},
"deps": {"total": deps.len(), "direct": rows(&dep_refs)},
"affected": {
"direct": direct,
"direct_files": direct_files,
"callers": rows(&callers),
"importing_files": importers.len(),
"importers": rows(&importers),
},
}))
}
pub(crate) fn response(repo: &Path, store: &Store, task: &str) -> Result<Value> {
let root = crate::pipeline::discover_root(repo).canonicalize()?;
let snapshot = ensure_snapshot(store, None)?;
let ask = crate::ask::ask_response_with_store(
&root,
store,
&lexical_query(task),
ASK_LIMIT,
&ScopeSelection::ask_default(),
false,
)?;
let abstain = ask["decision"] == "abstain";
let abstain_reason = ask["topics"]
.as_array()
.into_iter()
.flatten()
.find(|topic| topic["status"] == "abstain")
.map(|topic| topic["confidence"]["reason"].clone())
.unwrap_or(Value::Null);
let Grounding {
anchors,
unresolved: unresolved_intents,
ambiguous_files,
} = anchors_of(store, task)?;
let hits = ranked_hits(&ask);
let top = hits.first().and_then(|h| h["score"].as_i64()).unwrap_or(0);
let filter = EdgeFilter::default();
let mut candidates = Vec::with_capacity(hits.len() + anchors.len());
let mut focus: Vec<Node> = Vec::new();
let mut confidences = Vec::new();
let mut unresolved = 0usize;
let mut rank = 0usize;
for anchor in &anchors {
let hit = anchor_hit(&anchor.node, &anchor.term);
let mut entry = card(&root, store, &hit, &anchor.node, &filter, &mut confidences)?;
rank += 1;
entry["rank"] = json!(rank);
entry["score"] = Value::Null;
entry["focus"] = json!(true);
entry["anchor"] = json!(anchor.term);
unresolved += store.unresolved_named(&anchor.node.name)?;
focus.push(anchor.node.clone());
candidates.push(entry);
}
for anchor in &anchors {
for node in &anchor.contained {
if candidates.iter().any(|c| c["handle"] == handle_of(node)) {
continue;
}
rank += 1;
candidates.push(json!({
"id": node.symbol_key().as_str(),
"handle": handle_of(node),
"qualified": qualified_of(node.id.as_str()),
"kind": node.kind.as_str(),
"file": node.file,
"line": line_of(&root, &node.file, node.span.start),
"why": {"matched": [anchor.term], "roles": ["contained"], "channels": ["file"]},
"rank": rank,
"score": Value::Null,
"focus": false,
}));
}
}
let anchored: BTreeSet<String> = anchors
.iter()
.flat_map(|a| std::iter::once(&a.node).chain(a.contained.iter()))
.map(|n| n.id.as_str().to_owned())
.collect();
for hit in &hits {
let id = NodeId::new(hit["snapshot_id"].as_str().unwrap_or(""));
if anchored.contains(id.as_str()) {
continue;
}
let Some(node) = store.node(&id)? else {
continue;
};
let score = hit["score"].as_i64().unwrap_or(0);
let expand = anchors.is_empty()
&& focus.len() < MAX_FOCUS
&& (rank == 0 || (!abstain && is_contender(score, top)));
let mut entry = if expand {
card(&root, store, hit, &node, &filter, &mut confidences)?
} else {
json!({
"id": hit["id"],
"handle": format!("{}@{}", node.name, node.file),
"qualified": qualified_of(node.id.as_str()),
"kind": node.kind.as_str(),
"file": node.file,
"line": hit["line"],
"why": {"matched": hit["matched"], "roles": hit["roles"], "channels": hit["channels"]},
})
};
rank += 1;
entry["rank"] = json!(rank);
entry["score"] = json!(score);
entry["focus"] = json!(expand);
if expand {
unresolved += store.unresolved_named(&node.name)?;
focus.push(node);
}
candidates.push(entry);
}
let radius = crate::impact::blast_radius(store, &filter, &focus)?;
let tests = crate::impact::affected_tests(store, &radius, &focus)?;
let tests_total = tests.len();
let test_rows: Vec<Value> = tests
.iter()
.take(TEST_ROWS)
.map(|t| {
let cmd = test_node(store, t).and_then(|n| crate::impact::test_command(&root, &n));
json!({"qualified": t.qualified, "kind": t.kind, "file": t.file, "cmd": cmd})
})
.collect();
let evidence = crate::coverage::TraversalEvidence::from_confidences(confidences, unresolved);
let coverage =
crate::coverage::traversal_json(&root, store, &filter, evidence, !focus.is_empty())?;
let grounded = !anchors.is_empty();
let mut next_actions: Vec<String> = Vec::new();
if !grounded && (abstain || focus.is_empty()) {
let terms = ask["topics"]
.as_array()
.into_iter()
.flatten()
.flat_map(|t| t["query_terms"].as_array().into_iter().flatten())
.filter_map(Value::as_str)
.filter(|term| term.len() > 2)
.take(RG_TERMS)
.collect::<Vec<_>>()
.join("|");
next_actions.push(format!("rg -n \"{terms}\""));
next_actions.push("sinter map".to_string());
next_actions.push("sinter ask \"<one concrete term from the task>\"".to_string());
}
for node in &focus {
let handle = handle_of(node);
next_actions.push(format!("sinter show {handle}"));
next_actions.push(format!("sinter affected {handle} --max-depth 3"));
}
next_actions
.push("sinter impact # after editing: changed symbols, blast radius, tests".to_string());
Ok(json!({
"task": task,
"snapshot": snapshot,
"outcome": if grounded || !(abstain || focus.is_empty()) { "ranked" } else { "abstain" },
"anchors": anchors
.iter()
.map(|a| json!({
"term": a.term,
"qualified": qualified_of(a.node.id.as_str()),
"k": a.node.kind.as_str(),
"f": a.node.file,
}))
.collect::<Vec<_>>(),
"unresolved_intents": unresolved_intents,
"candidates": candidates,
"tests": test_rows,
"tests_total": tests_total,
"gaps": {
"abstain_reason": abstain_reason,
"ambiguous_files": ambiguous_files
.iter()
.map(|(term, paths)| json!({"term": term, "candidates": paths}))
.collect::<Vec<_>>(),
"unresolved_refs_matching_candidates": unresolved,
"ask_advice": ask["topics"][0]["advice"],
},
"coverage": coverage,
"next_actions": next_actions,
}))
}
pub fn run(repo: &Path, task: &str, json: bool) -> Result<bool> {
let store = open_store(repo)?;
let packet = response(repo, &store, task)?;
let ranked = packet["outcome"] == "ranked";
if json {
crate::agent_protocol::write_json(&packet)?;
return Ok(ranked);
}
print_packet(&packet);
Ok(ranked)
}
fn one_line(text: &str, max: usize) -> String {
let mut out = String::new();
for word in text.split_whitespace() {
if !out.is_empty() && out.chars().count() + 1 + word.chars().count() > max {
out.push('\u{2026}');
break;
}
if !out.is_empty() {
out.push(' ');
}
out.push_str(word);
}
out
}
fn print_packet(p: &Value) {
let list = |v: &Value| -> Vec<String> {
v.as_array()
.into_iter()
.flatten()
.map(|r| {
format!(
"{} ({})",
r["s"].as_str().unwrap_or(""),
r["f"].as_str().unwrap_or("")
)
})
.collect()
};
println!(
"context: {} [{}]",
p["task"].as_str().unwrap_or(""),
p["outcome"].as_str().unwrap_or("")
);
let anchors: Vec<String> = p["anchors"]
.as_array()
.into_iter()
.flatten()
.map(|a| {
format!(
"{} -> {}",
a["term"].as_str().unwrap_or(""),
a["qualified"].as_str().unwrap_or("")
)
})
.collect();
if !anchors.is_empty() {
println!("anchors: {}", anchors.join(", "));
}
let intents: Vec<&str> = p["unresolved_intents"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.collect();
if !intents.is_empty() {
println!("unresolved intents: {}", intents.join(", "));
}
for a in p["gaps"]["ambiguous_files"]
.as_array()
.into_iter()
.flatten()
{
let paths: Vec<&str> = a["candidates"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.collect();
println!(
"ambiguous file: {} -> {}",
a["term"].as_str().unwrap_or(""),
ellipsize(&paths.join(", "), 100)
);
}
for c in p["candidates"].as_array().into_iter().flatten() {
let marker = if c["anchor"].is_string() {
"@"
} else if c["focus"] == true {
"*"
} else {
" "
};
println!(
"{marker}{}. {} {} {}:{} [{}]",
c["rank"],
c["kind"].as_str().unwrap_or(""),
c["qualified"].as_str().unwrap_or(""),
c["file"].as_str().unwrap_or(""),
c["line"],
c["why"]["matched"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join(" ")
);
if c["focus"] != true {
continue;
}
if let Some(sig) = c["signature"].as_str().filter(|s| !s.is_empty()) {
println!(" {}", ellipsize(sig, 100));
}
if let Some(doc) = c["doc"].as_str() {
println!(" /// {}", one_line(doc, 100));
}
let deps = list(&c["deps"]["direct"]);
println!(
" deps ({}): {}",
c["deps"]["total"],
ellipsize(&deps.join(", "), 110)
);
let callers = list(&c["affected"]["callers"]);
println!(
" affected: {} direct in {} file(s); {} importing file(s): {}",
c["affected"]["direct"],
c["affected"]["direct_files"],
c["affected"]["importing_files"],
ellipsize(&callers.join(", "), 90)
);
}
let tests = p["tests"].as_array().map_or(&[][..], Vec::as_slice);
println!(
"tests ({} affected, {} shown):",
p["tests_total"],
tests.len()
);
for t in tests.iter().take(PRINTED_TESTS) {
match t["cmd"].as_str() {
Some(cmd) => println!(" {cmd}"),
None => println!(
" # {} ({})",
t["qualified"].as_str().unwrap_or(""),
t["file"].as_str().unwrap_or("")
),
}
}
if let Some(rest) = tests.len().checked_sub(PRINTED_TESTS).filter(|n| *n > 0) {
println!(" # +{rest} more (--json for all)");
}
println!(
"gaps: coverage {}; unresolved refs naming candidates {}; abstain {}",
p["coverage"]["status"].as_str().unwrap_or("?"),
p["gaps"]["unresolved_refs_matching_candidates"],
p["gaps"]["abstain_reason"].as_str().unwrap_or("none")
);
println!("next:");
for a in p["next_actions"].as_array().into_iter().flatten() {
println!(" {}", a.as_str().unwrap_or(""));
}
println!(" snapshot: {}", p["snapshot"].as_str().unwrap_or(""));
}
#[cfg(test)]
mod tests {
use super::{Shape, identifier_candidates, lexical_query, one_line};
fn terms(task: &str) -> Vec<String> {
identifier_candidates(task)
.into_iter()
.map(|(_, t)| t)
.collect()
}
#[test]
fn extraction_keeps_identifier_shapes_and_drops_prose() {
let got = terms(
"add a new field to `Decision` and thread it through adjudication, see Store::node in src/ask/query.rs (don't break it)",
);
for name in [
"Decision",
"adjudication",
"Store::node",
"src/ask/query.rs",
] {
assert!(got.iter().any(|t| t == name), "lost `{name}`: {got:?}");
}
for prose in ["a", "to", "and", "it", "the", "don't"] {
assert!(!got.iter().any(|t| t == prose), "kept prose `{prose}`");
}
}
#[test]
fn strongest_shape_is_resolved_first() {
let got = identifier_candidates("thread a field through Decision via Store::node");
assert_eq!(got[0], (Shape::Explicit, "Store::node".to_owned()));
assert_eq!(got[1], (Shape::Shaped, "Decision".to_owned()));
assert_eq!(
got[2..].iter().map(|(_, t)| t.as_str()).collect::<Vec<_>>(),
["thread", "field", "through", "via"]
);
}
#[test]
fn extraction_is_deduplicated_and_bounded() {
let task = "Node Node ".repeat(20);
assert_eq!(terms(&task), vec!["Node".to_owned()]);
let many = (0..40)
.map(|i| format!("sym_{i}"))
.collect::<Vec<_>>()
.join(" ");
assert_eq!(terms(&many).len(), super::MAX_IDENTIFIERS);
}
#[test]
fn lexical_query_drops_files_filler_and_short_fragments() {
assert_eq!(
lexical_query("make take_budget honor a per-tool default budget in serve.rs"),
"take_budget tool default budget"
);
assert_eq!(
lexical_query("add a new placement Policy variant in src/placement.rs"),
"placement Policy variant"
);
assert_eq!(lexical_query("multi-thread it"), "multi thread");
assert_eq!(lexical_query("add a new"), "add a new");
}
#[test]
fn extraction_treats_hyphenated_prose_and_filler_as_noise() {
assert_eq!(
terms("honor a per-tool budget in serve.rs"),
["serve.rs", "budget"]
);
assert!(terms("notes.txt").is_empty());
}
#[test]
fn one_line_joins_and_cuts_on_words() {
assert_eq!(
one_line("Pull `x`\n(never\nsees them)", 100),
"Pull `x` (never sees them)"
);
assert_eq!(one_line("alpha beta gamma", 10), "alpha beta\u{2026}");
}
#[test]
fn extraction_keeps_structured_tokens_stopwords_would_lose() {
assert_eq!(
terms("use for_each and use::this"),
["use::this".to_owned(), "for_each".to_owned()]
);
}
}