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};
17
18#[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#[derive(Debug, Clone)]
29pub struct Source {
30 pub name: String,
31 pub path: PathBuf,
32 pub branch: Option<String>,
34}
35
36impl Source {
37 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#[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#[derive(Debug, Clone, Default)]
66pub struct LinkReport {
67 pub symbols: usize,
68 pub edges: usize,
69 pub cross_repo_edges: usize,
70 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
82pub 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
134pub 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
268pub 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 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
386async 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
493async 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
528async 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
592async 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 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 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
745async 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
777async 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 if let Some(doc) = old_doc
857 .as_ref()
858 .filter(|_| stored_keys.get(&repo) == Some(&key))
859 {
860 for sec in §ions {
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 §ions {
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
962async 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
989fn 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
1003fn 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
1039fn 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
1061async 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
1216async 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
1246fn 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
1263async 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
1286async 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
1309async 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
1333fn 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
1395fn 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}