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