Skip to main content

comindly_core/
answers.rs

1//! What the read commands answer, computed the same way on this machine and on a server, so a
2//! command run with `--server` prints what a local run would.
3
4use std::collections::{BTreeMap, BTreeSet, HashSet};
5
6use crate::conventions::{Convention, Manual, ORG};
7use crate::graph::{CodeGraph, Node};
8use crate::ledger::Entry;
9use crate::manifests::InternalDep;
10use crate::model::{Edge, Symbol};
11use crate::search::SearchHit;
12
13/// `find`: symbols whose name, descriptor or path matches, in `repos` when given.
14pub fn find(g: &CodeGraph, query: &str, repos: &[String]) -> Vec<Node> {
15    // Over-fetch when filtering by repo so a full page survives the filter.
16    let mut hits = g.find(query, if repos.is_empty() { 30 } else { 200 });
17    if !repos.is_empty() {
18        hits.retain(|n| in_repos(&n.repo, repos));
19        hits.truncate(30);
20    }
21    hits
22}
23
24pub use comindly_api::answers::{
25    Contracts, Conventions, Explore, ExploreView, Flow, Ledger, Preflight, RepoStats, Search,
26    Stats, Step, Why,
27};
28
29/// How many hits to ask the ranker for; more when they are filtered by repo afterwards.
30pub fn search_limit(repos: &[String]) -> usize {
31    if repos.is_empty() {
32        12
33    } else {
34        60
35    }
36}
37
38/// `search`: ranked hits, kept to `repos` when given.
39pub fn search(query: &str, mut hits: Vec<SearchHit>, repos: &[String]) -> Search {
40    if !repos.is_empty() {
41        hits.retain(|h| in_repos(&h.repo, repos));
42        hits.truncate(12);
43    }
44    Search {
45        markdown: crate::search::markdown(query, &hits),
46        hits,
47    }
48}
49
50/// `why`: the ledger about `focus` and what it contains.
51pub fn why(g: &CodeGraph, entries: &[Entry], focus: &str) -> Option<Why> {
52    let (name, ids, n) = g.scopes_of(focus)?;
53    let about = crate::ledger::about(entries, &ids);
54    Some(Why {
55        markdown: crate::ledger::markdown(&why_title(&name, n), &about),
56        name,
57    })
58}
59
60/// "why `get`", or with several symbols of that name "why `get` (3 symbols of that name)".
61pub fn why_title(name: &str, n: usize) -> String {
62    if n > 1 {
63        format!("why `{name}` ({n} symbols of that name; pass an id from `find` for one)")
64    } else {
65        format!("why `{name}`")
66    }
67}
68
69/// `ledger`: the records, or the inbox of those needing attention; with `check`, constraint checks.
70pub fn ledger(
71    mut entries: Vec<Entry>,
72    symbols: &[Symbol],
73    edges: &[Edge],
74    packages: &[crate::manifests::PackageRow],
75    inbox: bool,
76    check: bool,
77) -> Ledger {
78    let findings = crate::checks::run(&entries, symbols, edges, packages);
79    if check {
80        if findings.is_empty() {
81            return Ledger {
82                note: Some("no constraint carries a check (add one with --field check=...)".into()),
83                ..Default::default()
84            };
85        }
86        return Ledger {
87            markdown: format!(
88                "## Constraint checks\n\n{}",
89                crate::checks::markdown(&findings, None)
90            ),
91            failed: findings
92                .iter()
93                .any(|f| f.severity == "must" && !f.violations.is_empty()),
94            note: None,
95        };
96    }
97    crate::checks::promote(&mut entries, &findings);
98    let shown = crate::ledger::inbox(&entries, inbox);
99    let title = if inbox { "Ledger inbox" } else { "Ledger" };
100    let mut markdown = format!("{}\n", crate::ledger::markdown(title, &shown));
101    let revived = crate::checks::revivals(&entries, packages);
102    if !revived.is_empty() {
103        markdown.push_str("## Retry conditions met\n\n");
104        for (e, why) in revived {
105            markdown.push_str(&format!("- {} — {why}\n", crate::ledger::line(e)));
106        }
107    }
108    Ledger {
109        markdown,
110        ..Default::default()
111    }
112}
113
114/// `flow`: the walkthrough written for `focus`, if any, and its live call trace.
115pub fn flow(
116    g: &CodeGraph,
117    focus: &str,
118    narration: impl FnOnce(&str) -> Option<(String, Vec<String>)>,
119) -> Option<Flow> {
120    let idx = g.lookup(focus)?;
121    let narration = g.zoom(idx).focus.and_then(|n| narration(&n.id));
122    let steps = g
123        .thread(idx, 4)
124        .into_iter()
125        .map(|h| Step {
126            depth: h.depth,
127            via: h.via.to_string(),
128            name: h.node.name,
129            location: h.node.location,
130        })
131        .collect();
132    Some(Flow {
133        focus: focus.to_string(),
134        narration,
135        steps,
136    })
137}
138
139pub fn contracts(g: &CodeGraph, kind: Option<&str>, dangling: bool) -> Contracts {
140    Contracts {
141        total: g.contracts(None, false).len(),
142        views: g.contracts(kind, dangling),
143    }
144}
145
146/// `conventions`: the conventions in force (measured, edited and the team's) and the hidden ones,
147/// for `repo` and the org baseline when a repo is given.
148pub fn conventions(all: Vec<Convention>, manual: &[Manual], repo: Option<&str>) -> Conventions {
149    let here = |r: &str| repo.is_none_or(|want| r == want || r == ORG);
150    Conventions {
151        conventions: all.into_iter().filter(|c| here(&c.repo)).collect(),
152        hidden: manual
153            .iter()
154            .filter(|m| m.hidden && here(&m.repo))
155            .cloned()
156            .collect(),
157    }
158}
159
160pub use comindly_api::answers::{Hook, RIPPLE_DEPTH};
161pub const PACK_BUDGET: usize = 1500;
162
163/// `explore`: zoom, blast radius and context pack for `focus`.
164pub fn explore(g: &CodeGraph, focus: &str) -> Explore {
165    let view = g.lookup(focus).map(|idx| {
166        let z = g.zoom(idx);
167        let hits = g.ripple(idx, RIPPLE_DEPTH);
168        let mut by_repo = BTreeMap::new();
169        for h in &hits {
170            *by_repo.entry(h.node.repo.clone()).or_default() += 1;
171        }
172        ExploreView {
173            focus: z.focus,
174            container: z.container,
175            callees: z.callees,
176            callers: z.callers,
177            importers: z.importers,
178            members: z.members,
179            dependents: hits.len(),
180            by_repo,
181            budget: PACK_BUDGET,
182            pack: g.context_pack(idx, PACK_BUDGET),
183        }
184    });
185    Explore {
186        symbols: g.node_count(),
187        edges: g.edge_count(),
188        dangling: g.dangling_edges,
189        view,
190    }
191}
192
193/// `stats`: per repo (`want`, else all) symbols, edges, kinds and how many have LLM summaries.
194pub fn stats(
195    symbols: &[Symbol],
196    edges: &[Edge],
197    enriched: &HashSet<String>,
198    want: &[String],
199) -> Stats {
200    let all: BTreeSet<&str> = symbols.iter().map(|s| s.id.package.as_str()).collect();
201    let (known, unknown): (Vec<&String>, Vec<&String>) =
202        want.iter().partition(|r| all.contains(r.as_str()));
203    let repos: Vec<&str> = if want.is_empty() {
204        all.iter().copied().collect()
205    } else {
206        known.into_iter().map(String::as_str).collect()
207    };
208    let repos = repos
209        .into_iter()
210        .map(|repo| {
211            let rsyms: Vec<&Symbol> = symbols.iter().filter(|s| s.id.package == repo).collect();
212            let mut kinds = BTreeMap::new();
213            for s in &rsyms {
214                *kinds.entry(format!("{:?}", s.kind)).or_default() += 1;
215            }
216            RepoStats {
217                repo: repo.to_string(),
218                symbols: rsyms.len(),
219                edges: edges.iter().filter(|e| e.src.package == repo).count(),
220                enriched: rsyms
221                    .iter()
222                    .filter(|s| enriched.contains(&s.id.render()))
223                    .count(),
224                kinds,
225            }
226        })
227        .collect();
228    Stats {
229        symbols: symbols.len(),
230        indexed: all.len(),
231        unknown: unknown.into_iter().cloned().collect(),
232        repos,
233    }
234}
235
236/// `packages`: internal dependencies, only the skewed ones with `skew`.
237pub fn packages(deps: Vec<InternalDep>, skew: bool) -> Vec<InternalDep> {
238    deps.into_iter().filter(|d| !skew || d.skewed()).collect()
239}
240
241/// `preflight`: what is known about the code `changes` touch; the changes come from git.
242pub fn preflight(
243    g: &CodeGraph,
244    symbols: &[Symbol],
245    edges: &[Edge],
246    mut entries: Vec<Entry>,
247    recipes: &[Convention],
248    packages: &[crate::manifests::PackageRow],
249    changes: &[crate::preflight::Change],
250) -> Preflight {
251    let findings = crate::checks::run(&entries, symbols, edges, packages);
252    crate::checks::promote(&mut entries, &findings);
253    let markdown =
254        crate::preflight::report(g, symbols, edges, &entries, recipes, &findings, changes);
255    let changed: Vec<String> = changes
256        .iter()
257        .map(|c| format!("{}/{}", c.repo, c.path))
258        .collect();
259    let blocking = findings.iter().any(|f| {
260        f.severity == "must"
261            && f.violations
262                .iter()
263                .any(|v| changed.contains(&format!("{}/{}", v.repo, v.path)))
264    });
265    Preflight { markdown, blocking }
266}
267
268fn in_repos(repo: &str, repos: &[String]) -> bool {
269    repos.iter().any(|r| repo.eq_ignore_ascii_case(r))
270}
271
272/// The hook's context: at session start, the guide and records about the repository `cwd` is in;
273/// before an edit, records about the symbols in `path`. Records in `seen` were shown already.
274pub fn hook(
275    symbols: &[Symbol],
276    entries: &[Entry],
277    event: &str,
278    cwd: &str,
279    path: Option<&str>,
280    seen: &HashSet<String>,
281) -> Hook {
282    use crate::ledger::{Status, Trust};
283    let (ids, indexed, limit): (Vec<String>, bool, usize) = match (event, path) {
284        ("session-start", _) => {
285            let repos: BTreeSet<&str> = symbols.iter().map(|s| s.repo.0.as_str()).collect();
286            let repo = std::path::Path::new(cwd)
287                .ancestors()
288                .filter_map(|a| a.file_name().and_then(|n| n.to_str()))
289                .find(|n| repos.contains(n));
290            let ids = symbols
291                .iter()
292                .filter(|s| Some(s.repo.0.as_str()) == repo)
293                .map(|s| s.id.render())
294                .collect();
295            (ids, repo.is_some(), 8)
296        }
297        ("pre-edit", Some(path)) => {
298            let ids = symbols
299                .iter()
300                .filter(|s| !s.file_path.is_empty() && path.ends_with(&format!("/{}", s.file_path)))
301                .map(|s| s.id.render())
302                .collect();
303            (ids, false, 4)
304        }
305        _ => return Hook::default(),
306    };
307    let fresh: Vec<&Entry> = crate::ledger::about(entries, &ids)
308        .into_iter()
309        .filter(|e| e.trust >= Trust::T2)
310        .filter(|e| e.status == Status::Active)
311        .filter(|e| !seen.contains(&e.record.id))
312        .take(limit)
313        .collect();
314    let mut text = if fresh.is_empty() {
315        String::new()
316    } else {
317        crate::ledger::markdown(
318            "comindly ledger: reasoning recorded about this code",
319            &fresh,
320        )
321    };
322    if text.len() > 9_000 {
323        let cut = (0..=9_000)
324            .rev()
325            .find(|&i| text.is_char_boundary(i))
326            .unwrap_or(0);
327        text.truncate(cut);
328    }
329    if indexed {
330        text = format!(
331            "# Using comindly\n\n{}\n{text}",
332            comindly_api::agents::GUIDE
333        );
334    }
335    Hook {
336        text,
337        ids: fresh.iter().map(|e| e.record.id.clone()).collect(),
338    }
339}