Skip to main content

meta_ast/graph/
builder.rs

1//! Graph builder for incremental construction from extraction results.
2//!
3//! Construction proceeds in stages:
4//! 1. Ownership graph: files and their symbols
5//! 2. Dataflow nodes and edges (requires `--features dataflow`)
6//! 3. Dependency graph: imports and cross-file references
7//!
8//! The builder maintains index mappings from domain IDs (FileId, SymbolId)
9//! to petgraph `NodeIndex` for efficient lookups.
10
11use std::collections::HashMap;
12use std::path::PathBuf;
13
14use petgraph::graph::{DiGraph, EdgeIndex, NodeIndex};
15use petgraph::visit::EdgeRef;
16
17use crate::graph::CodeGraph;
18use crate::graph::edge::{EdgeData, EdgeKind};
19#[cfg(feature = "dataflow")]
20use crate::graph::node::DataGraphNode;
21use crate::graph::node::{ExternalNode, FileNode, NodeData, SymbolNode};
22use crate::language::LangId;
23#[cfg(feature = "dataflow")]
24use crate::model::DataNodeId;
25use crate::model::{FileId, IdGenerator, SnapshotId, Symbol, SymbolId};
26
27/// Builder for incremental graph construction from extraction results.
28#[derive(Debug)]
29pub struct GraphBuilder {
30    /// Underlying graph being constructed
31    graph: DiGraph<NodeData, EdgeData>,
32
33    /// Map from FileId to graph node index
34    file_to_index: HashMap<FileId, NodeIndex>,
35
36    /// Map from SymbolId to graph node index
37    symbol_to_index: HashMap<SymbolId, NodeIndex>,
38
39    /// Map from file path to FileId (for resolving import targets)
40    path_to_file: HashMap<PathBuf, FileId>,
41
42    /// ID generator for FileIds
43    file_id_gen: IdGenerator<FileId>,
44
45    /// Snapshot ID for this analysis
46    snapshot_id: SnapshotId,
47
48    /// O(1) dedup index mirroring `CodeGraph::edge_index`.
49    edge_index: HashMap<(NodeIndex, NodeIndex, EdgeKind), EdgeIndex>,
50
51    /// Map from external raw path to graph node index
52    external_index: HashMap<String, NodeIndex>,
53
54    /// Map from DataNodeId to graph node index
55    #[cfg(feature = "dataflow")]
56    data_to_index: HashMap<DataNodeId, NodeIndex>,
57}
58
59impl GraphBuilder {
60    /// Creates a new GraphBuilder for the given snapshot.
61    pub fn new(snapshot_id: SnapshotId) -> Self {
62        Self {
63            graph: DiGraph::new(),
64            file_to_index: HashMap::new(),
65            symbol_to_index: HashMap::new(),
66            path_to_file: HashMap::new(),
67            file_id_gen: IdGenerator::new(),
68            snapshot_id,
69            edge_index: HashMap::new(),
70            external_index: HashMap::new(),
71            #[cfg(feature = "dataflow")]
72            data_to_index: HashMap::new(),
73        }
74    }
75
76    /// Adds a file node to the graph.
77    ///
78    /// If the file already exists (determined by path), returns the existing FileId.
79    pub fn add_file(&mut self, path: PathBuf, language: LangId) -> FileId {
80        // Check if file already exists
81        if let Some(&existing_id) = self.path_to_file.get(&path) {
82            return existing_id;
83        }
84
85        let id = self.file_id_gen.next();
86
87        let node = FileNode {
88            id,
89            path: path.clone(),
90            language,
91            snapshot_id: self.snapshot_id,
92        };
93
94        let idx = self.graph.add_node(NodeData::File(node));
95
96        self.file_to_index.insert(id, idx);
97        self.path_to_file.insert(path, id);
98
99        id
100    }
101
102    /// Adds a symbol node and its ownership edge to the containing file.
103    ///
104    /// The containing file must already exist in the builder. The symbol's
105    /// file path is resolved against registered files.
106    ///
107    /// Returns the NodeIndex for the symbol node.
108    pub fn add_symbol(&mut self, symbol: &Symbol) -> Result<NodeIndex, crate::Error> {
109        // Check if symbol already exists
110        if let Some(&existing) = self.symbol_to_index.get(&symbol.id) {
111            return Ok(existing);
112        }
113
114        // Look up the file by path
115        let file_id = *self
116            .path_to_file
117            .get(&symbol.file_path)
118            .ok_or_else(|| crate::Error::Graph("file must be added before its symbols".into()))?;
119
120        let file_idx = *self
121            .file_to_index
122            .get(&file_id)
123            .ok_or_else(|| crate::Error::Graph("file index must exist".into()))?;
124
125        let node = SymbolNode {
126            id: symbol.id,
127            name: symbol.name.clone(),
128            kind: symbol.kind,
129            file_id,
130            visibility: symbol.visibility,
131            source_range: symbol.source_range.clone(),
132        };
133
134        let sym_idx = self.graph.add_node(NodeData::Symbol(node));
135        self.symbol_to_index.insert(symbol.id, sym_idx);
136
137        // Add ownership edge: file -> symbol (always full confidence)
138        self.add_edge_internal(file_idx, sym_idx, EdgeKind::Ownership, 1.0);
139
140        Ok(sym_idx)
141    }
142
143    /// Adds a data-bearing node to the graph.
144    ///
145    /// Returns the NodeIndex for the data node. Idempotent: returns
146    /// the existing index if a data node with the same DataNodeId exists.
147    ///
148    /// When the data node has a `symbol_id` that resolves to an existing
149    /// SymbolNode in the graph, an Ownership edge is created from the
150    /// symbol to the data node.
151    #[cfg(feature = "dataflow")]
152    pub fn add_data_node(&mut self, data_node: &crate::model::DataNode) -> NodeIndex {
153        if let Some(&existing) = self.data_to_index.get(&data_node.id) {
154            return existing;
155        }
156        let node = DataGraphNode {
157            id: data_node.id,
158            symbol_id: data_node.symbol_id,
159            name: data_node.name.clone(),
160            scope: data_node.scope,
161            type_hint: data_node.type_hint.clone(),
162            source_range: data_node.source_range.clone(),
163        };
164        let idx = self.graph.add_node(NodeData::Data(node));
165        self.data_to_index.insert(data_node.id, idx);
166
167        if let Some(symbol_id) = data_node.symbol_id
168            && let Some(&sym_idx) = self.symbol_to_index.get(&symbol_id)
169        {
170            self.add_edge_internal(sym_idx, idx, EdgeKind::Ownership, 1.0);
171        }
172
173        idx
174    }
175
176    /// Adds a flow edge between two data nodes.
177    #[cfg(feature = "dataflow")]
178    pub fn add_flow_edge(
179        &mut self,
180        source: crate::model::DataNodeId,
181        target: crate::model::DataNodeId,
182        kind: crate::model::FlowKind,
183        confidence: f32,
184    ) {
185        let Some(&src_idx) = self.data_to_index.get(&source) else {
186            return;
187        };
188        let Some(&tgt_idx) = self.data_to_index.get(&target) else {
189            return;
190        };
191        self.add_edge_internal_with_flow(src_idx, tgt_idx, EdgeKind::Flow, confidence, Some(kind));
192    }
193
194    /// Adds an import edge from one file to another.
195    ///
196    /// If the target file exists in the project, creates an import edge.
197    /// If the target is external (not in the project), creates an ExternalNode
198    /// placeholder and an import edge to it.
199    pub fn add_import(&mut self, from: FileId, to: PathBuf) {
200        let Some(&from_idx) = self.file_to_index.get(&from) else {
201            return; // Source not in graph
202        };
203
204        // Resolve target path to file ID if it exists in our graph
205        if let Some(&to_id) = self.path_to_file.get(&to) {
206            if let Some(&to_idx) = self.file_to_index.get(&to_id) {
207                self.add_edge_internal(from_idx, to_idx, EdgeKind::Import, 1.0);
208            }
209            return;
210        }
211
212        // External dependency: create or reuse external node
213        let raw_path = to.to_string_lossy().to_string();
214        let to_idx = if let Some(&idx) = self.external_index.get(&raw_path) {
215            idx
216        } else {
217            // Determine language from source file
218            let language = self
219                .path_to_file
220                .iter()
221                .find(|(_, id)| **id == from)
222                .map(|(p, _)| {
223                    crate::input::detect_language(p).unwrap_or(crate::language::LangId::Python)
224                })
225                .unwrap_or(crate::language::LangId::Python);
226
227            let node = ExternalNode {
228                raw_path: raw_path.clone(),
229                language,
230                classification: None,
231            };
232            let idx = self.graph.add_node(NodeData::External(node));
233            self.external_index.insert(raw_path, idx);
234            idx
235        };
236
237        self.add_edge_internal(from_idx, to_idx, EdgeKind::Import, 1.0);
238    }
239
240    /// Internal edge addition with flow kind, respecting normalization.
241    #[cfg(feature = "dataflow")]
242    fn add_edge_internal_with_flow(
243        &mut self,
244        source: NodeIndex,
245        target: NodeIndex,
246        kind: EdgeKind,
247        confidence: f32,
248        flow_kind: Option<crate::model::FlowKind>,
249    ) {
250        let confidence = confidence.clamp(0.0, 1.0);
251        let key = (source, target, kind);
252        if let Some(&edge_idx) = self.edge_index.get(&key) {
253            let edge = &mut self.graph[edge_idx];
254            edge.confidence = edge.confidence.max(confidence);
255            if edge.flow_kind.is_none() {
256                edge.flow_kind = flow_kind;
257            }
258            return;
259        }
260        let edge_idx = self.graph.add_edge(
261            source,
262            target,
263            EdgeData {
264                kind,
265                confidence,
266                flow_kind,
267            },
268        );
269        self.edge_index.insert(key, edge_idx);
270    }
271
272    /// Adds a reference edge between two symbols with a confidence score.
273    ///
274    /// Both symbols must exist in the builder. If a duplicate edge
275    /// already exists, the confidence is merged via max (the stronger
276    /// signal is preserved).
277    pub fn add_reference(&mut self, from: SymbolId, to: SymbolId, confidence: f32) {
278        let Some(&from_idx) = self.symbol_to_index.get(&from) else {
279            return;
280        };
281        let Some(&to_idx) = self.symbol_to_index.get(&to) else {
282            return;
283        };
284
285        self.add_edge_internal(from_idx, to_idx, EdgeKind::Reference, confidence);
286    }
287
288    /// Internal method to add an edge with deduplication and confidence.
289    ///
290    /// If a duplicate `(source, target, kind)` already exists, the existing
291    /// edge's confidence is bumped to `max(existing, new)` - never reduced.
292    fn add_edge_internal(
293        &mut self,
294        source: NodeIndex,
295        target: NodeIndex,
296        kind: EdgeKind,
297        confidence: f32,
298    ) {
299        let confidence = confidence.clamp(0.0, 1.0);
300        let key = (source, target, kind);
301        if let Some(&edge_idx) = self.edge_index.get(&key) {
302            let existing = &mut self.graph[edge_idx];
303            existing.confidence = existing.confidence.max(confidence);
304            return;
305        }
306
307        let edge_data = EdgeData::with_confidence(kind, confidence);
308
309        let edge_idx = self.graph.add_edge(source, target, edge_data);
310        self.edge_index.insert(key, edge_idx);
311    }
312
313    /// Returns the FileId for a given file path, if registered.
314    pub fn file_id_for_path(&self, path: &std::path::PathBuf) -> Option<FileId> {
315        self.path_to_file.get(path).copied()
316    }
317
318    /// Builds and returns an adjacency map from FileId to the FileIds it imports.
319    ///
320    /// Walks all Import edges in the graph to produce the relationship.
321    pub fn import_adjacency(&self) -> HashMap<FileId, Vec<FileId>> {
322        let mut adjacency: HashMap<FileId, Vec<FileId>> = HashMap::new();
323        let mut index_to_file: HashMap<NodeIndex, FileId> = HashMap::new();
324        for (&file_id, &idx) in &self.file_to_index {
325            index_to_file.insert(idx, file_id);
326        }
327        for edge in self.graph.edge_references() {
328            if edge.weight().kind == EdgeKind::Import
329                && let (Some(&from_id), Some(&to_id)) = (
330                    index_to_file.get(&edge.source()),
331                    index_to_file.get(&edge.target()),
332                )
333            {
334                adjacency.entry(from_id).or_default().push(to_id);
335            }
336        }
337        adjacency
338    }
339
340    /// Finalizes the graph and returns the constructed CodeGraph.
341    pub fn build(self) -> CodeGraph {
342        CodeGraph::from_parts(
343            self.graph,
344            self.edge_index,
345            self.file_to_index,
346            self.symbol_to_index,
347            self.external_index,
348            self.snapshot_id,
349        )
350    }
351
352    /// Returns the number of nodes in the graph so far.
353    pub fn node_count(&self) -> usize {
354        self.graph.node_count()
355    }
356
357    /// Returns the number of edges in the graph so far.
358    pub fn edge_count(&self) -> usize {
359        self.graph.edge_count()
360    }
361
362    /// Returns the number of external dependency nodes.
363    pub fn external_count(&self) -> usize {
364        self.external_index.len()
365    }
366
367    /// Assemble a complete CodeGraph + SCC from parallel extraction results.
368    ///
369    /// Replaces the manual multi-step wiring in pipeline.rs. Handles:
370    /// - File and symbol node registration
371    /// - Import edge resolution (via language-specific resolvers)
372    /// - Cross-file reference resolution via FlattenedScopeCache
373    /// - SCC analysis
374    ///
375    /// Errors during symbol addition are non-fatal and appended to `diagnostics`.
376    pub fn from_extractions<F>(
377        extractions: &[F],
378        root: &std::path::Path,
379        snapshot_id: crate::model::SnapshotId,
380        diagnostics: &mut Vec<crate::error::Diagnostic>,
381    ) -> (CodeGraph, crate::graph::SccAnalysis)
382    where
383        F: std::borrow::Borrow<crate::model::FileExtraction> + Sync,
384    {
385        let (graph, scc, _) =
386            Self::from_extractions_with_scope(extractions, root, snapshot_id, diagnostics);
387        (graph, scc)
388    }
389
390    /// Build the graph, SCC, and the flattened scope cache.
391    pub fn from_extractions_with_scope<F>(
392        extractions: &[F],
393        root: &std::path::Path,
394        snapshot_id: crate::model::SnapshotId,
395        diagnostics: &mut Vec<crate::error::Diagnostic>,
396    ) -> (
397        CodeGraph,
398        crate::graph::SccAnalysis,
399        crate::graph::resolver::FlattenedScopeCache,
400    )
401    where
402        F: std::borrow::Borrow<crate::model::FileExtraction> + Sync,
403    {
404        let mut builder = Self::new(snapshot_id);
405
406        // Register all files
407        for file in extractions {
408            let file = file.borrow();
409            builder.add_file(file.path.clone(), file.lang);
410        }
411
412        // Register all symbols; symbol errors are non-fatal
413        for file in extractions {
414            let file = file.borrow();
415            for symbol in &file.symbols {
416                if let Err(e) = builder.add_symbol(symbol) {
417                    diagnostics.push(crate::error::Diagnostic {
418                        path: file.path.clone(),
419                        severity: crate::error::Severity::Warning,
420                        message: format!("failed to add symbol to graph: {e}"),
421                        source_range: None,
422                    });
423                }
424            }
425        }
426
427        // Register data nodes and flow edges from dataflow extraction
428        #[cfg(feature = "dataflow")]
429        {
430            for file in extractions {
431                let file = file.borrow();
432                for data_node in &file.data_nodes {
433                    builder.add_data_node(data_node);
434                }
435            }
436            for file in extractions {
437                let file = file.borrow();
438                for flow_edge in &file.flow_edges {
439                    builder.add_flow_edge(
440                        flow_edge.source,
441                        flow_edge.target,
442                        flow_edge.kind,
443                        flow_edge.confidence,
444                    );
445                }
446            }
447        }
448
449        // Build path -> FileId map needed by the resolver
450        let path_to_file_id: HashMap<std::path::PathBuf, crate::model::FileId> = extractions
451            .iter()
452            .filter_map(|f| {
453                let f = f.borrow();
454                builder
455                    .file_id_for_path(&f.path)
456                    .map(|fid| (f.path.clone(), fid))
457            })
458            .collect();
459
460        // Resolve language-specific import paths and add import edges
461        let mut resolvers = HashMap::new();
462        for lang in crate::language::LangId::all() {
463            resolvers.insert(lang, crate::language::import_resolver::make_resolver(lang));
464        }
465
466        for file in extractions {
467            let file = file.borrow();
468            let Some(&source_fid) = path_to_file_id.get(&file.path) else {
469                continue;
470            };
471            let source_dir = file.path.parent().unwrap_or(std::path::Path::new("."));
472            if let Some(resolver) = resolvers.get(&file.lang) {
473                for import in &file.imports {
474                    if let Some(target) =
475                        resolver.resolve(&import.import_specifier, source_dir, root)
476                    {
477                        builder.add_import(source_fid, target);
478                    }
479                }
480            }
481        }
482
483        // Cross-file reference resolution via FlattenedScopeCache
484        let import_adjacency = builder.import_adjacency();
485        let ctx = crate::graph::resolver::ResolutionContext::from_extractions(
486            extractions,
487            &path_to_file_id,
488            import_adjacency,
489        );
490        let scope_cache = crate::graph::resolver::FlattenedScopeCache::build(&ctx, diagnostics);
491        let ref_edges = crate::graph::resolver::resolve_all_references(
492            extractions,
493            &path_to_file_id,
494            &scope_cache,
495            diagnostics,
496        );
497        for (from, to, confidence) in ref_edges {
498            builder.add_reference(from, to, confidence);
499        }
500
501        // Finalize and compute SCC
502        let graph = builder.build();
503        #[cfg(feature = "metacall-deploy")]
504        let mut graph = graph;
505
506        // Client-call edges are resolved after the graph exists, because
507        // resolution needs file and symbol nodes. They are ordinary Reference
508        // edges, so navigation and SCC see them.
509        #[cfg(feature = "metacall-deploy")]
510        {
511            let call_sites: Vec<crate::deploy::scanner::CallSite> = extractions
512                .iter()
513                .flat_map(|file| file.borrow().call_sites.iter().cloned())
514                .collect();
515            if !call_sites.is_empty() {
516                let (call_edges, call_diagnostics) =
517                    crate::deploy::client_call::resolve_client_call_edges(
518                        &graph,
519                        extractions,
520                        &call_sites,
521                        root,
522                    );
523                diagnostics.extend(call_diagnostics);
524                for (from, to, confidence) in call_edges {
525                    if let (Some(from_idx), Some(to_idx)) =
526                        (graph.symbol_node_index(from), graph.symbol_node_index(to))
527                    {
528                        graph.add_edge_normalized(
529                            from_idx,
530                            to_idx,
531                            EdgeKind::Reference,
532                            confidence,
533                        );
534                    }
535                }
536            }
537        }
538
539        let scc = crate::graph::SccAnalysis::analyze(graph.graph());
540
541        (graph, scc, scope_cache)
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use crate::model::{LineColumn, SourceRange, SymbolKind, Visibility};
549
550    fn test_source_range() -> SourceRange {
551        SourceRange {
552            byte_start: 0,
553            byte_end: 10,
554            start: LineColumn { line: 0, column: 0 },
555            end: LineColumn {
556                line: 0,
557                column: 10,
558            },
559        }
560    }
561
562    fn test_symbol(id: u32, name: &str, _file_id: u32) -> Symbol {
563        Symbol {
564            id: SymbolId::new(id).unwrap(),
565            name: name.to_string(),
566            kind: SymbolKind::Function,
567            language: LangId::Python,
568            file_path: PathBuf::from("test.py"),
569            source_range: test_source_range(),
570            visibility: Some(Visibility::Public),
571            signature: None,
572            docstring: None,
573            is_async: false,
574        }
575    }
576
577    #[test]
578    fn builder_creates_file_nodes() {
579        let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
580        let path = PathBuf::from("src/main.py");
581
582        let id1 = builder.add_file(path.clone(), LangId::Python);
583        let id2 = builder.add_file(path, LangId::Python);
584
585        assert_eq!(id1, id2, "same path should return same FileId");
586        assert_eq!(builder.node_count(), 1);
587    }
588
589    #[test]
590    fn builder_creates_symbol_with_ownership() {
591        let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
592        let path = PathBuf::from("test.py");
593
594        let file_id = builder.add_file(path.clone(), LangId::Python);
595        let symbol = test_symbol(1, "hello", file_id.to_raw());
596
597        let _sym_idx = builder.add_symbol(&symbol).unwrap();
598
599        assert_eq!(builder.node_count(), 2); // file + symbol
600        assert_eq!(builder.edge_count(), 1); // ownership edge
601    }
602
603    #[test]
604    fn builder_deduplicates_edges() {
605        let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
606        let path1 = PathBuf::from("a.py");
607        let path2 = PathBuf::from("b.py");
608
609        let file1 = builder.add_file(path1, LangId::Python);
610        let _file2 = builder.add_file(path2, LangId::Python);
611
612        // Add same import twice
613        builder.add_import(file1, PathBuf::from("b.py"));
614        builder.add_import(file1, PathBuf::from("b.py"));
615
616        assert_eq!(
617            builder.edge_count(),
618            1,
619            "duplicate edges should be deduplicated"
620        );
621    }
622
623    #[test]
624    fn builder_reference_max_merges_confidence() {
625        let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
626        builder.add_file(PathBuf::from("test.py"), LangId::Python);
627        let low = test_symbol(10, "low_fn", 0);
628        let high = test_symbol(11, "high_fn", 0);
629        builder.add_symbol(&low).unwrap();
630        builder.add_symbol(&high).unwrap();
631
632        builder.add_reference(low.id, high.id, 0.5);
633        builder.add_reference(low.id, high.id, 0.9);
634        assert_eq!(builder.edge_count(), 3);
635
636        let graph = builder.build();
637        assert_eq!(graph.edge_count(), 3);
638        let refs: Vec<_> = graph.edges_of_kind(EdgeKind::Reference).collect();
639        assert_eq!(refs.len(), 1);
640        let (src, dst) = refs[0];
641        let edge = graph.graph().edges_connecting(src, dst).next().unwrap();
642        assert_eq!(edge.weight().confidence, 0.9);
643    }
644
645    #[test]
646    fn builder_creates_external_node_for_unknown_import() {
647        let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
648        let path = PathBuf::from("main.py");
649
650        let file_id = builder.add_file(path, LangId::Python);
651
652        // Import of external module not in our project
653        builder.add_import(file_id, PathBuf::from("external_module.py"));
654
655        // Should create an external node and import edge
656        assert_eq!(
657            builder.edge_count(),
658            1,
659            "external import should create an edge"
660        );
661        assert_eq!(builder.external_count(), 1, "should have one external node");
662    }
663
664    #[test]
665    fn builder_tracks_node_mappings() {
666        let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
667        let path = PathBuf::from("test.py");
668
669        let file_id = builder.add_file(path, LangId::Python);
670        let symbol = test_symbol(42, "func", file_id.to_raw());
671
672        builder.add_symbol(&symbol).unwrap();
673
674        // Verify mappings exist
675        assert!(builder.file_to_index.contains_key(&file_id));
676        assert!(
677            builder
678                .symbol_to_index
679                .contains_key(&SymbolId::new(42).unwrap())
680        );
681    }
682
683    #[test]
684    fn from_extractions_builds_graph_with_correct_node_count() {
685        use crate::model::{FileExtraction, LineColumn, SourceRange, Symbol, SymbolId, SymbolKind};
686        use std::path::PathBuf;
687        let sym = Symbol {
688            id: SymbolId::new(1).unwrap(),
689            name: "foo".into(),
690            kind: SymbolKind::Function,
691            language: LangId::Python,
692            file_path: PathBuf::from("/proj/a.py"),
693            source_range: SourceRange {
694                byte_start: 0,
695                byte_end: 10,
696                start: LineColumn { line: 0, column: 0 },
697                end: LineColumn {
698                    line: 0,
699                    column: 10,
700                },
701            },
702            visibility: None,
703            signature: None,
704            docstring: None,
705            is_async: false,
706        };
707        let mut base = FileExtraction::empty(PathBuf::from("/proj/a.py"), LangId::Python);
708        base.symbols = vec![sym];
709        let extractions = vec![base];
710        let mut diags = Vec::new();
711        let (graph, _scc) = GraphBuilder::from_extractions(
712            &extractions,
713            std::path::Path::new("/proj"),
714            SnapshotId::new(1).unwrap(),
715            &mut diags,
716        );
717        assert_eq!(graph.file_count(), 1);
718        assert_eq!(graph.symbol_count(), 1);
719        assert!(diags.is_empty());
720    }
721
722    #[test]
723    fn from_extractions_populates_scc_analysis() {
724        use crate::model::FileExtraction;
725        let extractions: Vec<FileExtraction> = vec![];
726        let mut diags = Vec::new();
727        let (graph, scc) = GraphBuilder::from_extractions(
728            &extractions,
729            std::path::Path::new("/proj"),
730            SnapshotId::new(1).unwrap(),
731            &mut diags,
732        );
733        assert_eq!(graph.node_count(), 0);
734        assert!(!scc.components.iter().any(|c| c.is_cyclic));
735    }
736
737    #[test]
738    fn from_extractions_accumulates_diagnostics_on_symbol_error() {
739        use crate::model::{FileExtraction, LineColumn, SourceRange, Symbol, SymbolId, SymbolKind};
740        use std::path::PathBuf;
741        let sym = Symbol {
742            id: SymbolId::new(99).unwrap(),
743            name: "orphan".into(),
744            kind: SymbolKind::Function,
745            language: LangId::Python,
746            file_path: PathBuf::from("/proj/missing.py"),
747            source_range: SourceRange {
748                byte_start: 0,
749                byte_end: 5,
750                start: LineColumn { line: 0, column: 0 },
751                end: LineColumn { line: 0, column: 5 },
752            },
753            visibility: None,
754            signature: None,
755            docstring: None,
756            is_async: false,
757        };
758        let mut base = FileExtraction::empty(PathBuf::from("/proj/a.py"), LangId::Python);
759        base.symbols = vec![sym];
760        let extractions = vec![base];
761        let mut diags = Vec::new();
762        let (_graph, _scc) = GraphBuilder::from_extractions(
763            &extractions,
764            std::path::Path::new("/proj"),
765            SnapshotId::new(1).unwrap(),
766            &mut diags,
767        );
768        assert!(!diags.is_empty(), "expected diagnostic for orphan symbol");
769    }
770
771    #[test]
772    fn from_extractions_resolves_cross_file_imports() {
773        use crate::model::{FileExtraction, LineColumn, SourceRange, UnresolvedImport};
774        use std::path::PathBuf;
775        let mut first = FileExtraction::empty(PathBuf::from("/proj/a.py"), LangId::Python);
776        first.imports = vec![UnresolvedImport {
777            import_specifier: "b".into(),
778            alias: None,
779            symbol: None,
780            star: false,
781            range: SourceRange {
782                byte_start: 0,
783                byte_end: 1,
784                start: LineColumn { line: 0, column: 0 },
785                end: LineColumn { line: 0, column: 1 },
786            },
787        }];
788        let second = FileExtraction::empty(PathBuf::from("/proj/b.py"), LangId::Python);
789        let extractions = vec![first, second];
790        let mut diags = Vec::new();
791        let (graph, _scc) = GraphBuilder::from_extractions(
792            &extractions,
793            std::path::Path::new("/proj"),
794            SnapshotId::new(1).unwrap(),
795            &mut diags,
796        );
797        assert_eq!(graph.file_count(), 2);
798        let import_edges: Vec<_> = graph
799            .edges_of_kind(crate::graph::EdgeKind::Import)
800            .collect();
801        assert_eq!(
802            import_edges.len(),
803            1,
804            "expected import edge from a.py to b.py"
805        );
806    }
807
808    #[cfg(feature = "dataflow")]
809    #[test]
810    fn add_data_node_creates_ownership_edge_to_symbol() {
811        use crate::model::{DataNode, DataNodeId, DataScope};
812        let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
813        let file_path = PathBuf::from("test.rs");
814        let _file_id = builder.add_file(file_path.clone(), LangId::Rust);
815        let sym = Symbol {
816            id: SymbolId::new(1).unwrap(),
817            name: "my_fn".into(),
818            kind: SymbolKind::Function,
819            language: LangId::Rust,
820            file_path,
821            source_range: test_source_range(),
822            visibility: Some(Visibility::Public),
823            signature: None,
824            docstring: None,
825            is_async: false,
826        };
827        let sym_idx = builder.add_symbol(&sym).unwrap();
828
829        let data = DataNode {
830            id: DataNodeId::new(1).unwrap(),
831            symbol_id: Some(SymbolId::new(1).unwrap()),
832            name: Some("x".into()),
833            scope: DataScope::Local,
834            type_hint: Some("i32".into()),
835            source_range: test_source_range(),
836        };
837        builder.add_data_node(&data);
838
839        let graph = builder.build();
840        let ownership_edges: Vec<_> = graph.edges_of_kind(EdgeKind::Ownership).collect();
841        assert_eq!(
842            ownership_edges.len(),
843            2,
844            "expected file→symbol and symbol→data ownership edges"
845        );
846
847        let data_ownership_edges: Vec<_> = ownership_edges
848            .into_iter()
849            .filter(|(src, _)| *src == sym_idx)
850            .collect();
851        assert!(
852            !data_ownership_edges.is_empty(),
853            "expected symbol→data ownership edge"
854        );
855    }
856}