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, 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    /// Counter for edge deduplication
49    edge_normalizer: EdgeNormalizer,
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
59/// Tracks seen edges to prevent duplicates.
60#[derive(Debug, Default)]
61struct EdgeNormalizer {
62    seen: std::collections::HashSet<(NodeIndex, NodeIndex, EdgeKind)>,
63}
64
65impl EdgeNormalizer {
66    fn is_new(&mut self, source: NodeIndex, target: NodeIndex, kind: EdgeKind) -> bool {
67        self.seen.insert((source, target, kind))
68    }
69}
70
71impl GraphBuilder {
72    /// Creates a new GraphBuilder for the given snapshot.
73    pub fn new(snapshot_id: SnapshotId) -> Self {
74        Self {
75            graph: DiGraph::new(),
76            file_to_index: HashMap::new(),
77            symbol_to_index: HashMap::new(),
78            path_to_file: HashMap::new(),
79            file_id_gen: IdGenerator::new(),
80            snapshot_id,
81            edge_normalizer: EdgeNormalizer::default(),
82            external_index: HashMap::new(),
83            #[cfg(feature = "dataflow")]
84            data_to_index: HashMap::new(),
85        }
86    }
87
88    /// Adds a file node to the graph.
89    ///
90    /// If the file already exists (determined by path), returns the existing FileId.
91    pub fn add_file(&mut self, path: PathBuf, language: LangId) -> FileId {
92        // Check if file already exists
93        if let Some(&existing_id) = self.path_to_file.get(&path) {
94            return existing_id;
95        }
96
97        let id = self.file_id_gen.next();
98
99        let node = FileNode {
100            id,
101            path: path.clone(),
102            language,
103            snapshot_id: self.snapshot_id,
104        };
105
106        let idx = self.graph.add_node(NodeData::File(node));
107
108        self.file_to_index.insert(id, idx);
109        self.path_to_file.insert(path, id);
110
111        id
112    }
113
114    /// Adds a symbol node and its ownership edge to the containing file.
115    ///
116    /// The containing file must already exist in the builder. The symbol's
117    /// file path is resolved against registered files.
118    ///
119    /// Returns the NodeIndex for the symbol node.
120    pub fn add_symbol(&mut self, symbol: &Symbol) -> Result<NodeIndex, crate::Error> {
121        // Check if symbol already exists
122        if let Some(&existing) = self.symbol_to_index.get(&symbol.id) {
123            return Ok(existing);
124        }
125
126        // Look up the file by path
127        let file_id = *self
128            .path_to_file
129            .get(&symbol.file_path)
130            .ok_or_else(|| crate::Error::Graph("file must be added before its symbols".into()))?;
131
132        let file_idx = *self
133            .file_to_index
134            .get(&file_id)
135            .ok_or_else(|| crate::Error::Graph("file index must exist".into()))?;
136
137        let node = SymbolNode {
138            id: symbol.id,
139            name: symbol.name.clone(),
140            kind: symbol.kind,
141            file_id,
142            visibility: symbol.visibility,
143            source_range: symbol.source_range.clone(),
144        };
145
146        let sym_idx = self.graph.add_node(NodeData::Symbol(node));
147        self.symbol_to_index.insert(symbol.id, sym_idx);
148
149        // Add ownership edge: file -> symbol (always full confidence)
150        self.add_edge_internal(file_idx, sym_idx, EdgeKind::Ownership, 1.0);
151
152        Ok(sym_idx)
153    }
154
155    /// Adds a data-bearing node to the graph.
156    ///
157    /// Returns the NodeIndex for the data node. Idempotent: returns
158    /// the existing index if a data node with the same DataNodeId exists.
159    ///
160    /// When the data node has a `symbol_id` that resolves to an existing
161    /// SymbolNode in the graph, an Ownership edge is created from the
162    /// symbol to the data node.
163    #[cfg(feature = "dataflow")]
164    pub fn add_data_node(&mut self, data_node: &crate::model::DataNode) -> NodeIndex {
165        if let Some(&existing) = self.data_to_index.get(&data_node.id) {
166            return existing;
167        }
168        let node = DataGraphNode {
169            id: data_node.id,
170            symbol_id: data_node.symbol_id,
171            name: data_node.name.clone(),
172            scope: data_node.scope,
173            type_hint: data_node.type_hint.clone(),
174            source_range: data_node.source_range.clone(),
175        };
176        let idx = self.graph.add_node(NodeData::Data(node));
177        self.data_to_index.insert(data_node.id, idx);
178
179        if let Some(symbol_id) = data_node.symbol_id
180            && let Some(&sym_idx) = self.symbol_to_index.get(&symbol_id)
181        {
182            self.add_edge_internal(sym_idx, idx, EdgeKind::Ownership, 1.0);
183        }
184
185        idx
186    }
187
188    /// Adds a flow edge between two data nodes.
189    #[cfg(feature = "dataflow")]
190    pub fn add_flow_edge(
191        &mut self,
192        source: crate::model::DataNodeId,
193        target: crate::model::DataNodeId,
194        kind: crate::model::FlowKind,
195        confidence: f32,
196    ) {
197        let Some(&src_idx) = self.data_to_index.get(&source) else {
198            return;
199        };
200        let Some(&tgt_idx) = self.data_to_index.get(&target) else {
201            return;
202        };
203        self.add_edge_internal_with_flow(src_idx, tgt_idx, EdgeKind::Flow, confidence, Some(kind));
204    }
205
206    /// Adds an import edge from one file to another.
207    ///
208    /// If the target file exists in the project, creates an import edge.
209    /// If the target is external (not in the project), creates an ExternalNode
210    /// placeholder and an import edge to it.
211    pub fn add_import(&mut self, from: FileId, to: PathBuf) {
212        let Some(&from_idx) = self.file_to_index.get(&from) else {
213            return; // Source not in graph
214        };
215
216        // Resolve target path to file ID if it exists in our graph
217        if let Some(&to_id) = self.path_to_file.get(&to) {
218            if let Some(&to_idx) = self.file_to_index.get(&to_id) {
219                self.add_edge_internal(from_idx, to_idx, EdgeKind::Import, 1.0);
220            }
221            return;
222        }
223
224        // External dependency: create or reuse external node
225        let raw_path = to.to_string_lossy().to_string();
226        let to_idx = if let Some(&idx) = self.external_index.get(&raw_path) {
227            idx
228        } else {
229            // Determine language from source file
230            let language = self
231                .path_to_file
232                .iter()
233                .find(|(_, id)| **id == from)
234                .map(|(p, _)| {
235                    crate::input::detect_language(p).unwrap_or(crate::language::LangId::Python)
236                })
237                .unwrap_or(crate::language::LangId::Python);
238
239            let node = ExternalNode {
240                raw_path: raw_path.clone(),
241                language,
242                classification: None,
243            };
244            let idx = self.graph.add_node(NodeData::External(node));
245            self.external_index.insert(raw_path, idx);
246            idx
247        };
248
249        self.add_edge_internal(from_idx, to_idx, EdgeKind::Import, 1.0);
250    }
251
252    /// Adds a MetaCall load edge between files.
253    pub fn add_metacall_load(
254        &mut self,
255        from_path: PathBuf,
256        target_lang: LangId,
257        scripts: &[String],
258        confidence: f64,
259        root: &std::path::Path,
260    ) {
261        let Some(&from_fid) = self.path_to_file.get(&from_path) else {
262            return;
263        };
264        let Some(&from_idx) = self.file_to_index.get(&from_fid) else {
265            return;
266        };
267
268        for script in scripts {
269            let target_path = root.join(script);
270            // Resolve to FileId if it exists
271            if let Some(&to_fid) = self.path_to_file.get(&target_path) {
272                if let Some(&to_idx) = self.file_to_index.get(&to_fid) {
273                    let edge_data = EdgeData::with_confidence(EdgeKind::Import, confidence as f32);
274                    self.graph.add_edge(from_idx, to_idx, edge_data);
275                }
276            } else {
277                // External or not discovered
278                let raw_path = script.clone();
279                if let Some(&to_idx) = self.external_index.get(&raw_path) {
280                    let edge_data = EdgeData::with_confidence(EdgeKind::Import, confidence as f32);
281                    self.graph.add_edge(from_idx, to_idx, edge_data);
282                } else {
283                    let node = ExternalNode {
284                        raw_path: raw_path.clone(),
285                        language: target_lang,
286                        classification: None,
287                    };
288                    let idx = self.graph.add_node(NodeData::External(node));
289                    self.external_index.insert(raw_path, idx);
290
291                    let edge_data = EdgeData::with_confidence(EdgeKind::Import, confidence as f32);
292                    self.graph.add_edge(from_idx, idx, edge_data);
293                }
294            }
295        }
296    }
297
298    /// Internal edge addition with flow kind, respecting normalization.
299    #[cfg(feature = "dataflow")]
300    fn add_edge_internal_with_flow(
301        &mut self,
302        source: NodeIndex,
303        target: NodeIndex,
304        kind: EdgeKind,
305        confidence: f32,
306        flow_kind: Option<crate::model::FlowKind>,
307    ) {
308        if !self.edge_normalizer.is_new(source, target, kind) {
309            if let Some(edge_idx) = self.graph.find_edge(source, target) {
310                let edge = &mut self.graph[edge_idx];
311                edge.confidence = edge.confidence.max(confidence);
312                if edge.flow_kind.is_none() {
313                    edge.flow_kind = flow_kind;
314                }
315            }
316            return;
317        }
318        let edge_data = EdgeData {
319            kind,
320            confidence,
321            flow_kind,
322        };
323        self.graph.add_edge(source, target, edge_data);
324    }
325
326    /// Adds a reference edge between two symbols with a confidence score.
327    ///
328    /// Both symbols must exist in the builder. If a duplicate edge
329    /// already exists, the confidence is merged via max (the stronger
330    /// signal is preserved).
331    pub fn add_reference(&mut self, from: SymbolId, to: SymbolId, confidence: f32) {
332        let Some(&from_idx) = self.symbol_to_index.get(&from) else {
333            return;
334        };
335        let Some(&to_idx) = self.symbol_to_index.get(&to) else {
336            return;
337        };
338
339        self.add_edge_internal(from_idx, to_idx, EdgeKind::Reference, confidence);
340    }
341
342    /// Internal method to add an edge with deduplication and confidence.
343    ///
344    /// If a duplicate `(source, target, kind)` already exists, the existing
345    /// edge's confidence is bumped to `max(existing, new)` - never reduced.
346    fn add_edge_internal(
347        &mut self,
348        source: NodeIndex,
349        target: NodeIndex,
350        kind: EdgeKind,
351        confidence: f32,
352    ) {
353        if !self.edge_normalizer.is_new(source, target, kind) {
354            // Duplicate edge: max-merge confidence on the existing edge.
355            if let Some(edge_idx) = self.graph.find_edge(source, target) {
356                let existing = &mut self.graph[edge_idx];
357                if existing.kind == kind {
358                    existing.confidence = existing.confidence.max(confidence);
359                }
360            }
361            return;
362        }
363
364        let edge_data = EdgeData::with_confidence(kind, confidence);
365
366        self.graph.add_edge(source, target, edge_data);
367    }
368
369    /// Returns the FileId for a given file path, if registered.
370    pub fn file_id_for_path(&self, path: &std::path::PathBuf) -> Option<FileId> {
371        self.path_to_file.get(path).copied()
372    }
373
374    /// Builds and returns an adjacency map from FileId to the FileIds it imports.
375    ///
376    /// Walks all Import edges in the graph to produce the relationship.
377    pub fn import_adjacency(&self) -> HashMap<FileId, Vec<FileId>> {
378        let mut adjacency: HashMap<FileId, Vec<FileId>> = HashMap::new();
379        let mut index_to_file: HashMap<NodeIndex, FileId> = HashMap::new();
380        for (&file_id, &idx) in &self.file_to_index {
381            index_to_file.insert(idx, file_id);
382        }
383        for edge in self.graph.edge_references() {
384            if edge.weight().kind == EdgeKind::Import
385                && let (Some(&from_id), Some(&to_id)) = (
386                    index_to_file.get(&edge.source()),
387                    index_to_file.get(&edge.target()),
388                )
389            {
390                adjacency.entry(from_id).or_default().push(to_id);
391            }
392        }
393        adjacency
394    }
395
396    /// Finalizes the graph and returns the constructed CodeGraph.
397    pub fn build(self) -> CodeGraph {
398        CodeGraph {
399            graph: self.graph,
400            file_to_index: self.file_to_index,
401            symbol_to_index: self.symbol_to_index,
402            external_index: self.external_index,
403            snapshot_id: self.snapshot_id,
404        }
405    }
406
407    /// Returns the number of nodes in the graph so far.
408    pub fn node_count(&self) -> usize {
409        self.graph.node_count()
410    }
411
412    /// Returns the number of edges in the graph so far.
413    pub fn edge_count(&self) -> usize {
414        self.graph.edge_count()
415    }
416
417    /// Returns the number of external dependency nodes.
418    pub fn external_count(&self) -> usize {
419        self.external_index.len()
420    }
421
422    /// Assemble a complete CodeGraph + SCC from parallel extraction results.
423    ///
424    /// Replaces the manual multi-step wiring in pipeline.rs. Handles:
425    /// - File and symbol node registration
426    /// - Import edge resolution (via language-specific resolvers)
427    /// - Cross-file reference resolution via FlattenedScopeCache
428    /// - SCC analysis
429    ///
430    /// Errors during symbol addition are non-fatal and appended to `diagnostics`.
431    pub fn from_extractions<F>(
432        extractions: &[F],
433        root: &std::path::Path,
434        snapshot_id: crate::model::SnapshotId,
435        diagnostics: &mut Vec<crate::error::Diagnostic>,
436    ) -> (CodeGraph, crate::graph::SccAnalysis)
437    where
438        F: std::borrow::Borrow<crate::model::FileExtraction> + Sync,
439    {
440        let mut builder = Self::new(snapshot_id);
441
442        // Register all files
443        for file in extractions {
444            let file = file.borrow();
445            builder.add_file(file.path.clone(), file.lang);
446        }
447
448        // Register all symbols; symbol errors are non-fatal
449        for file in extractions {
450            let file = file.borrow();
451            for symbol in &file.symbols {
452                if let Err(e) = builder.add_symbol(symbol) {
453                    diagnostics.push(crate::error::Diagnostic {
454                        path: file.path.clone(),
455                        severity: crate::error::Severity::Warning,
456                        message: format!("failed to add symbol to graph: {e}"),
457                        source_range: None,
458                    });
459                }
460            }
461        }
462
463        // Register data nodes and flow edges from dataflow extraction
464        #[cfg(feature = "dataflow")]
465        {
466            for file in extractions {
467                let file = file.borrow();
468                for data_node in &file.data_nodes {
469                    builder.add_data_node(data_node);
470                }
471            }
472            for file in extractions {
473                let file = file.borrow();
474                for flow_edge in &file.flow_edges {
475                    builder.add_flow_edge(
476                        flow_edge.source,
477                        flow_edge.target,
478                        flow_edge.kind,
479                        flow_edge.confidence,
480                    );
481                }
482            }
483        }
484
485        // Build path -> FileId map needed by the resolver
486        let path_to_file_id: HashMap<std::path::PathBuf, crate::model::FileId> = extractions
487            .iter()
488            .filter_map(|f| {
489                let f = f.borrow();
490                builder
491                    .file_id_for_path(&f.path)
492                    .map(|fid| (f.path.clone(), fid))
493            })
494            .collect();
495
496        // Resolve language-specific import paths and add import edges
497        let mut resolvers = HashMap::new();
498        for lang in crate::language::LangId::all() {
499            resolvers.insert(lang, crate::language::import_resolver::make_resolver(lang));
500        }
501
502        for file in extractions {
503            let file = file.borrow();
504            let Some(&source_fid) = path_to_file_id.get(&file.path) else {
505                continue;
506            };
507            let source_dir = file.path.parent().unwrap_or(std::path::Path::new("."));
508            if let Some(resolver) = resolvers.get(&file.lang) {
509                for import in &file.imports {
510                    if let Some(target) =
511                        resolver.resolve(&import.import_specifier, source_dir, root)
512                    {
513                        builder.add_import(source_fid, target);
514                    }
515                }
516            }
517        }
518
519        // Cross-file reference resolution via FlattenedScopeCache
520        let import_adjacency = builder.import_adjacency();
521        let ctx = crate::graph::resolver::ResolutionContext::from_extractions(
522            extractions,
523            &path_to_file_id,
524            import_adjacency,
525        );
526        let scope_cache = crate::graph::resolver::FlattenedScopeCache::build(&ctx, diagnostics);
527        let ref_edges = crate::graph::resolver::resolve_all_references(
528            extractions,
529            &path_to_file_id,
530            &scope_cache,
531            diagnostics,
532        );
533        for (from, to, confidence) in ref_edges {
534            builder.add_reference(from, to, confidence);
535        }
536
537        // Finalize and compute SCC
538        let graph = builder.build();
539        let scc = crate::graph::SccAnalysis::analyze(&graph.graph);
540
541        (graph, scc)
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_creates_external_node_for_unknown_import() {
625        let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
626        let path = PathBuf::from("main.py");
627
628        let file_id = builder.add_file(path, LangId::Python);
629
630        // Import of external module not in our project
631        builder.add_import(file_id, PathBuf::from("external_module.py"));
632
633        // Should create an external node and import edge
634        assert_eq!(
635            builder.edge_count(),
636            1,
637            "external import should create an edge"
638        );
639        assert_eq!(builder.external_count(), 1, "should have one external node");
640    }
641
642    #[test]
643    fn builder_tracks_node_mappings() {
644        let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
645        let path = PathBuf::from("test.py");
646
647        let file_id = builder.add_file(path, LangId::Python);
648        let symbol = test_symbol(42, "func", file_id.to_raw());
649
650        builder.add_symbol(&symbol).unwrap();
651
652        // Verify mappings exist
653        assert!(builder.file_to_index.contains_key(&file_id));
654        assert!(
655            builder
656                .symbol_to_index
657                .contains_key(&SymbolId::new(42).unwrap())
658        );
659    }
660
661    #[test]
662    fn from_extractions_builds_graph_with_correct_node_count() {
663        use crate::model::{FileExtraction, LineColumn, SourceRange, Symbol, SymbolId, SymbolKind};
664        use std::path::PathBuf;
665        let sym = Symbol {
666            id: SymbolId::new(1).unwrap(),
667            name: "foo".into(),
668            kind: SymbolKind::Function,
669            language: LangId::Python,
670            file_path: PathBuf::from("/proj/a.py"),
671            source_range: SourceRange {
672                byte_start: 0,
673                byte_end: 10,
674                start: LineColumn { line: 0, column: 0 },
675                end: LineColumn {
676                    line: 0,
677                    column: 10,
678                },
679            },
680            visibility: None,
681            signature: None,
682            docstring: None,
683            is_async: false,
684        };
685        let extractions = vec![FileExtraction {
686            path: PathBuf::from("/proj/a.py"),
687            lang: LangId::Python,
688            symbols: vec![sym],
689            imports: vec![],
690            references: vec![],
691            diagnostics: vec![],
692            ast_node_count: 0,
693            #[cfg(feature = "metacall-deploy")]
694            call_sites: vec![],
695            #[cfg(feature = "dataflow")]
696            data_nodes: vec![],
697            #[cfg(feature = "dataflow")]
698            flow_edges: vec![],
699        }];
700        let mut diags = Vec::new();
701        let (graph, _scc) = GraphBuilder::from_extractions(
702            &extractions,
703            std::path::Path::new("/proj"),
704            SnapshotId::new(1).unwrap(),
705            &mut diags,
706        );
707        assert_eq!(graph.file_count(), 1);
708        assert_eq!(graph.symbol_count(), 1);
709        assert!(diags.is_empty());
710    }
711
712    #[test]
713    fn from_extractions_populates_scc_analysis() {
714        use crate::model::FileExtraction;
715        let extractions: Vec<FileExtraction> = vec![];
716        let mut diags = Vec::new();
717        let (graph, scc) = GraphBuilder::from_extractions(
718            &extractions,
719            std::path::Path::new("/proj"),
720            SnapshotId::new(1).unwrap(),
721            &mut diags,
722        );
723        assert_eq!(graph.node_count(), 0);
724        assert!(!scc.components.iter().any(|c| c.is_cyclic));
725    }
726
727    #[test]
728    fn from_extractions_accumulates_diagnostics_on_symbol_error() {
729        use crate::model::{FileExtraction, LineColumn, SourceRange, Symbol, SymbolId, SymbolKind};
730        use std::path::PathBuf;
731        let sym = Symbol {
732            id: SymbolId::new(99).unwrap(),
733            name: "orphan".into(),
734            kind: SymbolKind::Function,
735            language: LangId::Python,
736            file_path: PathBuf::from("/proj/missing.py"),
737            source_range: SourceRange {
738                byte_start: 0,
739                byte_end: 5,
740                start: LineColumn { line: 0, column: 0 },
741                end: LineColumn { line: 0, column: 5 },
742            },
743            visibility: None,
744            signature: None,
745            docstring: None,
746            is_async: false,
747        };
748        let extractions = vec![FileExtraction {
749            path: PathBuf::from("/proj/a.py"),
750            lang: LangId::Python,
751            symbols: vec![sym],
752            imports: vec![],
753            references: vec![],
754            diagnostics: vec![],
755            ast_node_count: 0,
756            #[cfg(feature = "metacall-deploy")]
757            call_sites: vec![],
758            #[cfg(feature = "dataflow")]
759            data_nodes: vec![],
760            #[cfg(feature = "dataflow")]
761            flow_edges: vec![],
762        }];
763        let mut diags = Vec::new();
764        let (_graph, _scc) = GraphBuilder::from_extractions(
765            &extractions,
766            std::path::Path::new("/proj"),
767            SnapshotId::new(1).unwrap(),
768            &mut diags,
769        );
770        assert!(!diags.is_empty(), "expected diagnostic for orphan symbol");
771    }
772
773    #[test]
774    fn from_extractions_resolves_cross_file_imports() {
775        use crate::model::{FileExtraction, LineColumn, SourceRange, UnresolvedImport};
776        use std::path::PathBuf;
777        let extractions = vec![
778            FileExtraction {
779                path: PathBuf::from("/proj/a.py"),
780                lang: LangId::Python,
781                symbols: vec![],
782                imports: vec![UnresolvedImport {
783                    import_specifier: "b".into(),
784                    alias: None,
785                    symbol: None,
786                    star: false,
787                    range: SourceRange {
788                        byte_start: 0,
789                        byte_end: 1,
790                        start: LineColumn { line: 0, column: 0 },
791                        end: LineColumn { line: 0, column: 1 },
792                    },
793                }],
794                references: vec![],
795                diagnostics: vec![],
796                ast_node_count: 0,
797                #[cfg(feature = "metacall-deploy")]
798                call_sites: vec![],
799                #[cfg(feature = "dataflow")]
800                data_nodes: vec![],
801                #[cfg(feature = "dataflow")]
802                flow_edges: vec![],
803            },
804            FileExtraction {
805                path: PathBuf::from("/proj/b.py"),
806                lang: LangId::Python,
807                symbols: vec![],
808                imports: vec![],
809                references: vec![],
810                diagnostics: vec![],
811                ast_node_count: 0,
812                #[cfg(feature = "metacall-deploy")]
813                call_sites: vec![],
814                #[cfg(feature = "dataflow")]
815                data_nodes: vec![],
816                #[cfg(feature = "dataflow")]
817                flow_edges: vec![],
818            },
819        ];
820        let mut diags = Vec::new();
821        let (graph, _scc) = GraphBuilder::from_extractions(
822            &extractions,
823            std::path::Path::new("/proj"),
824            SnapshotId::new(1).unwrap(),
825            &mut diags,
826        );
827        assert_eq!(graph.file_count(), 2);
828        let import_edges: Vec<_> = graph
829            .edges_of_kind(crate::graph::EdgeKind::Import)
830            .collect();
831        assert_eq!(
832            import_edges.len(),
833            1,
834            "expected import edge from a.py to b.py"
835        );
836    }
837
838    #[cfg(feature = "dataflow")]
839    #[test]
840    fn add_data_node_creates_ownership_edge_to_symbol() {
841        use crate::model::{DataNode, DataNodeId, DataScope};
842        let mut builder = GraphBuilder::new(SnapshotId::new(1).unwrap());
843        let file_path = PathBuf::from("test.rs");
844        let _file_id = builder.add_file(file_path.clone(), LangId::Rust);
845        let sym = Symbol {
846            id: SymbolId::new(1).unwrap(),
847            name: "my_fn".into(),
848            kind: SymbolKind::Function,
849            language: LangId::Rust,
850            file_path,
851            source_range: test_source_range(),
852            visibility: Some(Visibility::Public),
853            signature: None,
854            docstring: None,
855            is_async: false,
856        };
857        let sym_idx = builder.add_symbol(&sym).unwrap();
858
859        let data = DataNode {
860            id: DataNodeId::new(1).unwrap(),
861            symbol_id: Some(SymbolId::new(1).unwrap()),
862            name: Some("x".into()),
863            scope: DataScope::Local,
864            type_hint: Some("i32".into()),
865            source_range: test_source_range(),
866        };
867        builder.add_data_node(&data);
868
869        let graph = builder.build();
870        let ownership_edges: Vec<_> = graph.edges_of_kind(EdgeKind::Ownership).collect();
871        assert_eq!(
872            ownership_edges.len(),
873            2,
874            "expected file→symbol and symbol→data ownership edges"
875        );
876
877        let data_ownership_edges: Vec<_> = ownership_edges
878            .into_iter()
879            .filter(|(src, _)| *src == sym_idx)
880            .collect();
881        assert!(
882            !data_ownership_edges.is_empty(),
883            "expected symbol→data ownership edge"
884        );
885    }
886}