Skip to main content

comindly_core/
graph.rs

1//! Comindly graph — the query layer a coding agent actually consumes.
2//!
3//! Loads the resolved symbol/edge graph into an in-memory `petgraph` (built once, queried in
4//! microseconds) and answers the questions grep can't:
5//!
6//!   * [`CodeGraph::ripple`] — *who breaks if I change this?* Reverse-reachable callers and
7//!     importers, multi-hop, across repos. The org-wide blast radius.
8//!   * [`CodeGraph::thread`] — *what does this do downstream?* Forward call trace from an entry.
9//!   * [`CodeGraph::zoom`]   — *show me this symbol in context.* Container, members, callers,
10//!     callees, importers in one structured view.
11//!   * [`CodeGraph::context_pack`] — *give me exactly what I need to read to change X safely,*
12//!     within a token budget. Personalized-PageRank proximity ranking, greedily packed.
13//!
14//! Every result carries an exact `file:line` location and the signature, so the agent jumps
15//! straight to code instead of searching for it.
16
17use 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
24/// Edge kinds meaning "src depends on dst": what `ripple` and ranking traverse.
25pub 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
36/// Provider-side contract roles: the contract depends on its provider, so `ripple` crosses
37/// these against their direction (handler → route → every consumer).
38pub 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
47/// Every contract-role edge kind (provider and consumer side).
48pub 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    /// edges we couldn't attach because an endpoint symbol was missing.
65    pub dangling_edges: usize,
66}
67
68impl CodeGraph {
69    /// Build from the resolved corpus. Edge endpoints are matched by rendered SCIP id.
70    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; // dedupe (same symbol can appear once per file scan)
79            }
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    /// A symbol and its containers (class, file) as ids: what explains it, from inside out.
111    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    /// A display node; methods are named `Class.method` so `__init__` and `get` read clearly.
137    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    /// Resolve a user-supplied focus string to a node: exact rendered id, then exact
166    /// descriptor, then descriptor substring, then symbol name. Prefers definitions
167    /// (functions/classes) over files, and shorter descriptors (less nested) on ties.
168    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        // `Class.method`, as nodes are displayed.
186        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    /// Indexed repos with their symbol counts (for a `repos` overview tool).
208    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    /// Cheap centrality proxy: number of direct dependents (incoming `Calls`/`Imports`).
219    /// Used to boost widely-depended-on code in search ranking — a signal pure semantic/
220    /// lexical search (e.g. semble) lacks.
221    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    /// Find symbols by name or descriptor substring. Prefers definitions over files and
232    /// shorter (less nested) descriptors. Returns up to `limit`.
233    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    /// *Who breaks if I change `focus`?* Reverse reachability over [`DEPENDENCY`] edges
251    /// (callers of callers, importers), multi-hop, cross-repo. Structural `Contains` is
252    /// ignored — a containing module is not a dependency.
253    pub fn ripple(&self, focus: NodeIndex, max_depth: usize) -> Vec<Hop> {
254        self.bfs(focus, max_depth, Incoming, DEPENDENCY, PROVIDES)
255    }
256
257    /// The flow from `focus` as the text a walkthrough is written from, one step per line.
258    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    /// Where a request or message started at `focus` travels: calls, then across contracts to the
272    /// handler of an HTTP route or the subscribers of a topic or queue, in other repositories too.
273    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    /// Where flows start: production functions whose flow goes furthest, the ones crossing the most
320    /// repositories first. Returns the node and how many steps its flow has.
321    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    /// *What does `focus` do downstream?* Forward call trace.
340    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    /// Providers and consumers of a contract node.
408    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    /// All contracts, optionally of one type (`http`, `sqs`, …) or only those missing a side.
424    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    /// The structured neighborhood of `focus`.
445    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    /// *The minimal correct context to change `focus` safely, within `token_budget`.*
497    ///
498    /// Ranks all symbols by personalized-PageRank proximity to `focus` (restart at focus,
499    /// edges treated as undirected for proximity), then greedily packs the highest-ranked
500    /// until the estimated token budget is exhausted. Returns `(node, est_tokens)` pairs.
501    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        // Focus itself is always first in the pack. Skip files — a pack is a read-set of
512        // definitions (functions/classes/methods), not whole files.
513        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; // unreachable from focus
519            }
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    /// Personalized PageRank with restart at `seed`, over the **dependency** view of the graph
537    /// (`Calls`/`Imports` only, treated as undirected). Structural `Contains` edges are
538    /// excluded so proximity reflects real usage, not mere co-location in a file/module.
539    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        // Undirected neighbor lists over dependency edges only.
545        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            // teleport mass back to the seed
560            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
576/// Rough token cost of including a symbol in a context pack (signature + location + slack).
577fn 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        // lib.util  <-calls-  app.mid  <-calls-  app.top   (app depends on lib)
626        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")); // transitive
640        assert!(hits.iter().any(|h| h.node.repo == "app")); // cross-repo blast radius
641    }
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"); // focus leads
654        assert!(pack.len() >= 2); // pulled in reachable neighbors
655
656        let tiny = g.context_pack(focus, 1); // only focus fits
657        assert_eq!(tiny.len(), 1);
658    }
659}