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