Skip to main content

comindly_core/
pipeline.rs

1//! Indexing: check out repositories, parse and bind them across repositories, persist the graph,
2//! then the optional steps (search embeddings, summaries, style guides, flow walkthroughs).
3//! Async and cancellable; CPU-heavy work runs on the blocking pool; progress goes to the context's
4//! reporter. LLM output is saved as it is written and reused while its input is unchanged, so an
5//! interrupted run continues where it stopped.
6
7use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use anyhow::{Context, Result};
12use futures::StreamExt;
13
14use crate::context::{Cancelled, IndexContext};
15use crate::events::Usage;
16use crate::model::{Edge, EdgeKind, GlobalSymbolId, Symbol, SymbolKind};
17use crate::prompts::{Prompts, Task};
18
19/// A repository to index: the folder of its clone and, for one comindly keeps itself, the URL it
20/// is cloned from when the folder is missing.
21#[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)]
22pub struct RepoEntry {
23    pub path: String,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub remote: Option<String>,
26}
27
28/// A repository to index: its name and the folder holding its checkout.
29#[derive(Debug, Clone)]
30pub struct Source {
31    pub name: String,
32    pub path: PathBuf,
33    /// The default branch checked out, when comindly made the checkout.
34    pub branch: Option<String>,
35}
36
37impl Source {
38    /// A folder, named after itself.
39    pub fn at(path: impl Into<PathBuf>) -> Source {
40        let path = path.into();
41        let name = match path.file_name().and_then(|n| n.to_str()) {
42            Some(n) if n != "." && n != ".." => n.to_string(),
43            _ => std::fs::canonicalize(&path)
44                .ok()
45                .and_then(|c| c.file_name().map(|n| n.to_string_lossy().into_owned()))
46                .unwrap_or_else(|| "repo".to_string()),
47        };
48        Source {
49            name,
50            path,
51            branch: None,
52        }
53    }
54}
55
56/// A symbol many repositories depend on, for the link report.
57#[derive(Debug, Clone)]
58pub struct Dependency {
59    pub descriptor: String,
60    pub defined_in: String,
61    pub repos: BTreeSet<String>,
62    pub refs: usize,
63}
64
65/// What a link run produced.
66#[derive(Debug, Clone, Default)]
67pub struct LinkReport {
68    pub symbols: usize,
69    pub edges: usize,
70    pub cross_repo_edges: usize,
71    /// Cross-repository import targets, most depended-on first.
72    pub dependencies: Vec<Dependency>,
73}
74
75type TextRow = (GlobalSymbolId, String, Vec<String>);
76
77async fn blocking<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Result<T> {
78    tokio::task::spawn_blocking(f)
79        .await
80        .context("a worker thread failed")
81}
82
83/// Bring each configured repository to its default branch in comindly's own checkout.
84pub fn checkout<'a>(
85    ctx: &'a IndexContext,
86    repos: &'a [RepoEntry],
87) -> futures::future::BoxFuture<'a, Result<Vec<Source>>> {
88    Box::pin(async move {
89        anyhow::ensure!(
90            !repos.is_empty(),
91            "no repositories: pass folders, or add them with `comindly repos add <folder>`"
92        );
93        let r = &ctx.report;
94        r.step(format!("Checking out {} repo(s)", repos.len()));
95        let mut out = Vec::new();
96        for repo in repos {
97            ctx.check()?;
98            let path = PathBuf::from(&repo.path);
99            if let Some(url) = repo.remote.clone() {
100                if !path.exists() {
101                    r.note(format!("cloning {url}"));
102                }
103                let (dest, git) = (path.clone(), ctx.git.clone());
104                blocking(move || {
105                    crate::worktree::ensure_clone(&url, &dest, git.as_ref())
106                        .map_err(|e| anyhow::anyhow!("{url}: {e:#}"))
107                })
108                .await??;
109            }
110            let (dir, git, cache) = (path.clone(), ctx.git.clone(), ctx.cache.clone());
111            let c = blocking(move || crate::worktree::prepare(&cache, &dir, git.as_ref()))
112                .await?
113                .with_context(|| path.display().to_string())?;
114            r.done(format!(
115                "{} @ {} {}",
116                crate::worktree::name(&path),
117                c.target,
118                c.commit
119            ));
120            if let Some(why) = &c.stale {
121                r.warn(format!(
122                    "could not fetch, indexing the last fetched state: {why}"
123                ));
124            }
125            let branch = c.target.strip_prefix("origin/").map(str::to_string);
126            out.push(Source {
127                branch,
128                ..Source::at(c.dir)
129            });
130        }
131        Ok(out)
132    })
133}
134
135/// Parse `sources`, bind references across them, persist the graph and run the optional steps.
136pub fn link<'a>(
137    ctx: &'a IndexContext,
138    sources: Vec<Source>,
139    incremental: bool,
140    scip: crate::scip::Mode,
141) -> futures::future::BoxFuture<'a, Result<LinkReport>> {
142    Box::pin(async move {
143        if let Some(bad) = sources.iter().find(|s| !s.path.is_dir()) {
144            anyhow::bail!("no such repo directory: {}", bad.path.display());
145        }
146        let r = &ctx.report;
147        r.step(format!("Parsing {} repo(s)", sources.len()));
148        let mut symbols: Vec<Symbol> = Vec::new();
149        let mut edges: Vec<Edge> = Vec::new();
150        let mut facts = Vec::new();
151        for s in &sources {
152            ctx.check()?;
153            let (name, path) = (s.name.clone(), s.path.clone());
154            let (out, scip_note) = blocking(move || -> Result<_> {
155                let mut o = crate::parse::parse_repo(&path, &name)
156                    .with_context(|| format!("{name}: parse failed"))?;
157                let note = attach_scip(scip, &path, &name, &mut o)?;
158                Ok((o, note))
159            })
160            .await??;
161            r.note(format!(
162                "{}: {} symbols, {} files",
163                s.name,
164                out.symbols.len(),
165                out.facts.len()
166            ));
167            if let Some(n) = scip_note {
168                r.note(n);
169            }
170            symbols.extend(out.symbols);
171            edges.extend(out.edges);
172            facts.extend(out.facts);
173        }
174
175        ctx.check()?;
176        let (symbols, resolved, facts) = blocking(move || {
177            let resolved = crate::resolve::resolve(&symbols, &edges, &facts);
178            let mut symbols = symbols;
179            symbols.extend(resolved.symbols.iter().cloned());
180            (symbols, resolved, facts)
181        })
182        .await?;
183        report_resolution(ctx, &resolved);
184        let edges = resolved.edges;
185        let dependencies = dependencies(&edges);
186
187        let dst = ctx.graph();
188        r.step("Persisting");
189        r.note(format!("writing org graph to {dst}"));
190        let roots: Vec<(String, PathBuf)> = sources
191            .iter()
192            .map(|s| (s.name.clone(), s.path.clone()))
193            .collect();
194        let packages = scan_packages(ctx, roots.clone()).await?;
195        let recipes = mine_recipes(ctx, roots.clone()).await?;
196        let explain = |e: anyhow::Error| {
197            anyhow::anyhow!(
198                "write failed: {}",
199                crate::storage::explain(&format!("{e:#}"))
200            )
201        };
202        let (sv, ev) = crate::index::write_graph(&dst, &symbols, &edges)
203            .await
204            .map_err(explain)?;
205        crate::index::write_facts(&dst, &facts)
206            .await
207            .map_err(explain)?;
208        crate::index::write_packages(&dst, &packages)
209            .await
210            .map_err(explain)?;
211        crate::index::write_recipes(&dst, &recipes)
212            .await
213            .map_err(explain)?;
214        store_sources(ctx, &dst, roots.clone(), false).await?;
215        r.done(format!(
216            "org graph: symbols v{sv}, edges v{ev} (latest pointer)"
217        ));
218        let (sc, ec) = crate::index::count_rows(&dst)
219            .await
220            .context("read-back failed")?;
221        r.note(format!("read back: {sc} symbols, {ec} edges"));
222
223        let paths: Vec<(String, PathBuf, Option<String>)> = sources
224            .iter()
225            .map(|s| (s.name.clone(), s.path.clone(), s.branch.clone()))
226            .collect();
227        let heads = blocking(move || {
228            paths
229                .into_iter()
230                .filter_map(|(name, p, branch)| {
231                    crate::git::head_commit(&p)
232                        .ok()
233                        .map(|commit| crate::index::IndexedRepo {
234                            name,
235                            commit,
236                            remote: crate::worktree::remote_url(&p),
237                            branch: branch.or_else(|| crate::worktree::current_branch(&p)),
238                        })
239                })
240                .collect::<Vec<_>>()
241        })
242        .await?;
243        let stale: HashSet<String> = if incremental {
244            let stale = stale_ids(&dst, roots.clone(), &symbols).await?;
245            r.note(format!(
246                "incremental: {} of {} symbols stale",
247                stale.len(),
248                symbols.len()
249            ));
250            stale
251        } else {
252            symbols.iter().map(|s| s.id.render()).collect()
253        };
254
255        let symbols = Arc::new(symbols);
256        let edges = Arc::new(edges);
257        enrich_all(ctx, &dst, &symbols, &edges, &stale, incremental, &roots).await?;
258        crate::index::write_repo_commits(&dst, &heads).await?;
259
260        Ok(LinkReport {
261            symbols: symbols.len(),
262            edges: edges.len(),
263            cross_repo_edges: edges.iter().filter(|e| e.cross_repo).count(),
264            dependencies,
265        })
266    })
267}
268
269/// Index one repository into the shared index: the whole repository, or with `incremental` only
270/// the files changed since `since` (or the commit recorded last time).
271pub fn index_repo<'a>(
272    ctx: &'a IndexContext,
273    source: Source,
274    incremental: bool,
275    since: Option<String>,
276    scip: crate::scip::Mode,
277) -> futures::future::BoxFuture<'a, Result<()>> {
278    Box::pin(async move {
279        anyhow::ensure!(
280            source.path.is_dir(),
281            "no such repo directory: {}",
282            source.path.display()
283        );
284        let r = &ctx.report;
285        let dst = ctx.graph();
286        let incremental = incremental || since.is_some();
287        if incremental {
288            let base = match since {
289                Some(s) => Some(s),
290                None => crate::index::read_repo_meta(&dst).await.ok().flatten(),
291            };
292            match base {
293                Some(base) => return index_changes(ctx, &source, &base).await,
294                None => r.note("no recorded base commit — doing a full index"),
295            }
296        }
297
298        r.step(format!("Indexing {}", source.name));
299        let (name, path) = (source.name.clone(), source.path.clone());
300        let (symbols, edges, facts) = blocking(move || -> Result<_> {
301            let mut out = crate::parse::parse_repo(&path, &name)
302                .map_err(|e| anyhow::anyhow!(crate::storage::explain(&format!("{e:#}"))))?;
303            attach_scip(scip, &path, &name, &mut out)?;
304            let resolved = crate::resolve::resolve(&out.symbols, &out.edges, &out.facts);
305            let mut symbols = out.symbols;
306            symbols.extend(resolved.symbols);
307            Ok((symbols, resolved.edges, out.facts))
308        })
309        .await??;
310
311        let mut by_kind: BTreeMap<String, usize> = BTreeMap::new();
312        for s in &symbols {
313            *by_kind.entry(format!("{:?}", s.kind)).or_default() += 1;
314        }
315        let calls = edges.iter().filter(|e| e.kind == EdgeKind::Calls).count();
316        let contains = edges
317            .iter()
318            .filter(|e| e.kind == EdgeKind::Contains)
319            .count();
320        r.note(format!("symbols: {}", symbols.len()));
321        for (k, n) in &by_kind {
322            r.note(format!("{k:<10} {n}"));
323        }
324        r.note(format!(
325            "edges: {} (contains={contains}, calls={calls})",
326            edges.len()
327        ));
328
329        r.step("Persisting");
330        r.note(format!("writing graph to {dst}"));
331        let roots = vec![(source.name.clone(), source.path.clone())];
332        let explain = |e: anyhow::Error| {
333            anyhow::anyhow!(
334                "write failed: {}",
335                crate::storage::explain(&format!("{e:#}"))
336            )
337        };
338        let (sv, ev) = crate::index::write_graph(&dst, &symbols, &edges)
339            .await
340            .map_err(explain)?;
341        crate::index::write_facts(&dst, &facts)
342            .await
343            .map_err(explain)?;
344        // Keep other repositories' package and recipe rows in a shared index.
345        let mut packages: Vec<_> = crate::index::read_packages(&dst)
346            .await
347            .unwrap_or_default()
348            .into_iter()
349            .filter(|p| p.repo != source.name)
350            .collect();
351        packages.extend(scan_packages(ctx, roots.clone()).await?);
352        crate::index::write_packages(&dst, &packages)
353            .await
354            .map_err(explain)?;
355        let mut recipes: Vec<_> = crate::index::read_recipes(&dst)
356            .await
357            .unwrap_or_default()
358            .into_iter()
359            .filter(|c| c.repo != source.name)
360            .collect();
361        recipes.extend(mine_recipes(ctx, roots.clone()).await?);
362        crate::index::write_recipes(&dst, &recipes)
363            .await
364            .map_err(explain)?;
365        store_sources(ctx, &dst, roots.clone(), true).await?;
366        r.done(format!("graph: symbols v{sv}, edges v{ev}"));
367        let p = source.path.clone();
368        if let Ok(head) = blocking(move || crate::git::head_commit(&p)).await? {
369            let _ = crate::index::write_repo_meta(&dst, &source.name, &head).await;
370            r.note(format!("recorded commit {}", short(&head)));
371        }
372
373        let all: HashSet<String> = symbols.iter().map(|s| s.id.render()).collect();
374        enrich_all(
375            ctx,
376            &dst,
377            &Arc::new(symbols),
378            &Arc::new(edges),
379            &all,
380            false,
381            &roots,
382        )
383        .await
384    })
385}
386
387/// Reparse only the files changed since `base`, drop what they owned, re-bind, and rewrite.
388async fn index_changes(ctx: &IndexContext, source: &Source, base: &str) -> Result<()> {
389    let r = &ctx.report;
390    let dst = ctx.graph();
391    let (root, name, base_c) = (source.path.clone(), source.name.clone(), base.to_string());
392    let (head, cs) = blocking(move || -> Result<_> {
393        let explain =
394            |e: anyhow::Error| anyhow::anyhow!(crate::storage::explain(&format!("{e:#}")));
395        let head = crate::git::head_commit(&root).map_err(explain)?;
396        let cs = crate::git::changed_files(&root, &base_c).map_err(explain)?;
397        Ok((head, cs))
398    })
399    .await??;
400    r.step(format!(
401        "Incremental {} → {}: {} changed ({}+ {}~ {}-)",
402        short(base),
403        short(&head),
404        cs.total(),
405        cs.added.len(),
406        cs.modified.len(),
407        cs.deleted.len()
408    ));
409    if cs.total() == 0 {
410        let _ = crate::index::write_repo_meta(&dst, &name, &head).await;
411        r.done("index already up to date");
412        return Ok(());
413    }
414    let (prior_syms, prior_edges) = crate::index::read_graph(&dst)
415        .await
416        .with_context(|| format!("no prior index at {dst}; run a full index first"))?;
417    let prior_facts = crate::index::read_facts(&dst).await.ok().flatten();
418    if prior_facts.is_none() {
419        r.warn("index has no stored facts; run a full index for exact edges");
420    }
421    let root = source.path.clone();
422    let (symbols, edges, facts, before, parsed) = blocking(move || {
423        let dropped: HashSet<String> = cs.to_drop().cloned().collect();
424        let to_parse: Vec<String> = cs.to_parse().cloned().collect();
425        let newout = crate::parse::parse_files(&root, &name, &to_parse);
426        let file_of: HashMap<String, String> = prior_syms
427            .iter()
428            .filter(|s| s.repo.0 == name)
429            .map(|s| (s.id.render(), s.file_path.clone()))
430            .collect();
431        let before = prior_syms.len();
432        let mut symbols: Vec<Symbol> = prior_syms
433            .into_iter()
434            .filter(|s| !(s.repo.0 == name && dropped.contains(&s.file_path)))
435            .collect();
436        let mut edges: Vec<Edge> = prior_edges
437            .into_iter()
438            .filter(|e| {
439                file_of
440                    .get(&e.src.render())
441                    .is_none_or(|f| !dropped.contains(f))
442            })
443            .collect();
444        symbols.extend(newout.symbols);
445        edges.extend(newout.edges);
446        let facts = match prior_facts {
447            Some(prior) => {
448                edges.retain(|e| e.kind == EdgeKind::Contains);
449                let mut all: Vec<_> = prior
450                    .into_iter()
451                    .filter(|f| {
452                        f.repo != name || !(dropped.contains(&f.path) || to_parse.contains(&f.path))
453                    })
454                    .collect();
455                all.extend(newout.facts);
456                all
457            }
458            None => newout.facts,
459        };
460        symbols.retain(|s| s.kind != SymbolKind::Contract);
461        let resolved = crate::resolve::resolve(&symbols, &edges, &facts);
462        symbols.extend(resolved.symbols);
463        (symbols, resolved.edges, facts, before, to_parse.len())
464    })
465    .await?;
466    let explain = |e: anyhow::Error| {
467        anyhow::anyhow!(
468            "write failed: {}",
469            crate::storage::explain(&format!("{e:#}"))
470        )
471    };
472    crate::index::write_graph(&dst, &symbols, &edges)
473        .await
474        .map_err(explain)?;
475    crate::index::write_facts(&dst, &facts)
476        .await
477        .map_err(explain)?;
478    store_sources(
479        ctx,
480        &dst,
481        vec![(source.name.clone(), source.path.clone())],
482        true,
483    )
484    .await?;
485    let _ = crate::index::write_repo_meta(&dst, &source.name, &head).await;
486    r.done(format!(
487        "reparsed {parsed} files → symbols {before}→{}, edges {}",
488        symbols.len(),
489        edges.len()
490    ));
491    Ok(())
492}
493
494/// The optional steps, each only when the settings ask for it; LLM steps need an LLM.
495async fn enrich_all(
496    ctx: &IndexContext,
497    dst: &str,
498    symbols: &Arc<Vec<Symbol>>,
499    edges: &Arc<Vec<Edge>>,
500    stale: &HashSet<String>,
501    incremental: bool,
502    roots: &[(String, PathBuf)],
503) -> Result<()> {
504    let s = &ctx.settings;
505    if s.embed {
506        embed(ctx, dst, symbols, stale, incremental).await?;
507    }
508    let wanted = s.enrich || s.flows || s.guide;
509    let Some(llm) = ctx.llm.clone().filter(|_| wanted) else {
510        if wanted {
511            ctx.report.warn(
512                "summaries, style guides and walkthroughs skipped: no LLM key (set one in the settings, or OPENAI_API_KEY)",
513            );
514        }
515        return Ok(());
516    };
517    let prompts = crate::prompts::load(&ctx.root)
518        .await
519        .context("reading the prompts")?
520        .doc
521        .in_use();
522    ctx.report.note(format!("prompts: {}", prompts.describe()));
523    for t in &s.rewrite {
524        ctx.report.warn(format!(
525            "rewriting every {} result, as asked; nothing stored is reused",
526            t.name()
527        ));
528    }
529    if s.enrich {
530        summaries(ctx, &llm, &prompts, dst, symbols, edges, stale, incremental).await?;
531    }
532    if s.guide {
533        style_guides(ctx, &llm, &prompts, dst, symbols, edges, roots).await?;
534    }
535    if s.flows {
536        walkthroughs(ctx, &llm, &prompts, dst, symbols, edges).await?;
537    }
538    Ok(())
539}
540
541/// Search embeddings, reusing the stored vector of every symbol whose code did not change.
542async fn embed(
543    ctx: &IndexContext,
544    dst: &str,
545    symbols: &Arc<Vec<Symbol>>,
546    stale: &HashSet<String>,
547    incremental: bool,
548) -> Result<()> {
549    ctx.check()?;
550    let prior: HashMap<String, Vec<f32>> = if incremental {
551        crate::index::read_search_vectors(dst)
552            .await
553            .ok()
554            .flatten()
555            .unwrap_or_default()
556            .into_iter()
557            .map(|(id, v)| (id.render(), v))
558            .collect()
559    } else {
560        HashMap::new()
561    };
562    ctx.report.step("Building search index");
563    let (model, syms, stale) = (
564        ctx.settings.embed_model.clone(),
565        symbols.clone(),
566        stale.clone(),
567    );
568    let (rows, reused, embedded) = blocking(move || -> Result<_> {
569        let embedder = crate::embed::Embedder::load(&model).context("embedding model")?;
570        let mut rows: Vec<(GlobalSymbolId, String, Vec<f32>)> = Vec::new();
571        let mut to_embed: Vec<&Symbol> = Vec::new();
572        for s in syms.iter().filter(|s| !matches!(s.kind, SymbolKind::File)) {
573            match prior
574                .get(&s.id.render())
575                .filter(|_| !stale.contains(&s.id.render()))
576            {
577                Some(v) => rows.push((s.id.clone(), crate::embed::symbol_text(s), v.clone())),
578                None => to_embed.push(s),
579            }
580        }
581        let reused = rows.len();
582        let texts: Vec<String> = to_embed
583            .iter()
584            .map(|s| crate::embed::symbol_text(s))
585            .collect();
586        for (s, v) in to_embed.iter().zip(embedder.embed(&texts)) {
587            rows.push((s.id.clone(), crate::embed::symbol_text(s), v));
588        }
589        Ok((rows, reused, to_embed.len()))
590    })
591    .await??;
592    ctx.report.note(format!(
593        "embedding {embedded} symbols + building BM25 index"
594    ));
595    let v = crate::index::write_search_table(dst, &rows)
596        .await
597        .context("write search table failed")?;
598    ctx.report.done(format!(
599        "search index v{v}: {} symbols ({reused} reused, {embedded} embedded) + BM25 FTS index",
600        rows.len()
601    ));
602    Ok(())
603}
604
605/// Summaries of the most-used definitions, saved every `CHECKPOINT` and reused while unchanged.
606#[allow(clippy::too_many_arguments)]
607async fn summaries(
608    ctx: &IndexContext,
609    llm: &Arc<crate::llm::LlmClient>,
610    prompts: &Prompts,
611    dst: &str,
612    symbols: &Arc<Vec<Symbol>>,
613    edges: &Arc<Vec<Edge>>,
614    stale: &HashSet<String>,
615    incremental: bool,
616) -> Result<()> {
617    const CHECKPOINT: usize = 500;
618    let r = &ctx.report;
619    r.step("Enriching symbols (LLM)");
620    let (syms, edgs, top) = (symbols.clone(), edges.clone(), ctx.settings.max_enrich);
621    let defs: Vec<Symbol> = blocking(move || {
622        let g = crate::graph::CodeGraph::build(&syms, &edgs);
623        let mut defs: Vec<&Symbol> = syms
624            .iter()
625            .filter(|s| {
626                matches!(
627                    s.kind,
628                    SymbolKind::Function
629                        | SymbolKind::Method
630                        | SymbolKind::Class
631                        | SymbolKind::Interface
632                        | SymbolKind::Struct
633                        | SymbolKind::Enum
634                )
635            })
636            .collect();
637        // Most-used first, ties by id, so a limit always picks the same symbols.
638        defs.sort_by_cached_key(|s| {
639            let id = s.id.render();
640            (std::cmp::Reverse(g.dependents_count(&id)), id)
641        });
642        defs.into_iter().take(top).cloned().collect()
643    })
644    .await?;
645    let prior: HashMap<String, (String, Vec<String>)> = if incremental {
646        crate::index::read_enrichment(dst)
647            .await
648            .ok()
649            .flatten()
650            .unwrap_or_default()
651            .into_iter()
652            .map(|(id, s, q)| (id.render(), (s, q)))
653            .collect()
654    } else {
655        HashMap::new()
656    };
657    // A summary is written from the name, signature and path only, so it stays valid while they do.
658    let key = |s: &Symbol| {
659        fingerprint([
660            s.name.clone(),
661            s.signature.clone().unwrap_or_default(),
662            s.file_path.clone(),
663        ])
664    };
665    let stored_keys = read_keys(dst, "enrich_keys").await;
666    let mut rows: Vec<TextRow> = Vec::new();
667    let mut keys: Vec<(String, String)> = Vec::new();
668    let mut todo: Vec<&Symbol> = Vec::new();
669    for s in &defs {
670        let (rid, k) = (s.id.render(), key(s));
671        let valid = !ctx.settings.rewrite.contains(&Task::Summary)
672            && match stored_keys.get(&rid) {
673                Some(old) => *old == k,
674                None => !stale.contains(&rid),
675            };
676        match prior.get(&rid).filter(|_| valid) {
677            Some((sum, q)) => {
678                rows.push((s.id.clone(), sum.clone(), q.clone()));
679                keys.push((rid, k));
680            }
681            None => todo.push(s),
682        }
683    }
684    let reused = rows.len();
685    if !todo.is_empty() {
686        r.warn("Summaries send symbol names, signatures and file paths to the LLM.");
687        r.note(format!(
688            "{} symbols to summarise ({reused} reused)",
689            todo.len()
690        ));
691    }
692    let total = todo.len() as u64;
693    let mut done = 0u64;
694    let mut generated = 0usize;
695    for chunk in todo.chunks(CHECKPOINT) {
696        let out = run_llm(
697            ctx,
698            chunk
699                .iter()
700                .map(|s| {
701                    (
702                        s.name.clone(),
703                        s.signature.clone().unwrap_or_default(),
704                        s.file_path.clone(),
705                    )
706                })
707                .collect(),
708            |(n, sig, path)| {
709                let (llm, prompts) = (llm.clone(), prompts.clone());
710                async move { llm.enrich_symbol(&prompts, &n, &sig, &path).await.ok() }
711            },
712            |d| {
713                done += 1;
714                let _ = d;
715                r.progress("enriching symbols", done, total, Some(usage(ctx, llm)));
716            },
717        )
718        .await;
719        let cancelled = out.is_err();
720        for (s, e) in chunk.iter().zip(out.unwrap_or_else(|v| v)) {
721            if let Some(e) = e {
722                rows.push((s.id.clone(), e.summary, e.queries));
723                keys.push((s.id.render(), key(s)));
724                generated += 1;
725            }
726        }
727        if cancelled || chunk.len() == CHECKPOINT {
728            crate::index::write_enrichment(dst, &rows)
729                .await
730                .context("saving summaries")?;
731            write_keys(dst, "enrich_keys", &keys)
732                .await
733                .context("saving summaries")?;
734        }
735        if cancelled {
736            return Err(Cancelled.into());
737        }
738    }
739    if total > 0 {
740        r.note(format!("spent: {}", usage(ctx, llm)));
741    }
742    let failed = todo.len().saturating_sub(generated);
743    let v = crate::index::write_enrichment(dst, &rows)
744        .await
745        .context("write enrichment failed")?;
746    write_keys(dst, "enrich_keys", &keys)
747        .await
748        .context("write enrichment failed")?;
749    r.done(format!(
750        "enrichment v{v}: {} symbols ({reused} reused, {generated} generated{})",
751        rows.len(),
752        if failed > 0 {
753            format!(", {failed} failed")
754        } else {
755            String::new()
756        }
757    ));
758    Ok(())
759}
760
761/// One LLM call per item, `concurrency` at a time; `Err` when cancelled, with the calls that
762/// finished before the cancellation in their positions.
763async fn run_llm<I, T, F, Fut>(
764    ctx: &IndexContext,
765    items: Vec<I>,
766    call: F,
767    mut each: impl FnMut(&Option<T>),
768) -> std::result::Result<Vec<Option<T>>, Vec<Option<T>>>
769where
770    F: Fn(I) -> Fut,
771    Fut: std::future::Future<Output = Option<T>> + Send + 'static,
772    T: Send + 'static,
773{
774    let mut out = Vec::with_capacity(items.len());
775    let mut stream =
776        futures::stream::iter(items.into_iter().map(call)).buffered(ctx.settings.llm_concurrency);
777    loop {
778        tokio::select! {
779            _ = ctx.cancel.cancelled() => return Err(out),
780            next = stream.next() => match next {
781                Some(r) => { each(&r); out.push(r); }
782                None => return Ok(out),
783            },
784        }
785    }
786}
787
788fn usage(ctx: &IndexContext, llm: &crate::llm::LlmClient) -> Usage {
789    let (i, o) = llm.token_usage();
790    Usage::priced(i, o, ctx.settings.prices)
791}
792
793/// A style guide per repository; a section is written again only when its evidence drifted.
794async fn style_guides(
795    ctx: &IndexContext,
796    llm: &Arc<crate::llm::LlmClient>,
797    prompts: &Prompts,
798    dst: &str,
799    symbols: &Arc<Vec<Symbol>>,
800    edges: &Arc<Vec<Edge>>,
801    roots: &[(String, PathBuf)],
802) -> Result<()> {
803    let r = &ctx.report;
804    r.step("Style guides (per repo)");
805    r.warn("Style guides send code samples and configuration to the LLM.");
806    let facts = crate::index::read_facts(dst).await.ok().flatten();
807    let stored_keys = read_keys(dst, "guide_keys").await;
808    let mut guides: Vec<(String, String)> = crate::index::read_style_guide(dst)
809        .await
810        .unwrap_or_default();
811    let mut keys: Vec<(String, String)> = stored_keys.clone().into_iter().collect();
812    let before = guides.len();
813    guides.retain(|(g, _)| roots.iter().any(|(r, _)| r == g));
814    keys.retain(|(g, _)| roots.iter().any(|(r, _)| r == g));
815    if guides.len() < before {
816        crate::index::write_style_guide(dst, &guides)
817            .await
818            .context("saving the style guide")?;
819        write_keys(dst, "guide_keys", &keys)
820            .await
821            .context("saving the style guide")?;
822        r.note(format!(
823            "{} style guide(s) of removed repositories dropped",
824            before - guides.len()
825        ));
826    }
827    let mut reused = 0usize;
828    let (syms, edgs, roots_owned) = (symbols.clone(), edges.clone(), roots.to_vec());
829    let guide_prompts = prompts.clone();
830    let per_repo = blocking(move || {
831        let conventions = match facts {
832            Some(f) => crate::conventions::mine(&syms, &edgs, &f),
833            None => Vec::new(),
834        };
835        roots_owned
836            .into_iter()
837            .filter_map(|(repo, root)| {
838                let rsyms: Vec<&Symbol> = syms.iter().filter(|s| s.id.package == repo).collect();
839                if rsyms.is_empty() {
840                    return None;
841                }
842                let redges: Vec<Edge> = edgs
843                    .iter()
844                    .filter(|e| e.src.package == repo)
845                    .cloned()
846                    .collect();
847                let rconv: Vec<_> = conventions
848                    .iter()
849                    .filter(|c| c.repo == repo)
850                    .cloned()
851                    .collect();
852                let mut sections =
853                    crate::styleguide::build_sections(&root, &rsyms, &redges, &rconv);
854                for sec in &mut sections {
855                    if let Some(g) = guide_prompts.guidance(&sec.title) {
856                        sec.guidance = g.to_string();
857                    }
858                }
859                (!sections.is_empty()).then_some((repo, sections))
860            })
861            .collect::<Vec<_>>()
862    })
863    .await?;
864    let mut bodies: HashMap<String, String> = read_keys(dst, "guide_sections").await;
865    let mut basis: HashMap<String, String> = read_keys(dst, "guide_basis").await;
866    let mut redone = 0usize;
867    for (repo, sections) in per_repo {
868        ctx.check()?;
869        let key = fingerprint(
870            sections
871                .iter()
872                .map(|s| format!("{}\0{}\0{}", s.title, s.guidance, s.evidence)),
873        );
874        let sid = |title: &str| format!("{repo}\0{title}");
875        let old_doc = guides
876            .iter()
877            .find(|(g, _)| *g == repo)
878            .map(|(_, d)| d.clone());
879        let rewrite = ctx.settings.rewrite.contains(&Task::Guide);
880        // Guides written before sections were stored: adopt the current one as the baseline.
881        if let Some(doc) = old_doc
882            .as_ref()
883            .filter(|_| !rewrite && stored_keys.get(&repo) == Some(&key))
884        {
885            for sec in &sections {
886                let id = sid(&sec.title);
887                if !bodies.contains_key(&id) {
888                    bodies.insert(id.clone(), guide_section_of(doc, &sec.title));
889                    basis.insert(id, guide_basis(sec));
890                }
891            }
892            reused += 1;
893            continue;
894        }
895        let todo: Vec<&crate::styleguide::Section> = sections
896            .iter()
897            .filter(|sec| {
898                let id = sid(&sec.title);
899                rewrite
900                    || !(bodies.contains_key(&id)
901                        && basis
902                            .get(&id)
903                            .is_some_and(|b| guide_similar(b, &guide_basis(sec))))
904            })
905            .collect();
906        let total = todo.len() as u64;
907        let mut done = 0u64;
908        let out = run_llm(
909            ctx,
910            todo.iter()
911                .map(|sec| {
912                    (
913                        sec.title.clone(),
914                        sec.guidance.clone(),
915                        sec.evidence.clone(),
916                    )
917                })
918                .collect(),
919            |(title, guidance, evidence)| {
920                let (llm, prompts, repo) = (llm.clone(), prompts.clone(), repo.clone());
921                async move {
922                    llm.guide_section(&prompts, &repo, &title, &guidance, &evidence)
923                        .await
924                        .ok()
925                        .map(|b| b.unwrap_or_else(|| NO_CONVENTION.to_string()))
926                }
927            },
928            |_| {
929                done += 1;
930                r.progress(
931                    &format!("{repo}: guide sections"),
932                    done,
933                    total,
934                    Some(usage(ctx, llm)),
935                );
936            },
937        )
938        .await
939        .map_err(|_| Cancelled)?;
940        for (sec, body) in todo.iter().zip(out) {
941            if let Some(body) = body {
942                let id = sid(&sec.title);
943                bodies.insert(id.clone(), body.trim().to_string());
944                basis.insert(id, guide_basis(sec));
945                redone += 1;
946            }
947        }
948        let mut doc = String::new();
949        for sec in &sections {
950            let t = bodies.get(&sid(&sec.title)).map(|b| b.trim()).unwrap_or("");
951            if t.is_empty() || t.eq_ignore_ascii_case(NO_CONVENTION) {
952                continue;
953            }
954            doc.push_str(&format!("## {}\n\n{}\n\n", sec.title, t));
955        }
956        if doc.is_empty() {
957            r.warn(format!("{repo}: no sections generated"));
958            continue;
959        }
960        doc.push_str("\n_AI-generated from the codebase; review before enforcing._\n");
961        r.done(format!(
962            "{repo}: style guide ({} sections, {} rewritten)",
963            doc.matches("\n## ").count() + 1,
964            todo.len()
965        ));
966        guides.retain(|(g, _)| *g != repo);
967        guides.push((repo.clone(), doc));
968        keys.retain(|(g, _)| *g != repo);
969        keys.push((repo, key));
970        crate::index::write_style_guide(dst, &guides)
971            .await
972            .context("saving the style guide")?;
973        write_keys(dst, "guide_keys", &keys)
974            .await
975            .context("saving the style guide")?;
976        save_guide_sections(dst, &bodies, &basis, roots).await?;
977    }
978    save_guide_sections(dst, &bodies, &basis, roots).await?;
979    if redone > 0 {
980        r.note(format!("{redone} style guide section(s) rewritten"));
981    }
982    if reused > 0 {
983        r.note(format!("{reused} unchanged style guide(s) reused"));
984    }
985    r.note(format!("spent: {}", usage(ctx, llm)));
986    Ok(())
987}
988
989/// Sections of indexed repositories only, so a removed repository's text goes too.
990async fn save_guide_sections(
991    dst: &str,
992    bodies: &HashMap<String, String>,
993    basis: &HashMap<String, String>,
994    roots: &[(String, PathBuf)],
995) -> Result<()> {
996    let keep = |id: &String| {
997        roots
998            .iter()
999            .any(|(r, _)| id.split('\0').next() == Some(r.as_str()))
1000    };
1001    let pick = |m: &HashMap<String, String>| -> Vec<(String, String)> {
1002        m.iter()
1003            .filter(|(id, _)| keep(id))
1004            .map(|(a, b)| (a.clone(), b.clone()))
1005            .collect()
1006    };
1007    write_keys(dst, "guide_sections", &pick(bodies))
1008        .await
1009        .context("saving the style guide")?;
1010    write_keys(dst, "guide_basis", &pick(basis))
1011        .await
1012        .context("saving the style guide")?;
1013    Ok(())
1014}
1015
1016/// A section's body in a guide document, or empty.
1017fn guide_section_of(doc: &str, title: &str) -> String {
1018    let head = format!("## {title}\n");
1019    let Some(i) = doc.find(&head) else {
1020        return String::new();
1021    };
1022    let rest = &doc[i + head.len()..];
1023    let end = rest
1024        .find("\n## ")
1025        .or_else(|| rest.find("\n_AI-generated"))
1026        .unwrap_or(rest.len());
1027    rest[..end].trim().to_string()
1028}
1029
1030/// What a section is written from, with counts dropped and percentages rounded to tens, so
1031/// ordinary churn does not count as a change.
1032fn guide_basis(sec: &crate::styleguide::Section) -> String {
1033    let mut out = fingerprint([sec.title.clone(), sec.guidance.clone()]);
1034    for line in sec.evidence.lines() {
1035        let line = line.trim();
1036        if line.is_empty() {
1037            continue;
1038        }
1039        out.push('\n');
1040        let mut num = String::new();
1041        let mut chars = line.chars().peekable();
1042        while let Some(c) = chars.next() {
1043            if c.is_ascii_digit() || (c == '.' && !num.is_empty()) {
1044                num.push(c);
1045                if chars
1046                    .peek()
1047                    .is_some_and(|n| n.is_ascii_digit() || *n == '.')
1048                {
1049                    continue;
1050                }
1051                if chars.peek() == Some(&'%') {
1052                    let p: f64 = num.trim_end_matches('.').parse().unwrap_or(0.0);
1053                    out.push_str(&format!("{}", (p / 10.0).round() as u32 * 10));
1054                } else {
1055                    out.push('#');
1056                }
1057                num.clear();
1058            } else {
1059                out.push(c);
1060            }
1061        }
1062    }
1063    out
1064}
1065
1066/// Same guidance, the same measured ratios (to the nearest ten), and at most a fifth of the other
1067/// evidence lines differ.
1068fn guide_similar(a: &str, b: &str) -> bool {
1069    const MIN_SHARED: f64 = 0.8;
1070    let split = |t: &str| -> (Option<String>, HashSet<String>, HashSet<String>) {
1071        let mut l = t.lines();
1072        let head = l.next().map(str::to_string);
1073        let (measured, other): (Vec<&str>, Vec<&str>) = l.partition(|x| x.contains('%'));
1074        (
1075            head,
1076            measured.into_iter().map(str::to_string).collect(),
1077            other.into_iter().map(str::to_string).collect(),
1078        )
1079    };
1080    let ((ha, ma, oa), (hb, mb, ob)) = (split(a), split(b));
1081    if ha != hb || ma != mb {
1082        return false;
1083    }
1084    let union = oa.union(&ob).count();
1085    union == 0 || oa.intersection(&ob).count() as f64 / union as f64 >= MIN_SHARED
1086}
1087
1088/// Walkthroughs of the flows reaching furthest, reused while their trace is unchanged and saved
1089/// every `CHECKPOINT`.
1090async fn walkthroughs(
1091    ctx: &IndexContext,
1092    llm: &Arc<crate::llm::LlmClient>,
1093    prompts: &Prompts,
1094    dst: &str,
1095    symbols: &Arc<Vec<Symbol>>,
1096    edges: &Arc<Vec<Edge>>,
1097) -> Result<()> {
1098    const CHECKPOINT: usize = 25;
1099    let r = &ctx.report;
1100    r.step("Narrating flows (LLM)");
1101    let (syms, edgs, top) = (symbols.clone(), edges.clone(), ctx.settings.max_flows);
1102    let prep: Vec<(GlobalSymbolId, String, String, String)> = blocking(move || {
1103        let g = crate::graph::CodeGraph::build(&syms, &edgs);
1104        let by_id: HashMap<String, &Symbol> = syms.iter().map(|s| (s.id.render(), s)).collect();
1105        g.entry_points(top)
1106            .into_iter()
1107            .filter_map(|(idx, _)| {
1108                let s = by_id.get(&g.node(idx).id)?;
1109                let trace = g.flow_trace(idx);
1110                Some((
1111                    s.id.clone(),
1112                    s.name.clone(),
1113                    s.signature.clone().unwrap_or_default(),
1114                    trace,
1115                ))
1116            })
1117            .collect()
1118    })
1119    .await?;
1120    if prep.is_empty() {
1121        r.note("flows: no multi-step flows found to narrate");
1122        return Ok(());
1123    }
1124    r.warn("Walkthroughs send call traces to the LLM.");
1125    let key = |name: &str, sig: &str, trace: &str| fingerprint([format!("{name}\0{sig}\0{trace}")]);
1126    let stored_keys = read_keys(dst, "flow_keys").await;
1127    let stored: HashMap<String, (String, Vec<String>)> = crate::index::read_flows(dst)
1128        .await
1129        .ok()
1130        .flatten()
1131        .unwrap_or_default()
1132        .into_iter()
1133        .map(|(id, n, q)| (id.render(), (n, q)))
1134        .collect();
1135    let mut rows: Vec<TextRow> = Vec::new();
1136    let mut keys: Vec<(String, String)> = Vec::new();
1137    let mut todo = Vec::new();
1138    for p @ (id, name, sig, trace) in &prep {
1139        let (rid, k) = (id.render(), key(name, sig, trace));
1140        match stored
1141            .get(&rid)
1142            .filter(|_| !ctx.settings.rewrite.contains(&Task::Flow))
1143            .filter(|_| stored_keys.get(&rid) == Some(&k))
1144        {
1145            Some((n, q)) => {
1146                rows.push((id.clone(), n.clone(), q.clone()));
1147                keys.push((rid, k));
1148            }
1149            None => todo.push(p),
1150        }
1151    }
1152    let reused = rows.len();
1153    r.note(format!(
1154        "{} flows to walk through ({reused} reused)",
1155        todo.len()
1156    ));
1157    let total = todo.len() as u64;
1158    let mut done = 0u64;
1159    for chunk in todo.chunks(CHECKPOINT) {
1160        let out = run_llm(
1161            ctx,
1162            chunk
1163                .iter()
1164                .map(|(_, name, sig, trace)| (name.clone(), sig.clone(), trace.clone()))
1165                .collect(),
1166            |(name, sig, trace)| {
1167                let (llm, prompts) = (llm.clone(), prompts.clone());
1168                async move { llm.narrate_flow(&prompts, &name, &sig, &trace).await.ok() }
1169            },
1170            |_| {
1171                done += 1;
1172                r.progress("narrating flows", done, total, Some(usage(ctx, llm)));
1173            },
1174        )
1175        .await;
1176        let cancelled = out.is_err();
1177        for ((id, name, sig, trace), res) in chunk.iter().zip(out.unwrap_or_else(|v| v)) {
1178            if let Some((narr, q)) = res {
1179                keys.push((id.render(), key(name, sig, trace)));
1180                rows.push((id.clone(), narr, q));
1181            }
1182        }
1183        if cancelled || chunk.len() == CHECKPOINT {
1184            crate::index::write_flows(dst, &rows)
1185                .await
1186                .context("saving walkthroughs")?;
1187            write_keys(dst, "flow_keys", &keys)
1188                .await
1189                .context("saving walkthroughs")?;
1190        }
1191        if cancelled {
1192            return Err(Cancelled.into());
1193        }
1194    }
1195    r.note(format!("spent: {}", usage(ctx, llm)));
1196    let v = crate::index::write_flows(dst, &rows)
1197        .await
1198        .context("write flows failed")?;
1199    write_keys(dst, "flow_keys", &keys)
1200        .await
1201        .context("write flows failed")?;
1202    r.done(format!(
1203        "flows v{v}: {} narrated ({reused} reused, {} written)",
1204        rows.len(),
1205        rows.len() - reused
1206    ));
1207    if let Some((_, narr, _)) = rows.first() {
1208        r.note(format!(
1209            "e.g. {}",
1210            narr.chars().take(120).collect::<String>()
1211        ));
1212    }
1213    Ok(())
1214}
1215
1216/// A guide section's body when its evidence shows no consistent convention.
1217const NO_CONVENTION: &str = "No consistent convention observed.";
1218
1219fn fingerprint(parts: impl IntoIterator<Item = String>) -> String {
1220    let mut h = blake3::Hasher::new();
1221    for p in parts {
1222        h.update(p.as_bytes());
1223        h.update(b"\0\0");
1224    }
1225    h.finalize().to_hex().to_string()
1226}
1227
1228async fn read_keys(dst: &str, table: &str) -> HashMap<String, String> {
1229    crate::index::read_keys(dst, table)
1230        .await
1231        .unwrap_or_default()
1232}
1233
1234async fn write_keys(dst: &str, table: &str, keys: &[(String, String)]) -> Result<u64> {
1235    crate::index::write_keys(dst, table, keys).await
1236}
1237
1238/// Symbols whose repository changed files since the commits recorded last time.
1239async fn stale_ids(
1240    dst: &str,
1241    roots: Vec<(String, PathBuf)>,
1242    symbols: &[Symbol],
1243) -> Result<HashSet<String>> {
1244    let prior = crate::index::read_repo_commits(dst)
1245        .await
1246        .unwrap_or_default();
1247    let changed = blocking(move || {
1248        roots
1249            .into_iter()
1250            .map(|(name, p)| {
1251                let set = match (prior.get(&name), crate::git::head_commit(&p)) {
1252                    (Some(base), Ok(_)) => crate::git::changed_files(&p, base).ok().map(|cs| {
1253                        cs.added
1254                            .into_iter()
1255                            .chain(cs.modified)
1256                            .collect::<HashSet<_>>()
1257                    }),
1258                    _ => None,
1259                };
1260                (name, set)
1261            })
1262            .collect::<HashMap<_, _>>()
1263    })
1264    .await?;
1265    Ok(stale_of(&changed, symbols))
1266}
1267
1268/// Symbols in files that changed, by repository; a repository without a known base is all stale.
1269/// Symbols of no indexed repository (contracts) are never stale: new ones have no stored vector anyway.
1270fn stale_of(
1271    changed: &HashMap<String, Option<HashSet<String>>>,
1272    symbols: &[Symbol],
1273) -> HashSet<String> {
1274    symbols
1275        .iter()
1276        .filter(|s| match changed.get(&s.repo.0) {
1277            Some(Some(files)) => files.contains(&s.file_path),
1278            Some(None) => true,
1279            None => false,
1280        })
1281        .map(|s| s.id.render())
1282        .collect()
1283}
1284
1285/// Manifests and lockfiles of each repository.
1286async fn scan_packages(
1287    ctx: &IndexContext,
1288    roots: Vec<(String, PathBuf)>,
1289) -> Result<Vec<crate::manifests::PackageRow>> {
1290    let rows = blocking(move || {
1291        roots
1292            .iter()
1293            .flat_map(|(name, root)| crate::manifests::scan(root, name))
1294            .collect::<Vec<_>>()
1295    })
1296    .await?;
1297    let deps = crate::manifests::internal(&rows);
1298    if !deps.is_empty() {
1299        let skewed = deps.iter().filter(|d| d.skewed()).count();
1300        ctx.report.done(format!(
1301            "internal packages: {} dependency link(s), {skewed} pinned to a different version than their publisher",
1302            deps.len()
1303        ));
1304    }
1305    Ok(rows)
1306}
1307
1308/// Store the indexed files' contents, so code is shown from the index rather than a checkout.
1309async fn store_sources(
1310    ctx: &IndexContext,
1311    dst: &str,
1312    roots: Vec<(String, PathBuf)>,
1313    merge: bool,
1314) -> Result<()> {
1315    let files = blocking(move || {
1316        roots
1317            .iter()
1318            .map(|(name, root)| crate::sources::collect(name, root))
1319            .collect::<Result<Vec<_>>>()
1320    })
1321    .await??
1322    .concat();
1323    let (n, new) = crate::index::write_sources(dst, &files, merge)
1324        .await
1325        .map_err(|e| anyhow::anyhow!(crate::storage::explain(&format!("{e:#}"))))?;
1326    ctx.report
1327        .done(format!("file contents: {n} files ({new} new or changed)"));
1328    Ok(())
1329}
1330
1331/// Change recipes from each repository's recent history.
1332async fn mine_recipes(
1333    ctx: &IndexContext,
1334    roots: Vec<(String, PathBuf)>,
1335) -> Result<Vec<crate::conventions::Convention>> {
1336    let out = blocking(move || {
1337        roots
1338            .iter()
1339            .filter_map(|(name, root)| {
1340                crate::git::history(root, 500)
1341                    .ok()
1342                    .map(|h| crate::recipes::mine(name, &h))
1343            })
1344            .flatten()
1345            .collect::<Vec<_>>()
1346    })
1347    .await?;
1348    if !out.is_empty() {
1349        ctx.report
1350            .done(format!("recipes: {} from commit history", out.len()));
1351    }
1352    Ok(out)
1353}
1354
1355/// Attach SCIP to one parsed repository; a note when indexes were found.
1356fn attach_scip(
1357    mode: crate::scip::Mode,
1358    root: &Path,
1359    name: &str,
1360    out: &mut crate::parse::ParseOutput,
1361) -> Result<Option<String>> {
1362    let a = crate::scip::ingest(mode, root, name, &out.symbols, &mut out.facts)?;
1363    Ok((a.documents > 0).then(|| {
1364        format!(
1365            "{name}: SCIP {} index(es), {} files, {} defs, {} refs",
1366            a.indexes, a.documents, a.definitions, a.references
1367        )
1368    }))
1369}
1370
1371fn report_resolution(ctx: &IndexContext, resolved: &crate::resolve::Resolved) {
1372    let r = &ctx.report;
1373    if !resolved.symbols.is_empty() {
1374        let mut by_type: BTreeMap<&str, usize> = BTreeMap::new();
1375        for c in &resolved.symbols {
1376            *by_type
1377                .entry(c.name.split(':').next().unwrap_or_default())
1378                .or_default() += 1;
1379        }
1380        let parts: Vec<String> = by_type.iter().map(|(t, n)| format!("{t} {n}")).collect();
1381        r.done(format!(
1382            "contracts: {} ({}), {} participant edges",
1383            resolved.symbols.len(),
1384            parts.join(", "),
1385            resolved.stats.contract_edges
1386        ));
1387    }
1388    let s = &resolved.stats;
1389    r.done(format!(
1390        "resolved: {} imports, {} calls, {} inherits/implements; {} cross-repo edges{}",
1391        s.resolved_imports,
1392        s.resolved_calls,
1393        s.type_edges,
1394        s.cross_repo_edges,
1395        if s.scip_answers > 0 {
1396            format!(" ({} answered by SCIP)", s.scip_answers)
1397        } else {
1398            String::new()
1399        }
1400    ));
1401    r.note(format!(
1402        "unresolved: {} imports, {} calls (mostly third-party or dynamic)",
1403        s.unresolved_imports, s.unresolved_calls
1404    ));
1405    let mut top: Vec<_> = resolved.unresolved_modules.iter().collect();
1406    top.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
1407    let top: Vec<String> = top
1408        .iter()
1409        .take(12)
1410        .map(|(m, n)| format!("{m} {n}"))
1411        .collect();
1412    if !top.is_empty() {
1413        r.note(format!("top unresolved: {}", top.join(", ")));
1414    }
1415}
1416
1417/// Cross-repository import targets, most depended-on first.
1418fn dependencies(edges: &[Edge]) -> Vec<Dependency> {
1419    let mut targets: BTreeMap<String, (String, BTreeSet<String>, usize)> = BTreeMap::new();
1420    for e in edges
1421        .iter()
1422        .filter(|e| e.kind == EdgeKind::Imports && e.cross_repo)
1423    {
1424        let t = targets
1425            .entry(e.dst.descriptor.clone())
1426            .or_insert_with(|| (e.dst.package.clone(), BTreeSet::new(), 0));
1427        t.1.insert(e.src.package.clone());
1428        t.2 += 1;
1429    }
1430    let mut out: Vec<Dependency> = targets
1431        .into_iter()
1432        .map(|(descriptor, (defined_in, repos, refs))| Dependency {
1433            descriptor,
1434            defined_in,
1435            repos,
1436            refs,
1437        })
1438        .collect();
1439    out.sort_by(|a, b| b.repos.len().cmp(&a.repos.len()).then(b.refs.cmp(&a.refs)));
1440    out
1441}
1442
1443fn short(sha: &str) -> String {
1444    sha.chars().take(8).collect()
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449    use super::*;
1450    use crate::styleguide::Section;
1451
1452    fn at(repo: &str, file: &str, name: &str) -> Symbol {
1453        use crate::model::{GlobalSymbolId, Language, Position, Range, RepoId};
1454        Symbol {
1455            id: GlobalSymbolId {
1456                scheme: "t".into(),
1457                package_manager: ".".into(),
1458                package: repo.into(),
1459                version: ".".into(),
1460                descriptor: format!("{file}/{name}"),
1461            },
1462            name: name.into(),
1463            kind: SymbolKind::Function,
1464            language: Language::Python,
1465            repo: RepoId(repo.into()),
1466            file_path: file.into(),
1467            range: Range {
1468                start: Position { line: 1, column: 0 },
1469                end: Position { line: 2, column: 0 },
1470            },
1471            signature: None,
1472            docstring: None,
1473            fingerprint: None,
1474        }
1475    }
1476
1477    #[test]
1478    fn only_changed_files_are_stale_and_contracts_never() {
1479        let changed: HashMap<String, Option<HashSet<String>>> = [
1480            (
1481                "orders".to_string(),
1482                Some(HashSet::from(["a.py".to_string()])),
1483            ),
1484            ("billing".to_string(), None),
1485        ]
1486        .into();
1487        let symbols = [
1488            at("orders", "a.py", "edited"),
1489            at("orders", "b.py", "untouched"),
1490            at("billing", "c.py", "no_base"),
1491            at(crate::resolve::CONTRACTS, "", "http:GET /orders"),
1492        ];
1493        let mut stale: Vec<String> = stale_of(&changed, &symbols).into_iter().collect();
1494        stale.sort();
1495        assert_eq!(stale, vec![symbols[2].id.render(), symbols[0].id.render()]);
1496    }
1497
1498    fn sec(evidence: &str) -> Section {
1499        Section {
1500            title: "Naming".into(),
1501            guidance: "State the casing".into(),
1502            evidence: evidence.into(),
1503        }
1504    }
1505
1506    #[test]
1507    fn guide_sections_ignore_churn_but_not_drift() {
1508        let lines: Vec<String> = (0..10)
1509            .map(|i| format!("def handler_{}(x): pass", (b'a' + i) as char))
1510            .collect();
1511        let base = format!("snake_case 97.3% (1234/1268)\n{}", lines.join("\n"));
1512        let a = guide_basis(&sec(&base));
1513        let counts = guide_basis(&sec(&base.replace("97.3% (1234/1268)", "96.8% (1301/1340)")));
1514        assert!(guide_similar(&a, &counts), "counts moving is churn");
1515        let shifted = guide_basis(&sec(&base.replace("97.3%", "61.0%")));
1516        assert!(
1517            !guide_similar(&a, &shifted),
1518            "a measured ratio moving rewrites"
1519        );
1520        let one = guide_basis(&sec(&base.replacen("handler_a", "handle_a", 1)));
1521        assert!(guide_similar(&a, &one), "one changed excerpt line is churn");
1522        let many = guide_basis(&sec(&base.replace("handler_", "Handler")));
1523        assert!(!guide_similar(&a, &many), "most lines changed is drift");
1524        let mut other = sec(&base);
1525        other.guidance = "New prompt".into();
1526        assert!(
1527            !guide_similar(&a, &guide_basis(&other)),
1528            "new guidance rewrites"
1529        );
1530    }
1531
1532    #[test]
1533    fn finds_a_section_in_a_guide() {
1534        let doc = "## Naming\n\nUse snake_case.\n\n## Testing\n\nPytest.\n\n\n_AI-generated from the codebase; review before enforcing._\n";
1535        assert_eq!(guide_section_of(doc, "Naming"), "Use snake_case.");
1536        assert_eq!(guide_section_of(doc, "Testing"), "Pytest.");
1537        assert_eq!(guide_section_of(doc, "Types"), "");
1538    }
1539}