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