Skip to main content

lean_ctx/core/
graph_provider.rs

1use std::path::Path;
2use std::sync::atomic::{AtomicBool, Ordering};
3
4use super::graph_index::{self, ProjectIndex};
5use super::property_graph::served::GraphGate;
6use super::property_graph::{CodeGraph, GraphSource};
7
8static GRAPH_BUILD_TRIGGERED: AtomicBool = AtomicBool::new(false);
9
10#[derive(Debug, Clone)]
11pub struct SymbolInfo {
12    pub name: String,
13    pub file: String,
14    pub kind: String,
15    pub start_line: usize,
16    pub end_line: usize,
17    pub is_exported: bool,
18}
19
20/// Normalize a property-graph edge-kind string back to the graph_index spelling
21/// when materializing a [`ProjectIndex`]. The mirror maps graph_index `import` →
22/// `EdgeKind::Imports`, which serializes as the plural `imports`; the legacy
23/// dependency consumers (`graph_index` BFS, the facade's index-backed
24/// `dependencies`/`dependents`, `ctx_graph`) filter on the singular `import`, so
25/// reverse exactly that one rename. All other kinds already share their spelling
26/// across both stores (#696 C1).
27fn index_edge_kind(pg_kind: &str) -> String {
28    match pg_kind {
29        "imports" => "import".to_string(),
30        other => other.to_string(),
31    }
32}
33
34/// Absolute distance from a symbol's `start` line to a handle's hint `line`.
35/// A missing hint contributes zero distance, so handles without an `@Lline`
36/// suffix neither help nor hurt the tiebreak (all candidates rank equal here).
37fn line_distance(start: usize, target: Option<usize>) -> usize {
38    match target {
39        Some(t) => start.abs_diff(t),
40        None => 0,
41    }
42}
43
44/// Convert a property-graph symbol [`Node`] into a backend-agnostic
45/// [`SymbolInfo`], recovering the precise source `kind` and export flag from the
46/// node metadata (the `Node` itself only carries a coarse `NodeKind`). Single
47/// source of truth for the three facade methods that surface PG symbols, so a
48/// materialized `ProjectIndex` is lossless (#696 C1).
49fn symbol_info_from_node(n: super::property_graph::Node) -> SymbolInfo {
50    let (meta_kind, meta_exported) =
51        super::property_graph::parse_symbol_metadata(n.metadata.as_deref());
52    SymbolInfo {
53        kind: meta_kind.unwrap_or_else(|| n.kind.as_str().to_string()),
54        is_exported: meta_exported.unwrap_or(true),
55        name: n.name,
56        file: n.file_path,
57        start_line: n.line_start.unwrap_or(0),
58        end_line: n.line_end.unwrap_or(0),
59    }
60}
61
62#[derive(Debug, Clone)]
63pub struct EdgeInfo {
64    pub from: String,
65    pub to: String,
66    pub kind: String,
67    pub weight: f64,
68}
69
70#[derive(Debug, Clone)]
71pub struct FileInfo {
72    pub path: String,
73    pub hash: String,
74    pub language: String,
75    pub line_count: usize,
76    pub token_count: usize,
77    pub exports: Vec<String>,
78    pub summary: String,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum GraphProviderSource {
83    PropertyGraph,
84    GraphIndex,
85}
86
87// One provider lives for one call on the stack; boxing the graph would add an
88// allocation per open and touch every construction site for no gain.
89#[allow(clippy::large_enum_variant)]
90pub enum GraphProvider {
91    PropertyGraph(CodeGraph),
92    GraphIndex(ProjectIndex),
93}
94
95pub struct OpenGraphProvider {
96    pub source: GraphProviderSource,
97    pub provider: GraphProvider,
98}
99
100/// The file an edge end names: a file path, or the file of a
101/// `path::symbol` key (a file path never contains `::`).
102fn edge_file(end: &str) -> &str {
103    end.split_once("::").map_or(end, |(file, _)| file)
104}
105
106/// The tool-facing surface. Every file, symbol and edge it returns has passed
107/// the serve-time source check (`property_graph::served`, G04): the property
108/// graph carries its gate for the request that opened it, the index backend
109/// gets one per call. The `*_unchecked` accessors below are the raw store
110/// reads and stay private to this facade.
111impl GraphProvider {
112    fn index_gate(&self) -> Option<GraphGate> {
113        match self {
114            GraphProvider::PropertyGraph(_) => None,
115            GraphProvider::GraphIndex(i) => Some(GraphGate::for_root(&i.project_root)),
116        }
117    }
118
119    fn file_served(&self, gate: Option<&GraphGate>, path: &str) -> bool {
120        match self {
121            GraphProvider::PropertyGraph(g) => g.serves_file(path),
122            GraphProvider::GraphIndex(_) => gate.is_none_or(|gate| gate.file(path)),
123        }
124    }
125
126    fn symbol_served(&self, gate: Option<&GraphGate>, path: &str, name: &str) -> bool {
127        match self {
128            GraphProvider::PropertyGraph(g) => g.serves_symbol(path, name),
129            GraphProvider::GraphIndex(_) => gate.is_none_or(|gate| gate.symbol(path, name)),
130        }
131    }
132
133    /// `paths` found for `anchor`: nothing when the anchor itself may not be
134    /// served, otherwise the paths that may.
135    fn served_from(&self, anchor: Option<&str>, paths: Vec<String>) -> Vec<String> {
136        let gate = self.index_gate();
137        if anchor.is_some_and(|anchor| !self.file_served(gate.as_ref(), anchor)) {
138            return Vec::new();
139        }
140        paths
141            .into_iter()
142            .filter(|path| self.file_served(gate.as_ref(), path))
143            .collect()
144    }
145
146    fn served_symbols(&self, symbols: Vec<SymbolInfo>) -> Vec<SymbolInfo> {
147        let gate = self.index_gate();
148        symbols
149            .into_iter()
150            .filter(|symbol| self.symbol_served(gate.as_ref(), &symbol.file, &symbol.name))
151            .collect()
152    }
153
154    pub fn dependencies(&self, file_path: &str) -> Vec<String> {
155        self.served_from(Some(file_path), self.dependencies_unchecked(file_path))
156    }
157
158    pub fn dependents(&self, file_path: &str) -> Vec<String> {
159        self.served_from(Some(file_path), self.dependents_unchecked(file_path))
160    }
161
162    /// Files reached from `file_path` within `depth`. The walk itself runs
163    /// over served files only: nothing is reached through a file that is not
164    /// served now (`property_graph::queries::impact_analysis`,
165    /// `ProjectIndex::get_related_served`).
166    pub fn related(&self, file_path: &str, depth: usize) -> Vec<String> {
167        let gate = self.index_gate();
168        let served = |path: &str| self.file_served(gate.as_ref(), path);
169        if !served(file_path) {
170            return Vec::new();
171        }
172        match self {
173            GraphProvider::PropertyGraph(g) => g
174                .impact_analysis(file_path, depth)
175                .map(|r| r.affected_files)
176                .unwrap_or_default(),
177            GraphProvider::GraphIndex(i) => i.get_related_served(file_path, depth, &served),
178        }
179    }
180
181    /// The file graph a computation over the whole graph may use — community
182    /// detection, a ranking: the files served now and the edges between two
183    /// of them, decided by one gate (`GraphGate::ranking_files`; under
184    /// protection a graph too large to decide in full yields nothing).
185    pub(crate) fn ranking_graph(&self) -> (Vec<String>, Vec<EdgeInfo>) {
186        let mut files = self.file_paths_unchecked();
187        files.sort();
188        files.dedup();
189        let edges = self.edges_unchecked();
190        let pairs: Vec<(String, String)> = edges
191            .iter()
192            .map(|e| (edge_file(&e.from).to_owned(), edge_file(&e.to).to_owned()))
193            .collect();
194        let served = match self {
195            GraphProvider::PropertyGraph(g) => g.rankable_files(files.clone(), &pairs, &[]),
196            GraphProvider::GraphIndex(i) => {
197                GraphGate::for_root(&i.project_root).ranking_files(files.clone(), &pairs, &[])
198            }
199        };
200        files.retain(|file| served.contains(file));
201        let edges = edges
202            .into_iter()
203            .filter(|e| served.contains(edge_file(&e.from)) && served.contains(edge_file(&e.to)))
204            .collect();
205        (files, edges)
206    }
207
208    pub fn file_paths(&self) -> Vec<String> {
209        self.served_from(None, self.file_paths_unchecked())
210    }
211
212    pub fn find_symbols(
213        &self,
214        name: &str,
215        file_filter: Option<&str>,
216        kind_filter: Option<&str>,
217    ) -> Vec<SymbolInfo> {
218        self.served_symbols(self.find_symbols_unchecked(name, file_filter, kind_filter))
219    }
220
221    /// Every symbol with its file + line span (unfiltered by name).
222    pub fn all_symbols(&self) -> Vec<SymbolInfo> {
223        self.served_symbols(self.all_symbols_unchecked())
224    }
225
226    pub fn get_symbol(&self, key: &str) -> Option<SymbolInfo> {
227        let gate = self.index_gate();
228        self.get_symbol_unchecked(key)
229            .filter(|symbol| self.symbol_served(gate.as_ref(), &symbol.file, &symbol.name))
230    }
231
232    pub fn edges(&self) -> Vec<EdgeInfo> {
233        let gate = self.index_gate();
234        let served = |end: &str| self.file_served(gate.as_ref(), edge_file(end));
235        self.edges_unchecked()
236            .into_iter()
237            .filter(|edge| served(edge.from.as_str()) && served(edge.to.as_str()))
238            .collect()
239    }
240
241    pub fn get_file_entry(&self, path: &str) -> Option<FileInfo> {
242        let gate = self.index_gate();
243        self.file_served(gate.as_ref(), path)
244            .then(|| self.get_file_entry_unchecked(path))
245            .flatten()
246    }
247
248    /// Scored related files using multi-edge weights (unscored deps and
249    /// dependents for the index backend). Only served neighbours are ranked,
250    /// and `limit` is applied after the check: a file that is not served now
251    /// takes no slot from one that is.
252    pub fn related_files_scored(&self, file_path: &str, limit: usize) -> Vec<(String, f64)> {
253        let gate = self.index_gate();
254        self.related_files_scored_with(gate.as_ref(), file_path, limit)
255    }
256
257    /// [`Self::related_files_scored`] under the caller's request gate: the
258    /// opened property graph carries its own; the index backend has none and
259    /// is decided by the gate the caller creates once per request, so each
260    /// file is decided once and one bound is spent.
261    pub(crate) fn related_files_scored_with(
262        &self,
263        gate: Option<&GraphGate>,
264        file_path: &str,
265        limit: usize,
266    ) -> Vec<(String, f64)> {
267        match self {
268            GraphProvider::PropertyGraph(g) => {
269                g.related_files(file_path, limit).unwrap_or_default()
270            }
271            GraphProvider::GraphIndex(_) => {
272                let served = |path: &str| self.file_served(gate, path);
273                let mut result: Vec<(String, f64)> = Vec::new();
274                if !served(file_path) {
275                    return result;
276                }
277                let neighbours = self
278                    .dependencies_unchecked(file_path)
279                    .into_iter()
280                    .map(|dep| (dep, 1.0))
281                    .chain(
282                        self.dependents_unchecked(file_path)
283                            .into_iter()
284                            .map(|dep| (dep, 0.5)),
285                    );
286                for (dep, score) in neighbours {
287                    if result.len() >= limit {
288                        break;
289                    }
290                    if !result.iter().any(|(path, _)| *path == dep) && served(&dep) {
291                        result.push((dep, score));
292                    }
293                }
294                result
295            }
296        }
297    }
298
299    pub fn node_count(&self) -> Option<usize> {
300        match self {
301            GraphProvider::PropertyGraph(g) => g.node_count().ok(),
302            GraphProvider::GraphIndex(i) => Some(i.file_count()),
303        }
304    }
305
306    pub fn edge_count(&self) -> Option<usize> {
307        match self {
308            GraphProvider::PropertyGraph(g) => g.edge_count().ok(),
309            GraphProvider::GraphIndex(i) => Some(i.edge_count()),
310        }
311    }
312
313    fn dependencies_unchecked(&self, file_path: &str) -> Vec<String> {
314        match self {
315            GraphProvider::PropertyGraph(g) => g.dependencies(file_path).unwrap_or_default(),
316            GraphProvider::GraphIndex(i) => i
317                .edges
318                .iter()
319                .filter(|e| e.kind == "import" && e.from == file_path)
320                .map(|e| e.to.clone())
321                .collect(),
322        }
323    }
324
325    fn dependents_unchecked(&self, file_path: &str) -> Vec<String> {
326        match self {
327            GraphProvider::PropertyGraph(g) => g.dependents(file_path).unwrap_or_default(),
328            GraphProvider::GraphIndex(i) => i
329                .edges
330                .iter()
331                .filter(|e| e.kind == "import" && e.to == file_path)
332                .map(|e| e.from.clone())
333                .collect(),
334        }
335    }
336
337    fn file_paths_unchecked(&self) -> Vec<String> {
338        match self {
339            GraphProvider::PropertyGraph(g) => g.file_catalog_paths().unwrap_or_default(),
340            GraphProvider::GraphIndex(i) => {
341                let mut paths: Vec<String> = i.files.keys().cloned().collect();
342                paths.sort();
343                paths
344            }
345        }
346    }
347
348    pub fn file_count(&self) -> usize {
349        match self {
350            GraphProvider::PropertyGraph(g) => g.file_catalog_count().unwrap_or(0),
351            GraphProvider::GraphIndex(i) => i.files.len(),
352        }
353    }
354
355    pub fn symbol_count(&self) -> usize {
356        match self {
357            GraphProvider::PropertyGraph(g) => g.symbol_count().unwrap_or(0),
358            GraphProvider::GraphIndex(i) => i.symbols.len(),
359        }
360    }
361
362    fn find_symbols_unchecked(
363        &self,
364        name: &str,
365        file_filter: Option<&str>,
366        kind_filter: Option<&str>,
367    ) -> Vec<SymbolInfo> {
368        match self {
369            GraphProvider::PropertyGraph(g) => g
370                // Kind is filtered AFTER mapping, not in SQL: the PG `nodes.kind`
371                // column is the coarse `NodeKind` (every code symbol is `"symbol"`),
372                // while the precise source kind (`struct`, `fn`, …) lives in metadata
373                // and is recovered by `symbol_info_from_node`. Passing `kind_filter`
374                // to the SQL query matched it against `"symbol"` and dropped every
375                // hit — an exported struct queried with kind="struct" came back empty
376                // (#889). ponytail: the SQL LIMIT 100 caps candidates pre-kind-filter,
377                // fine until a name substring has >100 cross-kind matches.
378                .find_symbols(name, file_filter, None)
379                .unwrap_or_default()
380                .into_iter()
381                .map(symbol_info_from_node)
382                .filter(|s| kind_filter.is_none_or(|k| s.kind == k))
383                .collect(),
384            GraphProvider::GraphIndex(i) => {
385                let name_lower = name.to_lowercase();
386                // Same order and cap as the property graph's SQL (path, line):
387                // a cap over hash order would pick a different 100 per process.
388                let mut hits: Vec<_> = i
389                    .symbols
390                    .values()
391                    .filter(|s| s.name.to_lowercase().contains(&name_lower))
392                    .filter(|s| file_filter.is_none_or(|f| s.file.contains(f)))
393                    .filter(|s| kind_filter.is_none_or(|k| s.kind == k))
394                    .collect();
395                hits.sort_by(|a, b| {
396                    (a.file.as_str(), a.start_line, a.name.as_str()).cmp(&(
397                        b.file.as_str(),
398                        b.start_line,
399                        b.name.as_str(),
400                    ))
401                });
402                hits.into_iter()
403                    .take(100)
404                    .map(|s| SymbolInfo {
405                        name: s.name.clone(),
406                        file: s.file.clone(),
407                        kind: s.kind.clone(),
408                        start_line: s.start_line,
409                        end_line: s.end_line,
410                        is_exported: s.is_exported,
411                    })
412                    .collect()
413            }
414        }
415    }
416
417    /// Every symbol with its file + line span (unfiltered). Backend-agnostic
418    /// equivalent of iterating `ProjectIndex::symbols` — used by the call-graph
419    /// builder to attribute call sites to their enclosing symbol.
420    fn all_symbols_unchecked(&self) -> Vec<SymbolInfo> {
421        match self {
422            GraphProvider::PropertyGraph(g) => g
423                .all_symbols()
424                .unwrap_or_default()
425                .into_iter()
426                .map(symbol_info_from_node)
427                .collect(),
428            GraphProvider::GraphIndex(i) => i
429                .symbols
430                .values()
431                .map(|s| SymbolInfo {
432                    name: s.name.clone(),
433                    file: s.file.clone(),
434                    kind: s.kind.clone(),
435                    start_line: s.start_line,
436                    end_line: s.end_line,
437                    is_exported: s.is_exported,
438                })
439                .collect(),
440        }
441    }
442
443    fn get_symbol_unchecked(&self, key: &str) -> Option<SymbolInfo> {
444        match self {
445            GraphProvider::PropertyGraph(g) => {
446                // Keys are `rel_path::sym_name` (graph_index, see `mod.rs`). A
447                // file path never contains `::`, but a symbol name does for trait
448                // impls (`std::fmt::Display for T`). Split on the FIRST `::` so
449                // those names round-trip — `rsplitn` mangled them (#682.3).
450                let parts: Vec<&str> = key.splitn(2, "::").collect();
451                if parts.len() != 2 {
452                    return None;
453                }
454                let (file_path, sym_name) = (parts[0], parts[1]);
455                g.get_node_by_symbol(sym_name, file_path)
456                    .ok()
457                    .flatten()
458                    .map(symbol_info_from_node)
459            }
460            GraphProvider::GraphIndex(i) => i.get_symbol(key).map(|s| SymbolInfo {
461                name: s.name.clone(),
462                file: s.file.clone(),
463                kind: s.kind.clone(),
464                start_line: s.start_line,
465                end_line: s.end_line,
466                is_exported: s.is_exported,
467            }),
468        }
469    }
470
471    /// Resolve a stable `SymbolHandle` to
472    /// its current [`SymbolInfo`], robust to line drift. Resolution order:
473    ///
474    /// 1. Exact `(path, name)` — the unique `{file}::{name}` index key, so the
475    ///    common case is a single `O(1)` lookup that ignores the `@Lline` hint
476    ///    entirely (the symbol may have moved since the handle was emitted).
477    /// 2. Otherwise, same-file candidates whose name matches exactly or by its
478    ///    unqualified tail (`Config::load` ↔ `load`), ranked by exact-name
479    ///    first, then nearest line to the handle hint, then lowest line, then
480    ///    name — a total, deterministic order (#498).
481    ///
482    /// Returns `None` only when no symbol in that file plausibly matches. The
483    /// `@Lline` is never a hard requirement, so this is strictly more robust
484    /// than a brittle line-only reference.
485    pub fn find_symbol_by_handle(
486        &self,
487        handle: &crate::core::handle::SymbolHandle,
488    ) -> Option<SymbolInfo> {
489        let key = format!("{}::{}", handle.path, handle.name);
490        if let Some(sym) = self.get_symbol(&key) {
491            return Some(sym);
492        }
493
494        let tail = handle
495            .name
496            .rsplit("::")
497            .next()
498            .unwrap_or(handle.name.as_str());
499        let mut candidates: Vec<SymbolInfo> = self
500            .all_symbols()
501            .into_iter()
502            .filter(|s| s.file == handle.path)
503            .filter(|s| s.name == handle.name || s.name.rsplit("::").next() == Some(tail))
504            .collect();
505        if candidates.is_empty() {
506            return None;
507        }
508        candidates.sort_by(|a, b| {
509            let exact_a = u8::from(a.name != handle.name);
510            let exact_b = u8::from(b.name != handle.name);
511            exact_a
512                .cmp(&exact_b)
513                .then_with(|| {
514                    line_distance(a.start_line, handle.line)
515                        .cmp(&line_distance(b.start_line, handle.line))
516                })
517                .then_with(|| a.start_line.cmp(&b.start_line))
518                .then_with(|| a.name.cmp(&b.name))
519        });
520        candidates.into_iter().next()
521    }
522
523    fn edges_unchecked(&self) -> Vec<EdgeInfo> {
524        match self {
525            // Normalize the raw property-graph edge-kind vocabulary back to the
526            // graph_index vocabulary every consumer speaks (notably `imports` →
527            // `import`, queried by impact/overview via `edges_by_kind("import")`).
528            // Without this the facade leaks `EdgeKind::as_str()` plurals and PG
529            // vs legacy backends would answer the same query differently (#696).
530            GraphProvider::PropertyGraph(g) => g
531                .all_edges_flat()
532                .unwrap_or_default()
533                .into_iter()
534                .map(|(from, to, kind, weight)| EdgeInfo {
535                    from,
536                    to,
537                    kind: index_edge_kind(&kind),
538                    weight,
539                })
540                .collect(),
541            GraphProvider::GraphIndex(i) => i
542                .edges
543                .iter()
544                .map(|e| EdgeInfo {
545                    from: e.from.clone(),
546                    to: e.to.clone(),
547                    kind: e.kind.clone(),
548                    weight: e.weight as f64,
549                })
550                .collect(),
551        }
552    }
553
554    pub fn edges_by_kind(&self, kind: &str) -> Vec<EdgeInfo> {
555        self.edges()
556            .into_iter()
557            .filter(|e| e.kind == kind)
558            .collect()
559    }
560
561    /// Every catalogued file as [`FileInfo`]. Backend-agnostic equivalent of
562    /// iterating `ProjectIndex::files` — used by stats/bootstrap consumers that
563    /// need per-file language + token counts, not just paths.
564    pub fn file_entries(&self) -> Vec<FileInfo> {
565        match self {
566            GraphProvider::PropertyGraph(_) => self
567                .file_paths()
568                .into_iter()
569                .filter_map(|p| self.get_file_entry_unchecked(&p))
570                .collect(),
571            GraphProvider::GraphIndex(i) => {
572                let gate = self.index_gate();
573                i.files
574                    .values()
575                    .filter(|e| self.file_served(gate.as_ref(), &e.path))
576                    .map(|e| FileInfo {
577                        path: e.path.clone(),
578                        hash: e.hash.clone(),
579                        language: e.language.clone(),
580                        line_count: e.line_count,
581                        token_count: e.token_count,
582                        exports: e.exports.clone(),
583                        summary: e.summary.clone(),
584                    })
585                    .collect()
586            }
587        }
588    }
589
590    fn get_file_entry_unchecked(&self, path: &str) -> Option<FileInfo> {
591        match self {
592            GraphProvider::PropertyGraph(g) => {
593                g.get_file_catalog(path).ok().flatten().map(|e| FileInfo {
594                    path: e.path,
595                    hash: e.hash,
596                    language: e.language,
597                    line_count: e.line_count,
598                    token_count: e.token_count,
599                    exports: e.exports,
600                    summary: e.summary,
601                })
602            }
603            GraphProvider::GraphIndex(i) => i.files.get(path).map(|e| FileInfo {
604                path: e.path.clone(),
605                hash: e.hash.clone(),
606                language: e.language.clone(),
607                line_count: e.line_count,
608                token_count: e.token_count,
609                exports: e.exports.clone(),
610                summary: e.summary.clone(),
611            }),
612        }
613    }
614
615    pub fn last_scan(&self) -> String {
616        match self {
617            GraphProvider::PropertyGraph(_) => String::new(),
618            GraphProvider::GraphIndex(i) => i.last_scan.clone(),
619        }
620    }
621
622    /// Reconstruct a full [`ProjectIndex`] from this provider — the inverse of
623    /// the graph_index→PG mirror
624    /// ([`populate_from_project_index`](super::property_graph::populate_from_project_index)).
625    /// Lets the
626    /// remaining legacy `ProjectIndex` consumers be sourced from the
627    /// PropertyGraph (parity-proven lossless, #682.3) so the redundant JSON
628    /// store can be retired (#696 phase C). For the GraphIndex backend it clones
629    /// the index it already holds.
630    pub fn materialize_project_index(&self, project_root: &str) -> ProjectIndex {
631        if let GraphProvider::GraphIndex(i) = self {
632            // The index it already holds, without what may not be served now.
633            let gate = self.index_gate();
634            let served = |path: &str| self.file_served(gate.as_ref(), path);
635            let mut index = i.clone();
636            index.files.retain(|path, _| served(path.as_str()));
637            index
638                .symbols
639                .retain(|_, symbol| self.symbol_served(gate.as_ref(), &symbol.file, &symbol.name));
640            index
641                .edges
642                .retain(|edge| served(edge_file(&edge.from)) && served(edge_file(&edge.to)));
643            index.rebuild_interner();
644            return index;
645        }
646        let mut idx = ProjectIndex::new(project_root);
647        // Stamp `last_scan` from the graph's build time (graph.meta.json) so the
648        // TTL staleness check reflects the real build age, not this
649        // materialization moment (#696 C4). Content-based staleness still keys
650        // off the meta file's mtime independently.
651        if let Some(meta) = super::property_graph::load_meta(project_root)
652            && let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&meta.built_at)
653        {
654            idx.last_scan = dt
655                .with_timezone(&chrono::Local)
656                .format("%Y-%m-%d %H:%M:%S")
657                .to_string();
658        }
659        for f in self.file_entries() {
660            idx.files.insert(
661                f.path.clone(),
662                graph_index::FileEntry {
663                    path: f.path,
664                    hash: f.hash,
665                    language: f.language,
666                    line_count: f.line_count,
667                    token_count: f.token_count,
668                    exports: f.exports,
669                    summary: f.summary,
670                },
671            );
672        }
673        for s in self.all_symbols() {
674            let key = format!("{}::{}", s.file, s.name);
675            idx.symbols.insert(
676                key,
677                graph_index::SymbolEntry {
678                    file: s.file,
679                    name: s.name,
680                    kind: s.kind,
681                    start_line: s.start_line,
682                    end_line: s.end_line,
683                    is_exported: s.is_exported,
684                },
685            );
686        }
687        for e in self.edges() {
688            // `edges()` already normalizes PG kinds to the graph_index
689            // vocabulary, so `e.kind` is ready to store verbatim.
690            idx.edges.push(graph_index::IndexEdge {
691                from: e.from,
692                to: e.to,
693                kind: e.kind,
694                weight: e.weight as f32,
695            });
696        }
697        idx.rebuild_interner();
698        idx
699    }
700
701    pub fn index_dir(project_root: &str) -> Option<std::path::PathBuf> {
702        graph_index::ProjectIndex::index_dir(project_root)
703    }
704}
705
706/// Open whichever graph is already populated on disk, **without** triggering any
707/// build, plus a flag telling the caller the property graph still wants a
708/// (re)build. Prefers a fully-populated PropertyGraph and falls back to the
709/// in-memory graph_index extractor while the PG is not yet populated (first run
710/// or just after a rebuild), flagging `needs_build` so the caller can warm the
711/// PG for the next call (#696 phase D: the `legacy` backend escape hatch was
712/// retired once PG-only persistence proved lossless in #682.3).
713fn open_existing(project_root: &str) -> (Option<OpenGraphProvider>, bool) {
714    let t0 = std::time::Instant::now();
715
716    let mut pg_provider = None;
717    let mut pg_populated = false;
718    // A graph built by an older engine may hold edges the current engine no
719    // longer derives (e.g. guessed `calls`, engine 6): never serve it — fall
720    // back to the index extractor and let the flagged rebuild replace it.
721    let pg_current = !crate::core::property_graph::engine_outdated(project_root);
722    if pg_current && let Ok(pg) = CodeGraph::open(project_root) {
723        let nodes = pg.node_count().unwrap_or(0);
724        let edges = pg.edge_count().unwrap_or(0);
725        let file_cat = pg.file_catalog_count().unwrap_or(0);
726        pg_populated = nodes > 0 && edges > 0 && file_cat > 0;
727        if pg_populated {
728            log_source_selection(GraphProviderSource::PropertyGraph, nodes, edges, t0);
729            return (
730                Some(OpenGraphProvider {
731                    source: GraphProviderSource::PropertyGraph,
732                    provider: GraphProvider::PropertyGraph(pg),
733                }),
734                false,
735            );
736        }
737        if nodes > 0 && file_cat > 0 {
738            pg_provider = Some(pg);
739        }
740    }
741
742    // PG is not fully populated: a (re)build would help the next call.
743    let needs_build = !pg_populated;
744
745    if let Some(idx) = super::index_orchestrator::try_load_graph_index(project_root) {
746        let files = idx.files.len();
747        let edges = idx.edges.len();
748        if !idx.edges.is_empty() || !idx.files.is_empty() {
749            log_source_selection(GraphProviderSource::GraphIndex, files, edges, t0);
750            return (
751                Some(OpenGraphProvider {
752                    source: GraphProviderSource::GraphIndex,
753                    provider: GraphProvider::GraphIndex(idx),
754                }),
755                needs_build,
756            );
757        }
758    }
759
760    if let Some(pg) = pg_provider {
761        let nodes = pg.node_count().unwrap_or(0);
762        log_source_selection(GraphProviderSource::PropertyGraph, nodes, 0, t0);
763        return (
764            Some(OpenGraphProvider {
765                source: GraphProviderSource::PropertyGraph,
766                provider: GraphProvider::PropertyGraph(pg),
767            }),
768            needs_build,
769        );
770    }
771
772    (None, needs_build)
773}
774
775/// Open an already-built graph, kicking off a one-shot background build when the
776/// property graph is not fully populated so the *next* call is fast. Returns
777/// `None` on this call when nothing is ready yet. Best-effort callers
778/// (dashboards, context gate, stats, `ctx_graph`) use this; callers that need a
779/// graph *right now* use [`open_or_build`], which builds synchronously instead.
780pub fn open_best_effort(project_root: &str) -> Option<OpenGraphProvider> {
781    let (existing, needs_build) = open_existing(project_root);
782    if needs_build {
783        trigger_lazy_graph_build(project_root);
784    }
785    existing
786}
787
788/// Hot-path open for optional enrichment (post-read hints, silent preload):
789/// only a fully populated property graph, opened from SQLite. Never parses the
790/// JSON index and never builds synchronously — when the graph is not ready it
791/// schedules the background build and returns `None`, so the caller simply
792/// skips its optional work. A read must not wait seconds (or a 30 s build) for
793/// a hint it may not even show.
794pub fn open_ready(project_root: &str) -> Option<OpenGraphProvider> {
795    let ready = if crate::core::property_graph::engine_outdated(project_root) {
796        None
797    } else {
798        // Nodes and catalog, not edges: a built graph of a project without
799        // imports (a single file) legitimately has none.
800        CodeGraph::open(project_root).ok().filter(|pg| {
801            pg.node_count().unwrap_or(0) > 0 && pg.file_catalog_count().unwrap_or(0) > 0
802        })
803    };
804    if let Some(pg) = ready {
805        Some(OpenGraphProvider {
806            source: GraphProviderSource::PropertyGraph,
807            provider: GraphProvider::PropertyGraph(pg),
808        })
809    } else {
810        trigger_lazy_graph_build(project_root);
811        None
812    }
813}
814
815fn log_source_selection(
816    source: GraphProviderSource,
817    nodes: usize,
818    edges: usize,
819    start: std::time::Instant,
820) {
821    let elapsed_ms = start.elapsed().as_millis();
822    if std::env::var("LCTX_DEBUG").is_ok() {
823        eprintln!(
824            "[graph_provider] source={source:?} nodes={nodes} edges={edges} resolve_ms={elapsed_ms}"
825        );
826    }
827    let _ = (source, nodes, edges, elapsed_ms);
828}
829
830/// Triggers a background graph build once per process when the graph is empty.
831fn trigger_lazy_graph_build(project_root: &str) {
832    // Unit tests rewrite the process-global `LEAN_CTX_DATA_DIR` per test (each uses
833    // its own tempdir). A detached, fire-and-forget build thread reads that global
834    // mid-flight and runs concurrently with test bodies that are otherwise
835    // serialized on `test_env_lock` — a source of graph-state concurrency, and the
836    // root of an intermittent macOS-only flake where a freshly-built index appeared
837    // empty to the asserting test. `open_or_build` has a synchronous fallback that
838    // fully covers tests, so skip the background build under `cfg!(test)`. Production
839    // (and integration tests, which run the lib normally) are unaffected.
840    if cfg!(test) {
841        return;
842    }
843    // Preserve the cheap post-trigger fast path, but do not claim the latch
844    // until the root has been validated as an actual project.
845    if GRAPH_BUILD_TRIGGERED.load(Ordering::SeqCst) {
846        return;
847    }
848    let root = Path::new(project_root);
849    // Both probes are TCC-guarded (#356): a non-existent/non-dir path has no
850    // markers, and a launchd-standalone process never stats under ~/Documents.
851    let is_project = crate::core::pathutil::has_project_marker(root)
852        || crate::core::pathutil::has_multi_repo_children(root);
853    if !is_project {
854        return;
855    }
856    if GRAPH_BUILD_TRIGGERED.swap(true, Ordering::SeqCst) {
857        return;
858    }
859    // #682.2: build via the same reliable worker that builds the JSON index
860    // (which now mirrors into PG, backend-gated), instead of a dedicated
861    // fire-and-forget thread that silently died in short-lived processes.
862    super::index_orchestrator::ensure_all_background(project_root);
863}
864
865/// Build the property graph from the proven graph_index extractor (#682.1).
866///
867/// Loads the current [`ProjectIndex`] (or scans if absent) and mirrors it into
868/// the SQLite store — files + `file_catalog`, symbols, and structural edges —
869/// then stamps `graph.meta.json`. Sourcing PG from the mature extractor
870/// guarantees PG ⊇ graph_index (so a later backend flip cannot lose data) and
871/// populates the `file_catalog` that the `pg_populated` gate requires.
872///
873/// Synchronous and self-contained in `core` (no `tools` dependency), so callers
874/// can build reliably without the fire-and-forget caveat of the lazy trigger.
875pub fn build_property_graph(project_root: &str) -> anyhow::Result<()> {
876    let index = super::index_orchestrator::try_load_graph_index(project_root)
877        .filter(|i| !i.files.is_empty())
878        .unwrap_or_else(|| graph_index::scan_with_content_cache(project_root).0);
879    super::property_graph::mirror_index(project_root, &index)
880}
881
882/// Maximum time `open_or_build` will wait for a synchronous graph build before
883/// falling back to "graph unavailable" (#1018). Prevents blocking-pool
884/// exhaustion on large repos with cold indices, especially on Windows with AV.
885const BUILD_TIMEOUT_SECS: u64 = 30;
886
887/// Serialization gate: only one synchronous graph build at a time. Concurrent
888/// callers that cannot acquire the gate return immediately with `None` rather
889/// than queueing (which would exhaust the blocking pool). The background indexer
890/// picks up the build for them asynchronously.
891static BUILD_GATE: std::sync::LazyLock<std::sync::Mutex<()>> =
892    std::sync::LazyLock::new(|| std::sync::Mutex::new(()));
893
894pub fn open_or_build(project_root: &str) -> Option<OpenGraphProvider> {
895    if let (Some(p), _) = open_existing(project_root) {
896        return Some(p);
897    }
898
899    // Tests require the documented byte-stable result even when the parallel
900    // suite is building another fixture. Production remains non-blocking.
901    let _gate = if cfg!(test) {
902        BUILD_GATE
903            .lock()
904            .unwrap_or_else(std::sync::PoisonError::into_inner)
905    } else {
906        let Ok(gate) = BUILD_GATE.try_lock() else {
907            tracing::info!(
908                "open_or_build: another build in progress for {project_root}; returning None"
909            );
910            trigger_lazy_graph_build(project_root);
911            return None;
912        };
913        gate
914    };
915
916    // Run the potentially-slow `load_or_build` under a timeout thread so we
917    // don't block the calling tool handler for unbounded time (#1018).
918    let root_owned = project_root.to_string();
919    let (tx, rx) = std::sync::mpsc::sync_channel(1);
920    std::thread::spawn(move || {
921        let idx = super::graph_index::load_or_build(&root_owned);
922        let _ = tx.send(idx);
923    });
924
925    let timeout = std::time::Duration::from_secs(BUILD_TIMEOUT_SECS);
926    match rx.recv_timeout(timeout) {
927        Ok(idx) if !idx.files.is_empty() => Some(OpenGraphProvider {
928            source: GraphProviderSource::GraphIndex,
929            provider: GraphProvider::GraphIndex(idx),
930        }),
931        Ok(_) => None,
932        Err(_) => {
933            tracing::warn!(
934                "open_or_build: graph build timed out after {BUILD_TIMEOUT_SECS}s for {project_root}; \
935                 triggering background build"
936            );
937            trigger_lazy_graph_build(project_root);
938            None
939        }
940    }
941}
942#[cfg(test)]
943mod tests {
944    use super::*;
945
946    #[test]
947    fn best_effort_prefers_graph_index_when_property_graph_empty() {
948        let _lock = crate::core::data_dir::test_env_lock();
949        let tmp = tempfile::tempdir().expect("tempdir");
950        let data = tmp.path().join("data");
951        std::fs::create_dir_all(&data).expect("mkdir data");
952        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
953
954        let project_root = tmp.path().join("proj");
955        std::fs::create_dir_all(&project_root).expect("mkdir proj");
956        let root = project_root.to_string_lossy().to_string();
957        // The index serves a file only while it exists in the project.
958        std::fs::create_dir_all(project_root.join("src")).expect("mkdir src");
959        std::fs::write(project_root.join("src/main.rs"), "fn main() {}\n").expect("source");
960
961        let mut idx = ProjectIndex::new(&root);
962        idx.files.insert(
963            "src/main.rs".to_string(),
964            super::super::graph_index::FileEntry {
965                path: "src/main.rs".to_string(),
966                hash: "h".to_string(),
967                language: "rs".to_string(),
968                line_count: 1,
969                token_count: 1,
970                exports: vec![],
971                summary: String::new(),
972            },
973        );
974        idx.save().expect("save index");
975
976        let open = open_best_effort(&root).expect("open");
977        assert_eq!(open.source, GraphProviderSource::GraphIndex);
978
979        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
980    }
981
982    #[test]
983    fn best_effort_none_when_no_graphs() {
984        let _lock = crate::core::data_dir::test_env_lock();
985        let tmp = tempfile::tempdir().expect("tempdir");
986        let data = tmp.path().join("data");
987        std::fs::create_dir_all(&data).expect("mkdir data");
988        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.to_string_lossy().to_string());
989
990        let project_root = tmp.path().join("proj");
991        std::fs::create_dir_all(&project_root).expect("mkdir proj");
992        let root = project_root.to_string_lossy().to_string();
993
994        let open = open_best_effort(&root);
995        assert!(open.is_none());
996
997        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
998    }
999
1000    fn handle_provider() -> (GraphProvider, tempfile::TempDir) {
1001        let mut idx = ProjectIndex::new("/test");
1002        for (key, file, name, kind, start, end) in [
1003            (
1004                "src/lib.rs::Config",
1005                "src/lib.rs",
1006                "Config",
1007                "struct",
1008                5usize,
1009                20usize,
1010            ),
1011            (
1012                "src/lib.rs::Config::load",
1013                "src/lib.rs",
1014                "Config::load",
1015                "method",
1016                22,
1017                35,
1018            ),
1019            ("src/main.rs::main", "src/main.rs", "main", "fn", 1, 10),
1020        ] {
1021            idx.symbols.insert(
1022                key.to_string(),
1023                graph_index::SymbolEntry {
1024                    file: file.to_string(),
1025                    name: name.to_string(),
1026                    kind: kind.to_string(),
1027                    start_line: start,
1028                    end_line: end,
1029                    is_exported: true,
1030                },
1031            );
1032        }
1033        let (idx, files) = idx.on_disk(&[]);
1034        (GraphProvider::GraphIndex(idx), files)
1035    }
1036
1037    #[test]
1038    fn handle_resolves_exact_file_and_name() {
1039        let (gp, _files) = handle_provider();
1040        let h = crate::core::handle::SymbolHandle::new("src/lib.rs", "Config::load", 22);
1041        let sym = gp.find_symbol_by_handle(&h).expect("resolves");
1042        assert_eq!(sym.name, "Config::load");
1043        assert_eq!(sym.start_line, 22);
1044    }
1045
1046    #[test]
1047    fn handle_resolves_after_line_drift() {
1048        // Same (path, name) but a stale, drifted line — must still resolve to
1049        // the current symbol at its real line (robust beyond line-only refs).
1050        let (gp, _files) = handle_provider();
1051        let h = crate::core::handle::SymbolHandle::new("src/lib.rs", "Config::load", 999);
1052        let sym = gp
1053            .find_symbol_by_handle(&h)
1054            .expect("resolves despite drift");
1055        assert_eq!(sym.start_line, 22);
1056    }
1057
1058    #[test]
1059    fn handle_resolves_by_unqualified_tail() {
1060        // Handle carries only the unqualified tail `load`; the exact key misses,
1061        // so the same-file tail match + line tiebreak recovers `Config::load`.
1062        let (gp, _files) = handle_provider();
1063        let h = crate::core::handle::SymbolHandle::new("src/lib.rs", "load", 22);
1064        let sym = gp.find_symbol_by_handle(&h).expect("resolves by tail");
1065        assert_eq!(sym.name, "Config::load");
1066    }
1067
1068    #[test]
1069    fn handle_unknown_file_returns_none() {
1070        let (gp, _files) = handle_provider();
1071        let h = crate::core::handle::SymbolHandle::new("src/nope.rs", "Config::load", 22);
1072        assert!(gp.find_symbol_by_handle(&h).is_none());
1073    }
1074
1075    #[test]
1076    fn pg_kind_filter_matches_precise_metadata_kind() {
1077        // #889: a PG symbol's coarse `nodes.kind` is always `"symbol"`; the
1078        // source kind (`struct`) lives in metadata. Filtering by kind="struct"
1079        // must still find it (previously matched the coarse column → empty).
1080        use super::super::property_graph::{Node, NodeKind};
1081
1082        let pg = CodeGraph::open_in_memory().unwrap();
1083        pg.upsert_node(
1084            &Node::symbol("State", "simplex/simplex.go", NodeKind::Symbol)
1085                .with_lines(129, 140)
1086                .with_metadata(r#"{"kind":"struct","exported":true}"#),
1087        )
1088        .unwrap();
1089        let gp = GraphProvider::PropertyGraph(pg);
1090
1091        let hits = gp.find_symbols("State", None, Some("struct"));
1092        assert_eq!(
1093            hits.len(),
1094            1,
1095            "kind=struct must resolve the exported struct"
1096        );
1097        assert_eq!(hits[0].kind, "struct");
1098        assert!(hits[0].is_exported);
1099
1100        // A non-matching kind still filters it out.
1101        assert!(gp.find_symbols("State", None, Some("fn")).is_empty());
1102    }
1103
1104    #[test]
1105    fn parity_dependencies_both_stores_agree() {
1106        use super::super::graph_index::{FileEntry, IndexEdge};
1107        use super::super::property_graph::{Edge, EdgeKind, Node};
1108
1109        let pg = CodeGraph::open_in_memory().unwrap();
1110        let a_id = pg.upsert_node(&Node::file("src/a.rs")).unwrap();
1111        let b_id = pg.upsert_node(&Node::file("src/b.rs")).unwrap();
1112        let c_id = pg.upsert_node(&Node::file("src/c.rs")).unwrap();
1113        pg.upsert_edge(&Edge::new(a_id, b_id, EdgeKind::Imports))
1114            .unwrap();
1115        pg.upsert_edge(&Edge::new(a_id, c_id, EdgeKind::Imports))
1116            .unwrap();
1117
1118        let mut idx = ProjectIndex::new("/test");
1119        for name in &["src/a.rs", "src/b.rs", "src/c.rs"] {
1120            idx.files.insert(
1121                name.to_string(),
1122                FileEntry {
1123                    path: name.to_string(),
1124                    hash: "h".into(),
1125                    language: "rs".into(),
1126                    line_count: 1,
1127                    token_count: 1,
1128                    exports: vec![],
1129                    summary: String::new(),
1130                },
1131            );
1132        }
1133        idx.edges.push(IndexEdge {
1134            from: "src/a.rs".into(),
1135            to: "src/b.rs".into(),
1136            kind: "import".into(),
1137            weight: 1.0,
1138        });
1139        idx.edges.push(IndexEdge {
1140            from: "src/a.rs".into(),
1141            to: "src/c.rs".into(),
1142            kind: "import".into(),
1143            weight: 1.0,
1144        });
1145
1146        let pg_deps = GraphProvider::PropertyGraph(pg);
1147        let (idx, _files) = idx.on_disk(&[]);
1148        let gi_deps = GraphProvider::GraphIndex(idx);
1149
1150        let mut pg_result = pg_deps.dependencies("src/a.rs");
1151        let mut gi_result = gi_deps.dependencies("src/a.rs");
1152        pg_result.sort();
1153        gi_result.sort();
1154
1155        assert_eq!(
1156            pg_result, gi_result,
1157            "Import edges must match between PG and GraphIndex"
1158        );
1159
1160        let mut pg_dependents = pg_deps.dependents("src/b.rs");
1161        let mut gi_dependents = gi_deps.dependents("src/b.rs");
1162        pg_dependents.sort();
1163        gi_dependents.sort();
1164        assert_eq!(
1165            pg_dependents, gi_dependents,
1166            "Dependents must match between PG and GraphIndex"
1167        );
1168    }
1169
1170    /// Round-trip guard for #696 C1: graph_index → PG (mirror) → graph_index
1171    /// (materialize) must preserve files (full catalog), symbols (incl. the
1172    /// precise `kind` + `is_exported`, which live in node metadata) and import
1173    /// edges. Proves the PropertyGraph can be the sole source for the remaining
1174    /// legacy `ProjectIndex` consumers — the prerequisite for retiring the JSON
1175    /// store.
1176    #[test]
1177    fn materialize_project_index_round_trips_losslessly() {
1178        use super::super::graph_index::{FileEntry, IndexEdge, SymbolEntry};
1179        use super::super::property_graph::populate_from_project_index;
1180
1181        let mut a = ProjectIndex::new("/test");
1182        a.files.insert(
1183            "src/a.rs".to_string(),
1184            FileEntry {
1185                path: "src/a.rs".to_string(),
1186                hash: "hash-a".to_string(),
1187                language: "rs".to_string(),
1188                line_count: 42,
1189                token_count: 137,
1190                exports: vec!["Foo".to_string()],
1191                summary: "module a".to_string(),
1192            },
1193        );
1194        a.files.insert(
1195            "src/b.rs".to_string(),
1196            FileEntry {
1197                path: "src/b.rs".to_string(),
1198                hash: "hash-b".to_string(),
1199                language: "rs".to_string(),
1200                line_count: 7,
1201                token_count: 19,
1202                exports: vec![],
1203                summary: String::new(),
1204            },
1205        );
1206        // Two symbols with DIFFERENT kinds and export flags — the fields most at
1207        // risk of being flattened by the coarse property-graph `NodeKind`.
1208        a.symbols.insert(
1209            "src/a.rs::Foo".to_string(),
1210            SymbolEntry {
1211                file: "src/a.rs".to_string(),
1212                name: "Foo".to_string(),
1213                kind: "struct".to_string(),
1214                start_line: 1,
1215                end_line: 9,
1216                is_exported: true,
1217            },
1218        );
1219        a.symbols.insert(
1220            "src/b.rs::helper".to_string(),
1221            SymbolEntry {
1222                file: "src/b.rs".to_string(),
1223                name: "helper".to_string(),
1224                kind: "function".to_string(),
1225                start_line: 3,
1226                end_line: 6,
1227                is_exported: false,
1228            },
1229        );
1230        a.edges.push(IndexEdge {
1231            from: "src/b.rs".to_string(),
1232            to: "src/a.rs".to_string(),
1233            kind: "import".to_string(),
1234            weight: 1.0,
1235        });
1236
1237        let pg = CodeGraph::open_in_memory().unwrap();
1238        populate_from_project_index(&pg, &a).unwrap();
1239        let provider = GraphProvider::PropertyGraph(pg);
1240        let b = provider.materialize_project_index("/test");
1241
1242        // Files: inventory + every catalog field survive.
1243        let mut a_files: Vec<&String> = a.files.keys().collect();
1244        let mut b_files: Vec<&String> = b.files.keys().collect();
1245        a_files.sort();
1246        b_files.sort();
1247        assert_eq!(a_files, b_files, "file inventory must round-trip");
1248        for (path, fa) in &a.files {
1249            let fb = b.files.get(path).expect("file present after round trip");
1250            assert_eq!(fa.hash, fb.hash, "hash {path}");
1251            assert_eq!(fa.language, fb.language, "language {path}");
1252            assert_eq!(fa.line_count, fb.line_count, "line_count {path}");
1253            assert_eq!(fa.token_count, fb.token_count, "token_count {path}");
1254            assert_eq!(fa.exports, fb.exports, "exports {path}");
1255            assert_eq!(fa.summary, fb.summary, "summary {path}");
1256        }
1257
1258        // Symbols: keys + spans + the metadata-carried kind/export flag survive.
1259        let mut a_syms: Vec<&String> = a.symbols.keys().collect();
1260        let mut b_syms: Vec<&String> = b.symbols.keys().collect();
1261        a_syms.sort();
1262        b_syms.sort();
1263        assert_eq!(a_syms, b_syms, "symbol table must round-trip");
1264        for (key, sa) in &a.symbols {
1265            let sb = b.symbols.get(key).expect("symbol present after round trip");
1266            assert_eq!(sa.name, sb.name, "name {key}");
1267            assert_eq!(sa.file, sb.file, "file {key}");
1268            assert_eq!(sa.kind, sb.kind, "kind {key}");
1269            assert_eq!(sa.start_line, sb.start_line, "start_line {key}");
1270            assert_eq!(sa.end_line, sb.end_line, "end_line {key}");
1271            assert_eq!(sa.is_exported, sb.is_exported, "is_exported {key}");
1272        }
1273
1274        // Structural edges: the import edge survives (PG may enrich, never lose).
1275        assert!(
1276            b.edges
1277                .iter()
1278                .any(|e| e.from == "src/b.rs" && e.to == "src/a.rs" && e.kind == "import"),
1279            "import edge must round-trip; got {:?}",
1280            b.edges
1281        );
1282    }
1283}