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