Skip to main content

lean_ctx/core/repomap/
graph.rs

1//! Graph builder for repo map.
2//!
3//! Constructs a file-level directed graph from the project index edges
4//! and call graph edges, then exposes symbol definitions per file.
5
6use std::collections::{HashMap, HashSet};
7
8use crate::core::call_graph::{CallGraph, CallGraphInputs};
9use crate::core::graph_index::{self, ProjectIndex, SymbolEntry};
10
11/// A symbol definition with its file context.
12#[derive(Debug, Clone)]
13pub struct SymbolDef {
14    pub name: String,
15    pub kind: String,
16    pub file: String,
17    pub line: usize,
18    pub end_line: usize,
19    pub is_exported: bool,
20    pub signature: String,
21}
22
23/// File-level graph combining import edges and call edges.
24pub struct RepoGraph {
25    pub files: HashSet<String>,
26    /// Forward adjacency: file -> list of files it depends on.
27    pub forward: HashMap<String, Vec<String>>,
28    /// All symbol definitions grouped by file.
29    pub symbols_by_file: HashMap<String, Vec<SymbolDef>>,
30}
31
32impl RepoGraph {
33    /// Build the repo graph from a project root.
34    ///
35    /// Loads or builds the project index and call graph,
36    /// then merges their edges into a unified file-level graph.
37    pub fn build(project_root: &str) -> Self {
38        let (index, content_cache) = graph_index::scan_with_content_cache(project_root);
39        let calls = graph_call_pairs(project_root).unwrap_or_else(|| {
40            let cg_inputs = CallGraphInputs::from_project_index(&index);
41            let call_graph = CallGraph::load_or_build(project_root, &cg_inputs);
42            structural_call_pairs(&cg_inputs, &call_graph)
43        });
44
45        Self::from_index_and_calls(&index, &calls, &content_cache)
46    }
47
48    /// `calls`: caller file → callee file pairs (see [`graph_call_pairs`]).
49    fn from_index_and_calls(
50        index: &ProjectIndex,
51        calls: &[(String, String)],
52        content_cache: &HashMap<String, String>,
53    ) -> Self {
54        let files: HashSet<String> = index.files.keys().cloned().collect();
55
56        let mut forward: HashMap<String, Vec<String>> = HashMap::new();
57
58        // Import edges from the project index
59        for edge in &index.edges {
60            if files.contains(&edge.from) && files.contains(&edge.to) && edge.from != edge.to {
61                forward
62                    .entry(edge.from.clone())
63                    .or_default()
64                    .push(edge.to.clone());
65            }
66        }
67
68        // Call edges, resolved like the property graph's (never by bare name)
69        for (from, to) in calls {
70            if files.contains(from) && files.contains(to) && from != to {
71                forward.entry(from.clone()).or_default().push(to.clone());
72            }
73        }
74
75        // Deduplicate edges
76        for deps in forward.values_mut() {
77            deps.sort();
78            deps.dedup();
79        }
80
81        let symbols_by_file = build_symbols_with_signatures(index, content_cache);
82
83        Self {
84            files,
85            forward,
86            symbols_by_file,
87        }
88    }
89}
90
91/// File-level `calls` edges of a current property graph: the same edges
92/// ranking and impact analysis use — scope-resolved, verified by a semantic
93/// backend where one answered, and without name-match guesses a backend
94/// vetoed. `None` without a current graph whose calls were consolidated (a
95/// freshly mirrored graph has none until enrichment runs). An empty edge set
96/// counts once a backend has answered for the graph: then every candidate
97/// was vetoed or ambiguous, and a fallback would resurrect the vetoed guess.
98fn graph_call_pairs(project_root: &str) -> Option<Vec<(String, String)>> {
99    use crate::core::property_graph::{CodeGraph, EdgeKind};
100    if crate::core::property_graph::engine_outdated(project_root) {
101        return None;
102    }
103    let graph = CodeGraph::open(project_root).ok()?;
104    let pairs: Vec<(String, String)> = graph
105        .file_edges_of_kind(&EdgeKind::Calls)
106        .ok()?
107        .into_iter()
108        // Serve-time source check (G04): an edge of a withdrawn file neither
109        // appears in the map nor ranks another file.
110        .filter(|(from, to, _)| graph.serves_file(from) && graph.serves_file(to))
111        .map(|(from, to, _)| (from, to))
112        .collect();
113    (!pairs.is_empty() || graph.has_semantic_answers().unwrap_or(false)).then_some(pairs)
114}
115
116/// Without a property graph: each call bound in the caller's own scope
117/// (same file → unique import → unique in the project); an ambiguous name
118/// yields no edge rather than an arbitrary one.
119fn structural_call_pairs(
120    inputs: &CallGraphInputs,
121    call_graph: &CallGraph,
122) -> Vec<(String, String)> {
123    crate::core::call_graph::resolve_edge_callee_files(inputs, &call_graph.edges)
124        .into_iter()
125        .zip(&call_graph.edges)
126        .filter_map(|(to, edge)| Some((edge.caller_file.clone(), to?)))
127        .collect()
128}
129
130/// Build symbol definitions with compact signatures from file contents.
131fn build_symbols_with_signatures(
132    index: &ProjectIndex,
133    content_cache: &HashMap<String, String>,
134) -> HashMap<String, Vec<SymbolDef>> {
135    let mut result: HashMap<String, Vec<SymbolDef>> = HashMap::new();
136
137    // Group index symbols by file
138    let mut idx_symbols: HashMap<&str, Vec<&SymbolEntry>> = HashMap::new();
139    for sym in index.symbols.values() {
140        idx_symbols.entry(sym.file.as_str()).or_default().push(sym);
141    }
142
143    for (file_path, file_entry) in &index.files {
144        let ext = std::path::Path::new(file_path)
145            .extension()
146            .and_then(|e| e.to_str())
147            .unwrap_or("");
148
149        // Extract signatures from file content if available
150        let signatures = content_cache
151            .get(file_path)
152            .map(|content| crate::core::signatures::extract_signatures(content, ext))
153            .unwrap_or_default();
154
155        let sig_by_name: HashMap<&str, &crate::core::signatures::Signature> =
156            signatures.iter().map(|s| (s.name.as_str(), s)).collect();
157
158        let mut file_symbols: Vec<SymbolDef> = Vec::new();
159
160        if let Some(syms) = idx_symbols.get(file_path.as_str()) {
161            for sym in syms {
162                let signature = sig_by_name
163                    .get(sym.name.as_str())
164                    .map_or_else(|| format!("{} {}", sym.kind, sym.name), |s| s.to_compact());
165
166                file_symbols.push(SymbolDef {
167                    name: sym.name.clone(),
168                    kind: sym.kind.clone(),
169                    file: sym.file.clone(),
170                    line: sym.start_line,
171                    end_line: sym.end_line,
172                    is_exported: sym.is_exported,
173                    signature,
174                });
175            }
176        }
177
178        // Also include exports from file entry that may not be in the symbols map
179        for export in &file_entry.exports {
180            let already_present = file_symbols.iter().any(|s| s.name == *export);
181            if !already_present {
182                let signature = sig_by_name
183                    .get(export.as_str())
184                    .map_or_else(|| export.clone(), |s| s.to_compact());
185
186                let (line, end_line) = sig_by_name
187                    .get(export.as_str())
188                    .and_then(|s| s.start_line.zip(s.end_line))
189                    .unwrap_or((0, 0));
190
191                file_symbols.push(SymbolDef {
192                    name: export.clone(),
193                    kind: "export".to_string(),
194                    file: file_path.clone(),
195                    line,
196                    end_line,
197                    is_exported: true,
198                    signature,
199                });
200            }
201        }
202
203        file_symbols.sort_by_key(|s| s.line);
204
205        if !file_symbols.is_empty() {
206            result.insert(file_path.clone(), file_symbols);
207        }
208    }
209
210    result
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    /// Two files define `save`; the caller imports neither. The repo map
218    /// used to link the call to whichever definition it saw first — now an
219    /// ambiguous name yields no edge, while a scope-bound one still does.
220    #[test]
221    fn ambiguous_calls_are_not_linked_by_name() {
222        use crate::core::call_graph::{CallEdge, SymbolSpan};
223        let span = |file: &str, name: &str| SymbolSpan {
224            file: file.into(),
225            name: name.into(),
226            start_line: 1,
227            end_line: 3,
228            kind: "fn".into(),
229        };
230        let inputs = CallGraphInputs {
231            project_root: "/tmp".into(),
232            file_paths: vec!["a.rs".into(), "b.rs".into(), "app.rs".into()],
233            symbols: vec![
234                span("a.rs", "save"),
235                span("b.rs", "save"),
236                span("b.rs", "load"),
237            ],
238            import_edges: Vec::new(),
239        };
240        let call = |callee: &str| CallEdge {
241            caller_file: "app.rs".into(),
242            caller_symbol: "run".into(),
243            caller_line: 2,
244            callee_name: callee.into(),
245            ..Default::default()
246        };
247        let mut call_graph = CallGraph::new("/tmp");
248        call_graph.edges = vec![call("save"), call("load")];
249
250        assert_eq!(
251            structural_call_pairs(&inputs, &call_graph),
252            [("app.rs".to_string(), "b.rs".to_string())],
253            "only the unique `load` binds"
254        );
255    }
256
257    /// A current graph without call edges is authoritative once a backend
258    /// answered for it (every guess was vetoed) — but not before enrichment.
259    #[test]
260    fn empty_graph_calls_count_only_after_a_semantic_answer() {
261        use crate::core::property_graph::{
262            CachedResolution, CodeGraph, GRAPH_ENGINE_VERSION, PropertyGraphMetaV1, write_meta,
263        };
264        let _iso = crate::core::data_dir::isolated_data_dir();
265        let proj = tempfile::tempdir().unwrap();
266        let root = proj.path().to_str().unwrap();
267        let meta = PropertyGraphMetaV1 {
268            built_at: "2026-01-01T00:00:00Z".to_string(),
269            engine_version: GRAPH_ENGINE_VERSION,
270            ..Default::default()
271        };
272        write_meta(root, &meta).unwrap();
273        let graph = CodeGraph::open(root).unwrap();
274        assert_eq!(graph_call_pairs(root), None, "not consolidated yet");
275
276        let veto = CachedResolution {
277            backend: "lsp:test@1".into(),
278            outcome: "external".into(),
279            target_file: None,
280            target_line: None,
281            target_symbol: None,
282            context: String::new(),
283        };
284        let admission = crate::core::context_admission::stores::StoreAdmission::current();
285        graph
286            .semantic_store(("app.rs", 2, 4, "definition"), "h", &veto, &admission)
287            .unwrap();
288        assert_eq!(graph_call_pairs(root), Some(Vec::new()), "vetoed stays out");
289    }
290
291    #[test]
292    fn repo_graph_deduplicates_edges() {
293        let mut index = ProjectIndex::new("/tmp");
294        index.files.insert("a.rs".into(), dummy_file_entry("a.rs"));
295        index.files.insert("b.rs".into(), dummy_file_entry("b.rs"));
296        index.edges.push(graph_index::IndexEdge {
297            from: "a.rs".into(),
298            to: "b.rs".into(),
299            kind: "import".into(),
300            weight: 1.0,
301        });
302        index.edges.push(graph_index::IndexEdge {
303            from: "a.rs".into(),
304            to: "b.rs".into(),
305            kind: "import".into(),
306            weight: 1.0,
307        });
308
309        let graph = RepoGraph::from_index_and_calls(&index, &[], &HashMap::new());
310
311        let a_deps = graph.forward.get("a.rs").unwrap();
312        assert_eq!(a_deps.len(), 1, "duplicate edges should be deduped");
313    }
314
315    #[test]
316    fn repo_graph_ignores_self_edges() {
317        let mut index = ProjectIndex::new("/tmp");
318        index.files.insert("a.rs".into(), dummy_file_entry("a.rs"));
319        index.edges.push(graph_index::IndexEdge {
320            from: "a.rs".into(),
321            to: "a.rs".into(),
322            kind: "import".into(),
323            weight: 1.0,
324        });
325
326        let graph = RepoGraph::from_index_and_calls(&index, &[], &HashMap::new());
327
328        assert!(
329            !graph.forward.contains_key("a.rs"),
330            "self-edges should be excluded"
331        );
332    }
333
334    fn dummy_file_entry(path: &str) -> graph_index::FileEntry {
335        graph_index::FileEntry {
336            path: path.into(),
337            hash: "abc".into(),
338            language: "rust".into(),
339            line_count: 10,
340            token_count: 50,
341            exports: vec![],
342            summary: String::new(),
343        }
344    }
345}