1use 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#[derive(Debug)]
29pub struct GraphBuilder {
30 graph: DiGraph<NodeData, EdgeData>,
32
33 file_to_index: HashMap<FileId, NodeIndex>,
35
36 symbol_to_index: HashMap<SymbolId, NodeIndex>,
38
39 path_to_file: HashMap<PathBuf, FileId>,
41
42 file_id_gen: IdGenerator<FileId>,
44
45 snapshot_id: SnapshotId,
47
48 edge_index: HashMap<(NodeIndex, NodeIndex, EdgeKind), EdgeIndex>,
50
51 external_index: HashMap<String, NodeIndex>,
53
54 #[cfg(feature = "dataflow")]
56 data_to_index: HashMap<DataNodeId, NodeIndex>,
57}
58
59impl GraphBuilder {
60 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 pub fn add_file(&mut self, path: PathBuf, language: LangId) -> FileId {
80 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 pub fn add_symbol(&mut self, symbol: &Symbol) -> Result<NodeIndex, crate::Error> {
109 if let Some(&existing) = self.symbol_to_index.get(&symbol.id) {
111 return Ok(existing);
112 }
113
114 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 self.add_edge_internal(file_idx, sym_idx, EdgeKind::Ownership, 1.0);
139
140 Ok(sym_idx)
141 }
142
143 #[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 #[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 pub fn add_import(&mut self, from: FileId, to: PathBuf) {
200 let Some(&from_idx) = self.file_to_index.get(&from) else {
201 return; };
203
204 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 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 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 #[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 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 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 pub fn file_id_for_path(&self, path: &std::path::PathBuf) -> Option<FileId> {
315 self.path_to_file.get(path).copied()
316 }
317
318 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 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 pub fn node_count(&self) -> usize {
354 self.graph.node_count()
355 }
356
357 pub fn edge_count(&self) -> usize {
359 self.graph.edge_count()
360 }
361
362 pub fn external_count(&self) -> usize {
364 self.external_index.len()
365 }
366
367 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 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 for file in extractions {
408 let file = file.borrow();
409 builder.add_file(file.path.clone(), file.lang);
410 }
411
412 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 #[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 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 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 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 let graph = builder.build();
503 #[cfg(feature = "metacall-deploy")]
504 let mut graph = graph;
505
506 #[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); assert_eq!(builder.edge_count(), 1); }
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 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 builder.add_import(file_id, PathBuf::from("external_module.py"));
654
655 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 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}