1use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
18
19use crate::model::{Edge, EdgeKind, Symbol};
20use petgraph::graph::{DiGraph, NodeIndex};
21use petgraph::visit::EdgeRef;
22use petgraph::Direction::{Incoming, Outgoing};
23
24pub const DEPENDENCY: &[EdgeKind] = &[
26 EdgeKind::Calls,
27 EdgeKind::Imports,
28 EdgeKind::Instantiates,
29 EdgeKind::Inherits,
30 EdgeKind::Implements,
31 EdgeKind::Consumes,
32 EdgeKind::Subscribes,
33 EdgeKind::Reads,
34];
35
36pub const PROVIDES: &[EdgeKind] = &[
39 EdgeKind::Serves,
40 EdgeKind::Publishes,
41 EdgeKind::Writes,
42 EdgeKind::Defines,
43];
44
45pub use comindly_api::graph::{ContractRole, ContractView, FlowStep, Hop, Node, Zoom};
46
47pub fn is_contract_role(k: &EdgeKind) -> bool {
49 PROVIDES.contains(k)
50 || matches!(
51 k,
52 EdgeKind::Consumes | EdgeKind::Subscribes | EdgeKind::Reads
53 )
54}
55
56struct NodeData {
57 sym: Symbol,
58}
59
60pub struct CodeGraph {
61 g: DiGraph<NodeData, EdgeKind>,
62 by_id: HashMap<String, NodeIndex>,
63 by_name: HashMap<String, Vec<NodeIndex>>,
64 pub dangling_edges: usize,
66}
67
68impl CodeGraph {
69 pub fn build(symbols: &[Symbol], edges: &[Edge]) -> Self {
71 let mut g = DiGraph::new();
72 let mut by_id = HashMap::new();
73 let mut by_name: HashMap<String, Vec<NodeIndex>> = HashMap::new();
74
75 for s in symbols {
76 let key = s.id.render();
77 if by_id.contains_key(&key) {
78 continue; }
80 let idx = g.add_node(NodeData { sym: s.clone() });
81 by_id.insert(key, idx);
82 by_name.entry(s.name.clone()).or_default().push(idx);
83 }
84
85 let mut dangling_edges = 0;
86 for e in edges {
87 match (by_id.get(&e.src.render()), by_id.get(&e.dst.render())) {
88 (Some(&a), Some(&b)) => {
89 g.add_edge(a, b, e.kind.clone());
90 }
91 _ => dangling_edges += 1,
92 }
93 }
94
95 Self {
96 g,
97 by_id,
98 by_name,
99 dangling_edges,
100 }
101 }
102
103 pub fn node_count(&self) -> usize {
104 self.g.node_count()
105 }
106 pub fn edge_count(&self) -> usize {
107 self.g.edge_count()
108 }
109
110 pub fn scope_of(&self, focus: &str) -> Option<(String, Vec<String>)> {
112 let idx = self.lookup(focus)?;
113 let mut ids = vec![self.node_of(idx).id];
114 let mut cur = idx;
115 for _ in 0..6 {
116 let parent = self
117 .g
118 .edges_directed(cur, Incoming)
119 .find(|e| *e.weight() == EdgeKind::Contains)
120 .map(|e| e.source());
121 match parent {
122 Some(p) => {
123 ids.push(self.node_of(p).id);
124 cur = p;
125 }
126 None => break,
127 }
128 }
129 Some((self.node_of(idx).name, ids))
130 }
131
132 pub fn node(&self, idx: NodeIndex) -> Node {
133 self.node_of(idx)
134 }
135
136 fn node_of(&self, idx: NodeIndex) -> Node {
138 let mut n = self.plain(idx);
139 if self.g[idx].sym.kind == crate::model::SymbolKind::Method {
140 if let Some(owner) = self
141 .g
142 .edges_directed(idx, Incoming)
143 .find(|e| *e.weight() == EdgeKind::Contains)
144 .map(|e| &self.g[e.source()].sym)
145 .filter(|o| o.kind != crate::model::SymbolKind::File)
146 {
147 n.name = format!("{}.{}", owner.name, n.name);
148 }
149 }
150 n
151 }
152
153 fn plain(&self, idx: NodeIndex) -> Node {
154 let s = &self.g[idx].sym;
155 Node {
156 id: s.id.render(),
157 name: s.name.clone(),
158 kind: format!("{:?}", s.kind),
159 repo: s.id.package.clone(),
160 location: format!("{}:{}", s.file_path, s.range.start.line),
161 signature: s.signature.clone(),
162 }
163 }
164
165 pub fn lookup(&self, focus: &str) -> Option<NodeIndex> {
169 if let Some(&i) = self.by_id.get(focus) {
170 return Some(i);
171 }
172 let mut candidates: Vec<NodeIndex> = self
173 .g
174 .node_indices()
175 .filter(|&i| {
176 let d = &self.g[i].sym.id.descriptor;
177 d == focus || d.contains(focus)
178 })
179 .collect();
180 if candidates.is_empty() {
181 if let Some(v) = self.by_name.get(focus) {
182 candidates = v.clone();
183 }
184 }
185 if let (true, Some((_, method))) = (candidates.is_empty(), focus.rsplit_once('.')) {
187 candidates = self
188 .by_name
189 .get(method)
190 .into_iter()
191 .flatten()
192 .copied()
193 .filter(|&i| self.node_of(i).name == focus)
194 .collect();
195 }
196 candidates.sort_by_key(|&i| {
197 let s = &self.g[i].sym;
198 let inexact = (s.name != focus && self.node_of(i).name != focus) as usize;
199 let is_file = matches!(s.kind, crate::model::SymbolKind::File) as usize;
200 let path = s.file_path.to_ascii_lowercase();
201 let is_test = (path.contains("test") || path.contains("spec")) as usize;
202 (inexact, is_file, is_test, s.id.descriptor.len())
203 });
204 candidates.first().copied()
205 }
206
207 pub fn repos(&self) -> Vec<(String, usize)> {
209 let mut m: BTreeMap<String, usize> = BTreeMap::new();
210 for i in self.g.node_indices() {
211 if self.g[i].sym.kind != crate::model::SymbolKind::Contract {
212 *m.entry(self.g[i].sym.id.package.clone()).or_default() += 1;
213 }
214 }
215 m.into_iter().collect()
216 }
217
218 pub fn dependents_count(&self, rendered_id: &str) -> usize {
222 let Some(&idx) = self.by_id.get(rendered_id) else {
223 return 0;
224 };
225 self.g
226 .edges_directed(idx, Incoming)
227 .filter(|e| DEPENDENCY.contains(e.weight()))
228 .count()
229 }
230
231 pub fn find(&self, query: &str, limit: usize) -> Vec<Node> {
234 let mut v: Vec<NodeIndex> = self
235 .g
236 .node_indices()
237 .filter(|&i| {
238 let s = &self.g[i].sym;
239 s.name == query || s.name.contains(query) || s.id.descriptor.contains(query)
240 })
241 .collect();
242 v.sort_by_key(|&i| {
243 let s = &self.g[i].sym;
244 let is_file = matches!(s.kind, crate::model::SymbolKind::File) as usize;
245 (is_file, s.id.descriptor.len())
246 });
247 v.into_iter().take(limit).map(|i| self.node_of(i)).collect()
248 }
249
250 pub fn ripple(&self, focus: NodeIndex, max_depth: usize) -> Vec<Hop> {
254 self.bfs(focus, max_depth, Incoming, DEPENDENCY, PROVIDES)
255 }
256
257 pub fn flow_trace(&self, focus: NodeIndex) -> String {
259 self.flow(focus, 6)
260 .iter()
261 .map(|h| {
262 format!(
263 "d{} {} {}:{} {}",
264 h.depth, h.via, h.node.repo, h.node.name, h.node.location
265 )
266 })
267 .collect::<Vec<_>>()
268 .join("\n")
269 }
270
271 pub fn flow(&self, focus: NodeIndex, max_depth: usize) -> Vec<FlowStep> {
274 use crate::model::SymbolKind::Contract;
275 let mut seen: HashSet<NodeIndex> = HashSet::from([focus]);
276 let mut q: VecDeque<(NodeIndex, usize)> = VecDeque::from([(focus, 0)]);
277 let mut out = Vec::new();
278 while let Some((n, depth)) = q.pop_front() {
279 if depth >= max_depth {
280 continue;
281 }
282 let next: Vec<(NodeIndex, EdgeKind)> = if self.g[n].sym.kind == Contract {
283 self.g
284 .edges_directed(n, Incoming)
285 .filter(|e| matches!(e.weight(), EdgeKind::Serves | EdgeKind::Subscribes))
286 .map(|e| (e.source(), e.weight().clone()))
287 .collect()
288 } else {
289 self.g
290 .edges_directed(n, Outgoing)
291 .filter(|e| {
292 matches!(
293 e.weight(),
294 EdgeKind::Calls
295 | EdgeKind::Instantiates
296 | EdgeKind::Consumes
297 | EdgeKind::Publishes
298 | EdgeKind::Writes
299 )
300 })
301 .map(|e| (e.target(), e.weight().clone()))
302 .collect()
303 };
304 for (m, via) in next {
305 if seen.insert(m) {
306 out.push(FlowStep {
307 node: self.node_of(m),
308 depth: depth + 1,
309 via: via.as_str().to_string(),
310 from: self.g[n].sym.id.render(),
311 });
312 q.push_back((m, depth + 1));
313 }
314 }
315 }
316 out
317 }
318
319 pub fn entry_points(&self, top: usize) -> Vec<(NodeIndex, usize)> {
322 use crate::model::SymbolKind::{Function, Method};
323 let mut out: Vec<(NodeIndex, usize, usize)> = self
324 .g
325 .node_indices()
326 .filter(|&i| matches!(self.g[i].sym.kind, Function | Method))
327 .filter(|&i| crate::embed::rank::path_penalty(&self.g[i].sym.file_path) > 0.3)
328 .filter_map(|i| {
329 let steps = self.flow(i, 6);
330 let repos: HashSet<&str> = steps.iter().map(|s| s.node.repo.as_str()).collect();
331 (!steps.is_empty()).then_some((i, repos.len(), steps.len()))
332 })
333 .collect();
334 out.sort_by_key(|x| std::cmp::Reverse((x.1, x.2)));
335 out.truncate(top);
336 out.into_iter().map(|(i, _, n)| (i, n)).collect()
337 }
338
339 pub fn thread(&self, focus: NodeIndex, max_depth: usize) -> Vec<Hop> {
341 self.bfs(
342 focus,
343 max_depth,
344 Outgoing,
345 &[EdgeKind::Calls, EdgeKind::Instantiates],
346 &[],
347 )
348 }
349
350 fn bfs(
351 &self,
352 start: NodeIndex,
353 max_depth: usize,
354 dir: petgraph::Direction,
355 kinds: &[EdgeKind],
356 reversed: &[EdgeKind],
357 ) -> Vec<Hop> {
358 let mut seen: HashSet<NodeIndex> = HashSet::from([start]);
359 let mut q: VecDeque<(NodeIndex, usize)> = VecDeque::from([(start, 0)]);
360 let mut out = Vec::new();
361 while let Some((n, depth)) = q.pop_front() {
362 if depth >= max_depth {
363 continue;
364 }
365 let forward = self
366 .g
367 .edges_directed(n, dir)
368 .filter(|e| kinds.contains(e.weight()))
369 .map(|e| {
370 (
371 e.weight(),
372 if dir == Incoming {
373 e.source()
374 } else {
375 e.target()
376 },
377 )
378 });
379 let backward = self
380 .g
381 .edges_directed(n, dir.opposite())
382 .filter(|e| reversed.contains(e.weight()))
383 .map(|e| {
384 (
385 e.weight(),
386 if dir == Incoming {
387 e.target()
388 } else {
389 e.source()
390 },
391 )
392 });
393 for (kind, next) in forward.chain(backward).collect::<Vec<_>>() {
394 if seen.insert(next) {
395 out.push(Hop {
396 node: self.node_of(next),
397 depth: depth + 1,
398 via: kind.clone(),
399 });
400 q.push_back((next, depth + 1));
401 }
402 }
403 }
404 out
405 }
406
407 fn sides(&self, contract: NodeIndex) -> (Vec<Node>, Vec<Node>) {
409 let (mut p, mut c) = (Vec::new(), Vec::new());
410 let (mut seen_p, mut seen_c) = (HashSet::new(), HashSet::new());
411 for e in self.g.edges_directed(contract, Incoming) {
412 if PROVIDES.contains(e.weight()) {
413 if seen_p.insert(e.source()) {
414 p.push(self.node_of(e.source()));
415 }
416 } else if is_contract_role(e.weight()) && seen_c.insert(e.source()) {
417 c.push(self.node_of(e.source()));
418 }
419 }
420 (p, c)
421 }
422
423 pub fn contracts(&self, kind: Option<&str>, dangling: bool) -> Vec<ContractView> {
425 let mut out: Vec<ContractView> = self
426 .g
427 .node_indices()
428 .filter(|&i| self.g[i].sym.kind == crate::model::SymbolKind::Contract)
429 .filter(|&i| kind.is_none_or(|k| self.g[i].sym.name.starts_with(&format!("{k}:"))))
430 .map(|i| {
431 let (providers, consumers) = self.sides(i);
432 ContractView {
433 contract: self.node_of(i),
434 providers,
435 consumers,
436 }
437 })
438 .filter(|v| !dangling || v.providers.is_empty() || v.consumers.is_empty())
439 .collect();
440 out.sort_by(|a, b| a.contract.name.cmp(&b.contract.name));
441 out
442 }
443
444 pub fn zoom(&self, focus: NodeIndex) -> Zoom {
446 let mut z = Zoom {
447 focus: Some(self.node_of(focus)),
448 ..Default::default()
449 };
450 for e in self.g.edges_directed(focus, Incoming) {
451 match e.weight() {
452 EdgeKind::Contains => z.container = Some(self.node_of(e.source())),
453 EdgeKind::Calls | EdgeKind::Instantiates => {
454 z.callers.push(self.node_of(e.source()))
455 }
456 EdgeKind::Imports => z.importers.push(self.node_of(e.source())),
457 _ => {}
458 }
459 }
460 for e in self.g.edges_directed(focus, Incoming) {
461 if is_contract_role(e.weight()) {
462 if PROVIDES.contains(e.weight()) {
463 z.providers.push(self.node_of(e.source()));
464 } else {
465 z.consumers.push(self.node_of(e.source()));
466 }
467 }
468 }
469 let mut roles = HashSet::new();
470 let me = self.g[focus].sym.id.render();
471 for e in self.g.edges_directed(focus, Outgoing) {
472 if is_contract_role(e.weight()) && roles.insert((e.weight().clone(), e.target())) {
473 let provider = PROVIDES.contains(e.weight());
474 let (providers, consumers) = self.sides(e.target());
475 let mut counterparts = if provider { consumers } else { providers };
476 counterparts.retain(|n| n.id != me);
477 z.contracts.push(ContractRole {
478 role: e.weight().clone(),
479 contract: self.node_of(e.target()),
480 counterparts,
481 });
482 }
483 }
484 for e in self.g.edges_directed(focus, Outgoing) {
485 match e.weight() {
486 EdgeKind::Contains => z.members.push(self.node_of(e.target())),
487 EdgeKind::Calls | EdgeKind::Instantiates => {
488 z.callees.push(self.node_of(e.target()))
489 }
490 _ => {}
491 }
492 }
493 z
494 }
495
496 pub fn context_pack(&self, focus: NodeIndex, token_budget: usize) -> Vec<(Node, usize)> {
502 let ranks = self.personalized_pagerank(focus, 0.85, 40);
503 let mut ranked: Vec<(NodeIndex, f32)> = ranks
504 .into_iter()
505 .enumerate()
506 .filter(|&(i, _)| NodeIndex::new(i) != focus)
507 .map(|(i, r)| (NodeIndex::new(i), r))
508 .collect();
509 ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
510
511 let mut pack = Vec::new();
514 let mut spent = est_tokens(&self.g[focus].sym);
515 pack.push((self.node_of(focus), spent));
516 for (idx, rank) in ranked {
517 if rank <= 0.0 {
518 break; }
520 if matches!(
521 self.g[idx].sym.kind,
522 crate::model::SymbolKind::File | crate::model::SymbolKind::Contract
523 ) {
524 continue;
525 }
526 let t = est_tokens(&self.g[idx].sym);
527 if spent + t > token_budget {
528 continue;
529 }
530 spent += t;
531 pack.push((self.node_of(idx), t));
532 }
533 pack
534 }
535
536 fn personalized_pagerank(&self, seed: NodeIndex, damping: f32, iters: usize) -> Vec<f32> {
540 let n = self.g.node_count();
541 if n == 0 {
542 return vec![];
543 }
544 let mut neighbors: Vec<Vec<usize>> = vec![Vec::new(); n];
546 for e in self.g.edge_references() {
547 if !DEPENDENCY.contains(e.weight()) && !PROVIDES.contains(e.weight()) {
548 continue;
549 }
550 let (a, b) = (e.source().index(), e.target().index());
551 neighbors[a].push(b);
552 neighbors[b].push(a);
553 }
554 let s = seed.index();
555 let mut rank = vec![0.0f32; n];
556 rank[s] = 1.0;
557 for _ in 0..iters {
558 let mut next = vec![0.0f32; n];
559 next[s] += 1.0 - damping;
561 for u in 0..n {
562 if rank[u] == 0.0 || neighbors[u].is_empty() {
563 continue;
564 }
565 let share = damping * rank[u] / neighbors[u].len() as f32;
566 for &v in &neighbors[u] {
567 next[v] += share;
568 }
569 }
570 rank = next;
571 }
572 rank
573 }
574}
575
576fn est_tokens(s: &Symbol) -> usize {
578 let sig = s.signature.as_deref().map_or(0, str::len);
579 (sig + s.file_path.len() + 24) / 4
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585 use crate::model::{GlobalSymbolId, Language, Position, Range, RepoId, SymbolKind};
586
587 fn id(pkg: &str, desc: &str) -> GlobalSymbolId {
588 GlobalSymbolId {
589 scheme: "t".into(),
590 package_manager: ".".into(),
591 package: pkg.into(),
592 version: ".".into(),
593 descriptor: desc.into(),
594 }
595 }
596 fn sym(pkg: &str, desc: &str, name: &str, kind: SymbolKind) -> Symbol {
597 Symbol {
598 id: id(pkg, desc),
599 name: name.into(),
600 kind,
601 language: Language::Python,
602 repo: RepoId(pkg.into()),
603 file_path: format!("{pkg}/f.py"),
604 range: Range {
605 start: Position { line: 3, column: 0 },
606 end: Position { line: 9, column: 0 },
607 },
608 signature: Some(format!("def {name}(): ...")),
609 docstring: None,
610 fingerprint: None,
611 }
612 }
613 fn edge(s: &Symbol, d: &Symbol, k: EdgeKind) -> Edge {
614 Edge {
615 src: s.id.clone(),
616 dst: d.id.clone(),
617 kind: k,
618 confidence: 1.0,
619 cross_repo: s.id.package != d.id.package,
620 }
621 }
622
623 #[test]
624 fn ripple_finds_transitive_cross_repo_callers() {
625 let util = sym("lib", "lib/util().", "util", SymbolKind::Function);
627 let mid = sym("app", "app/mid().", "mid", SymbolKind::Function);
628 let top = sym("app", "app/top().", "top", SymbolKind::Function);
629 let syms = vec![util.clone(), mid.clone(), top.clone()];
630 let edges = vec![
631 edge(&mid, &util, EdgeKind::Calls),
632 edge(&top, &mid, EdgeKind::Calls),
633 ];
634 let g = CodeGraph::build(&syms, &edges);
635 let focus = g.lookup("lib/util().").unwrap();
636 let hits = g.ripple(focus, 5);
637 let names: Vec<_> = hits.iter().map(|h| h.node.name.as_str()).collect();
638 assert!(names.contains(&"mid"));
639 assert!(names.contains(&"top")); assert!(hits.iter().any(|h| h.node.repo == "app")); }
642
643 #[test]
644 fn context_pack_respects_budget_and_leads_with_focus() {
645 let a = sym("lib", "lib/a().", "a", SymbolKind::Function);
646 let b = sym("lib", "lib/b().", "b", SymbolKind::Function);
647 let c = sym("lib", "lib/c().", "c", SymbolKind::Function);
648 let syms = vec![a.clone(), b.clone(), c.clone()];
649 let edges = vec![edge(&a, &b, EdgeKind::Calls), edge(&b, &c, EdgeKind::Calls)];
650 let g = CodeGraph::build(&syms, &edges);
651 let focus = g.lookup("lib/a().").unwrap();
652 let pack = g.context_pack(focus, 10_000);
653 assert_eq!(pack[0].0.name, "a"); assert!(pack.len() >= 2); let tiny = g.context_pack(focus, 1); assert_eq!(tiny.len(), 1);
658 }
659}