Skip to main content

unfault_core/graph/
mod.rs

1//! CodeGraph built from per-file semantics.
2//!
3//! This module provides a comprehensive code graph that captures:
4//! - File nodes with path information
5//! - Function/method nodes
6//! - Class/type nodes
7//! - External module/library nodes
8//! - Framework-specific nodes (FastAPI apps, routes, middlewares)
9//!
10//! Edges capture relationships:
11//! - Contains: File contains functions/classes
12//! - Imports: File imports another file
13//! - ImportsFrom: File imports specific items from module
14//! - Calls: Function calls another function
15//! - UsesLibrary: File/function uses external library
16//! - Framework-specific edges (FastAPI routes, middlewares)
17
18pub mod traversal;
19
20use std::collections::HashMap;
21use std::sync::Arc;
22
23use petgraph::Direction;
24use petgraph::graph::{DiGraph, NodeIndex};
25use petgraph::visit::EdgeRef;
26use serde::{Deserialize, Serialize};
27
28use crate::parse::ast::FileId;
29use crate::semantics::common::CommonSemantics;
30use crate::semantics::go::frameworks::GoFrameworkSummary;
31use crate::semantics::go::model::GoFileSemantics;
32use crate::semantics::python::fastapi::FastApiFileSummary;
33use crate::semantics::python::model::PyFileSemantics;
34use crate::semantics::rust::frameworks::RustFrameworkSummary;
35use crate::semantics::rust::model::RustFileSemantics;
36use crate::semantics::typescript::model::{ExpressFileSummary, TsFileSemantics};
37use crate::semantics::{Import, SourceSemantics};
38use crate::types::context::Language;
39
40/// Category of external modules for better organization
41#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
42pub enum ModuleCategory {
43    /// HTTP client libraries (requests, httpx, axios, etc.)
44    HttpClient,
45    /// Database/ORM libraries (sqlalchemy, prisma, etc.)
46    Database,
47    /// Web frameworks (fastapi, express, gin, etc.)
48    WebFramework,
49    /// Async runtimes (asyncio, tokio, etc.)
50    AsyncRuntime,
51    /// Logging libraries
52    Logging,
53    /// Retry/resilience libraries
54    Resilience,
55    /// Standard library
56    StandardLib,
57    /// Other external library
58    Other,
59}
60
61impl Default for ModuleCategory {
62    fn default() -> Self {
63        Self::Other
64    }
65}
66
67/// Nodes in the code graph.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub enum GraphNode {
70    /// A source file
71    File {
72        file_id: FileId,
73        path: String,
74        language: Language,
75    },
76
77    /// A function or method definition
78    Function {
79        file_id: FileId,
80        name: String,
81        /// Qualified name including class (e.g., "MyClass.my_method")
82        qualified_name: String,
83        is_async: bool,
84        /// Whether this is an HTTP handler, event handler, etc.
85        is_handler: bool,
86        /// HTTP method if this is an HTTP route handler (e.g., "GET", "POST")
87        http_method: Option<String>,
88        /// HTTP path if this is an HTTP route handler (e.g., "/users/{user_id}")
89        http_path: Option<String>,
90    },
91
92    /// A class or type definition
93    Class {
94        file_id: FileId,
95        name: String,
96    },
97
98    /// An external module/library dependency
99    ExternalModule {
100        /// Module name (e.g., "requests", "fastapi", "gin")
101        name: String,
102        /// Category for grouping
103        category: ModuleCategory,
104    },
105
106    // === FastAPI-specific nodes (for backward compatibility) ===
107    FastApiApp {
108        file_id: FileId,
109        var_name: String,
110    },
111
112    FastApiRoute {
113        file_id: FileId,
114        http_method: String,
115        path: String,
116    },
117
118    FastApiMiddleware {
119        file_id: FileId,
120        app_var_name: String,
121        middleware_type: String,
122    },
123}
124
125impl GraphNode {
126    /// Get the file_id if this node is associated with a file
127    pub fn file_id(&self) -> Option<FileId> {
128        match self {
129            GraphNode::File { file_id, .. } => Some(*file_id),
130            GraphNode::Function { file_id, .. } => Some(*file_id),
131            GraphNode::Class { file_id, .. } => Some(*file_id),
132            GraphNode::FastApiApp { file_id, .. } => Some(*file_id),
133            GraphNode::FastApiRoute { file_id, .. } => Some(*file_id),
134            GraphNode::FastApiMiddleware { file_id, .. } => Some(*file_id),
135            GraphNode::ExternalModule { .. } => None,
136        }
137    }
138
139    /// Get the display name for this node
140    pub fn display_name(&self) -> String {
141        match self {
142            GraphNode::File { path, .. } => path.clone(),
143            GraphNode::Function { qualified_name, .. } => qualified_name.clone(),
144            GraphNode::Class { name, .. } => name.clone(),
145            GraphNode::ExternalModule { name, .. } => name.clone(),
146            GraphNode::FastApiApp { var_name, .. } => format!("FastAPI({})", var_name),
147            GraphNode::FastApiRoute {
148                http_method, path, ..
149            } => format!("{} {}", http_method, path),
150            GraphNode::FastApiMiddleware {
151                middleware_type, ..
152            } => middleware_type.clone(),
153        }
154    }
155
156    /// Get the HTTP method for this node if it's an HTTP handler
157    pub fn http_method(&self) -> Option<&str> {
158        match self {
159            GraphNode::Function { http_method, .. } => http_method.as_deref(),
160            GraphNode::FastApiRoute { http_method, .. } => Some(http_method),
161            _ => None,
162        }
163    }
164
165    /// Get the HTTP path for this node if it's an HTTP handler
166    pub fn http_path(&self) -> Option<&str> {
167        match self {
168            GraphNode::Function { http_path, .. } => http_path.as_deref(),
169            GraphNode::FastApiRoute { path, .. } => Some(path),
170            _ => None,
171        }
172    }
173
174    /// Check if this is a file node
175    pub fn is_file(&self) -> bool {
176        matches!(self, GraphNode::File { .. })
177    }
178}
179
180/// Edge kinds between nodes.
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182pub enum GraphEdgeKind {
183    /// A file "contains" a construct (function, class, app, route, middleware).
184    Contains,
185
186    /// File A imports File B (entire module)
187    /// Direction: importing file -> imported file
188    Imports,
189
190    /// File A imports specific items from File B
191    /// Contains the item names in the edge data
192    ImportsFrom {
193        /// Items imported (e.g., ["FastAPI", "HTTPException"])
194        items: Vec<String>,
195    },
196
197    /// Function A calls Function B
198    Calls,
199
200    /// Class A inherits from Class B
201    Inherits,
202
203    /// File or Function uses an external library
204    UsesLibrary,
205
206    // === FastAPI-specific edges (for backward compatibility) ===
207    /// A FastAPI app "owns" a route.
208    FastApiAppOwnsRoute,
209
210    /// A FastAPI app "has" a middleware attached.
211    FastApiAppHasMiddleware,
212}
213
214/// The main code graph structure.
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct CodeGraph {
217    pub graph: DiGraph<GraphNode, GraphEdgeKind>,
218    /// Quick lookup: file_id -> node index for the file node.
219    #[serde(skip)]
220    pub file_nodes: HashMap<FileId, NodeIndex>,
221    /// Quick lookup: file path -> node index for the file node.
222    #[serde(skip)]
223    pub path_to_file: HashMap<String, NodeIndex>,
224    /// Quick lookup: path suffix -> node index (for fast import resolution)
225    /// Maps "module.py", "pkg/module.py", etc. to their file nodes
226    #[serde(skip)]
227    pub suffix_to_file: HashMap<String, NodeIndex>,
228    /// Quick lookup: module path (dot-separated) -> node index
229    /// Maps "pkg.module" to its file node
230    #[serde(skip)]
231    pub module_to_file: HashMap<String, NodeIndex>,
232    /// Quick lookup: external module name -> node index
233    #[serde(skip)]
234    pub external_modules: HashMap<String, NodeIndex>,
235    /// Quick lookup: (file_id, function_name) -> node index
236    #[serde(skip)]
237    pub function_nodes: HashMap<(FileId, String), NodeIndex>,
238    /// Quick lookup: (file_id, class_name) -> node index
239    #[serde(skip)]
240    pub class_nodes: HashMap<(FileId, String), NodeIndex>,
241}
242
243impl CodeGraph {
244    pub fn new() -> Self {
245        Self {
246            graph: DiGraph::new(),
247            file_nodes: HashMap::new(),
248            path_to_file: HashMap::new(),
249            suffix_to_file: HashMap::new(),
250            module_to_file: HashMap::new(),
251            external_modules: HashMap::new(),
252            function_nodes: HashMap::new(),
253            class_nodes: HashMap::new(),
254        }
255    }
256
257    /// Get or create an external module node
258    pub fn get_or_create_external_module(
259        &mut self,
260        name: &str,
261        category: ModuleCategory,
262    ) -> NodeIndex {
263        if let Some(&idx) = self.external_modules.get(name) {
264            return idx;
265        }
266        let idx = self.graph.add_node(GraphNode::ExternalModule {
267            name: name.to_string(),
268            category,
269        });
270        self.external_modules.insert(name.to_string(), idx);
271        idx
272    }
273
274    /// Find a file node by path (supports partial matching for relative imports)
275    ///
276    /// This uses pre-built indexes for O(1) lookups instead of O(n) iteration:
277    /// 1. Exact path match via path_to_file
278    /// 2. Module path (dot-separated) via module_to_file
279    /// 3. Path suffix match via suffix_to_file
280    pub fn find_file_by_path(&self, path: &str) -> Option<NodeIndex> {
281        // Try exact match first (fastest)
282        if let Some(&idx) = self.path_to_file.get(path) {
283            return Some(idx);
284        }
285
286        // Try module path lookup (e.g., "auth.middleware" -> "auth/middleware.py")
287        if let Some(&idx) = self.module_to_file.get(path) {
288            return Some(idx);
289        }
290
291        // Try suffix match via pre-built index
292        if let Some(&idx) = self.suffix_to_file.get(path) {
293            return Some(idx);
294        }
295
296        // Try converting module path to file path and lookup
297        if path.contains('.') {
298            let file_path = path.replace('.', "/");
299            // Try with common extensions
300            for ext in &[".py", ".ts", ".tsx", ".js", ".go", ".rs"] {
301                let full_path = format!("{}{}", file_path, ext);
302                if let Some(&idx) = self.suffix_to_file.get(&full_path) {
303                    return Some(idx);
304                }
305            }
306            // Try __init__.py for package imports
307            let init_path = format!("{}/__init__.py", file_path);
308            if let Some(&idx) = self.suffix_to_file.get(&init_path) {
309                return Some(idx);
310            }
311        }
312
313        None
314    }
315
316    /// Get all files that directly import a given file
317    pub fn get_importers(&self, file_id: FileId) -> Vec<FileId> {
318        let Some(&target_idx) = self.file_nodes.get(&file_id) else {
319            return vec![];
320        };
321
322        self.graph
323            .edges_directed(target_idx, Direction::Incoming)
324            .filter(|e| {
325                matches!(
326                    e.weight(),
327                    GraphEdgeKind::Imports | GraphEdgeKind::ImportsFrom { .. }
328                )
329            })
330            .filter_map(|e| {
331                let source_idx = e.source();
332                if let GraphNode::File { file_id, .. } = &self.graph[source_idx] {
333                    Some(*file_id)
334                } else {
335                    None
336                }
337            })
338            .collect()
339    }
340
341    /// Get all files that a given file directly imports
342    pub fn get_imports(&self, file_id: FileId) -> Vec<FileId> {
343        let Some(&source_idx) = self.file_nodes.get(&file_id) else {
344            return vec![];
345        };
346
347        self.graph
348            .edges_directed(source_idx, Direction::Outgoing)
349            .filter(|e| {
350                matches!(
351                    e.weight(),
352                    GraphEdgeKind::Imports | GraphEdgeKind::ImportsFrom { .. }
353                )
354            })
355            .filter_map(|e| {
356                let target_idx = e.target();
357                if let GraphNode::File { file_id, .. } = &self.graph[target_idx] {
358                    Some(*file_id)
359                } else {
360                    None
361                }
362            })
363            .collect()
364    }
365
366    /// Get all files that transitively import a given file (up to max_depth hops)
367    pub fn get_transitive_importers(
368        &self,
369        file_id: FileId,
370        max_depth: usize,
371    ) -> Vec<(FileId, usize)> {
372        let Some(&start_idx) = self.file_nodes.get(&file_id) else {
373            return vec![];
374        };
375
376        let mut result = Vec::new();
377        let mut visited = std::collections::HashSet::new();
378        let mut queue = std::collections::VecDeque::new();
379
380        visited.insert(start_idx);
381        queue.push_back((start_idx, 0usize));
382
383        while let Some((current_idx, depth)) = queue.pop_front() {
384            if depth >= max_depth {
385                continue;
386            }
387
388            for edge in self.graph.edges_directed(current_idx, Direction::Incoming) {
389                if !matches!(
390                    edge.weight(),
391                    GraphEdgeKind::Imports | GraphEdgeKind::ImportsFrom { .. }
392                ) {
393                    continue;
394                }
395
396                let importer_idx = edge.source();
397                if visited.contains(&importer_idx) {
398                    continue;
399                }
400
401                visited.insert(importer_idx);
402
403                if let GraphNode::File { file_id, .. } = &self.graph[importer_idx] {
404                    result.push((*file_id, depth + 1));
405                    queue.push_back((importer_idx, depth + 1));
406                }
407            }
408        }
409
410        result
411    }
412
413    /// Get external libraries used by a file
414    pub fn get_external_dependencies(&self, file_id: FileId) -> Vec<String> {
415        let Some(&file_idx) = self.file_nodes.get(&file_id) else {
416            return vec![];
417        };
418
419        self.graph
420            .edges_directed(file_idx, Direction::Outgoing)
421            .filter(|e| matches!(e.weight(), GraphEdgeKind::UsesLibrary))
422            .filter_map(|e| {
423                let target_idx = e.target();
424                if let GraphNode::ExternalModule { name, .. } = &self.graph[target_idx] {
425                    Some(name.clone())
426                } else {
427                    None
428                }
429            })
430            .collect()
431    }
432
433    /// Get all files that use a specific external library
434    pub fn get_files_using_library(&self, library_name: &str) -> Vec<FileId> {
435        let Some(&lib_idx) = self.external_modules.get(library_name) else {
436            return vec![];
437        };
438
439        self.graph
440            .edges_directed(lib_idx, Direction::Incoming)
441            .filter(|e| matches!(e.weight(), GraphEdgeKind::UsesLibrary))
442            .filter_map(|e| {
443                let source_idx = e.source();
444                if let GraphNode::File { file_id, .. } = &self.graph[source_idx] {
445                    Some(*file_id)
446                } else {
447                    None
448                }
449            })
450            .collect()
451    }
452
453    /// Get statistics about the graph
454    pub fn stats(&self) -> GraphStats {
455        let mut file_count = 0;
456        let mut function_count = 0;
457        let mut class_count = 0;
458        let mut external_module_count = 0;
459
460        for node in self.graph.node_weights() {
461            match node {
462                GraphNode::File { .. } => file_count += 1,
463                GraphNode::Function { .. } => function_count += 1,
464                GraphNode::Class { .. } => class_count += 1,
465                GraphNode::ExternalModule { .. } => external_module_count += 1,
466                _ => {}
467            }
468        }
469
470        let mut import_edge_count = 0;
471        let mut contains_edge_count = 0;
472        let mut uses_library_edge_count = 0;
473        let mut calls_edge_count = 0;
474
475        for edge in self.graph.edge_weights() {
476            match edge {
477                GraphEdgeKind::Imports | GraphEdgeKind::ImportsFrom { .. } => {
478                    import_edge_count += 1
479                }
480                GraphEdgeKind::Contains => contains_edge_count += 1,
481                GraphEdgeKind::UsesLibrary => uses_library_edge_count += 1,
482                GraphEdgeKind::Calls => calls_edge_count += 1,
483                _ => {}
484            }
485        }
486
487        GraphStats {
488            file_count,
489            function_count,
490            class_count,
491            external_module_count,
492            import_edge_count,
493            contains_edge_count,
494            uses_library_edge_count,
495            calls_edge_count,
496            total_nodes: self.graph.node_count(),
497            total_edges: self.graph.edge_count(),
498        }
499    }
500
501    /// Rebuild all lookup indexes from the graph.
502    ///
503    /// This must be called after deserializing a CodeGraph to restore
504    /// the quick-lookup HashMaps that are skipped during serialization.
505    pub fn rebuild_indexes(&mut self) {
506        self.file_nodes.clear();
507        self.path_to_file.clear();
508        self.suffix_to_file.clear();
509        self.module_to_file.clear();
510        self.external_modules.clear();
511        self.function_nodes.clear();
512        self.class_nodes.clear();
513
514        for node_idx in self.graph.node_indices() {
515            match &self.graph[node_idx] {
516                GraphNode::File { file_id, path, .. } => {
517                    self.file_nodes.insert(*file_id, node_idx);
518                    self.path_to_file.insert(path.clone(), node_idx);
519                    // Build suffix indexes for fast import resolution
520                    Self::add_path_to_indexes(
521                        path,
522                        node_idx,
523                        &mut self.suffix_to_file,
524                        &mut self.module_to_file,
525                    );
526                }
527                GraphNode::Function { file_id, name, .. } => {
528                    self.function_nodes
529                        .insert((*file_id, name.clone()), node_idx);
530                }
531                GraphNode::Class { file_id, name, .. } => {
532                    self.class_nodes.insert((*file_id, name.clone()), node_idx);
533                }
534                GraphNode::ExternalModule { name, .. } => {
535                    self.external_modules.insert(name.clone(), node_idx);
536                }
537                _ => {}
538            }
539        }
540    }
541
542    /// Add a path to suffix and module indexes for fast lookup
543    fn add_path_to_indexes(
544        path: &str,
545        node_idx: NodeIndex,
546        suffix_to_file: &mut HashMap<String, NodeIndex>,
547        module_to_file: &mut HashMap<String, NodeIndex>,
548    ) {
549        // Add various suffixes for import resolution
550        // e.g., "src/auth/middleware.py" -> ["middleware.py", "auth/middleware.py", "src/auth/middleware.py"]
551        let parts: Vec<&str> = path.split('/').collect();
552        for i in 0..parts.len() {
553            let suffix: String = parts[i..].join("/");
554            // Only insert if not already present (first path wins)
555            suffix_to_file.entry(suffix).or_insert(node_idx);
556        }
557
558        // Add module-style path (dots instead of slashes, no extension)
559        // e.g., "src/auth/middleware.py" -> "src.auth.middleware"
560        if let Some(without_ext) = path
561            .strip_suffix(".py")
562            .or_else(|| path.strip_suffix(".ts"))
563            .or_else(|| path.strip_suffix(".tsx"))
564            .or_else(|| path.strip_suffix(".js"))
565            .or_else(|| path.strip_suffix(".go"))
566            .or_else(|| path.strip_suffix(".rs"))
567        {
568            let module_path = without_ext.replace('/', ".");
569            module_to_file
570                .entry(module_path.clone())
571                .or_insert(node_idx);
572
573            // Also add partial module paths
574            let mod_parts: Vec<&str> = module_path.split('.').collect();
575            for i in 0..mod_parts.len() {
576                let partial: String = mod_parts[i..].join(".");
577                module_to_file.entry(partial).or_insert(node_idx);
578            }
579        }
580
581        // Special case for __init__.py - map directory to the init file
582        if path.ends_with("__init__.py") {
583            if let Some(dir_path) = path.strip_suffix("/__init__.py") {
584                let module_path = dir_path.replace('/', ".");
585                module_to_file.entry(module_path).or_insert(node_idx);
586            }
587        }
588    }
589}
590
591impl Default for CodeGraph {
592    fn default() -> Self {
593        Self::new()
594    }
595}
596
597/// Statistics about the code graph
598#[derive(Debug, Clone, Serialize, Deserialize)]
599pub struct GraphStats {
600    pub file_count: usize,
601    pub function_count: usize,
602    pub class_count: usize,
603    pub external_module_count: usize,
604    pub import_edge_count: usize,
605    pub contains_edge_count: usize,
606    pub uses_library_edge_count: usize,
607    /// Number of function-to-function call edges
608    pub calls_edge_count: usize,
609    pub total_nodes: usize,
610    pub total_edges: usize,
611}
612
613/// Build a CodeGraph from all file semantics.
614///
615/// `sem_entries` is typically a snapshot of the session's semantics map:
616/// (FileId, Arc<SourceSemantics>).
617pub fn build_code_graph(sem_entries: &[(FileId, Arc<SourceSemantics>)]) -> CodeGraph {
618    let mut cg = CodeGraph::new();
619
620    // First pass: create file nodes and collect path mappings with suffix indexes
621    for (file_id, sem) in sem_entries {
622        let (path, language) = match sem.as_ref() {
623            SourceSemantics::Python(py) => (py.path.clone(), Language::Python),
624            SourceSemantics::Go(go) => (go.path.clone(), Language::Go),
625            SourceSemantics::Rust(rs) => (rs.path.clone(), Language::Rust),
626            SourceSemantics::Typescript(ts) => (ts.path.clone(), Language::Typescript),
627        };
628
629        let node_index = cg.graph.add_node(GraphNode::File {
630            file_id: *file_id,
631            path: path.clone(),
632            language,
633        });
634
635        cg.file_nodes.insert(*file_id, node_index);
636        cg.path_to_file.insert(path.clone(), node_index);
637
638        // Build suffix and module indexes for fast import resolution
639        CodeGraph::add_path_to_indexes(
640            &path,
641            node_index,
642            &mut cg.suffix_to_file,
643            &mut cg.module_to_file,
644        );
645    }
646
647    // Second pass: add functions, classes, imports, and framework-specific nodes
648    for (file_id, sem) in sem_entries {
649        let file_node = match cg.file_nodes.get(file_id) {
650            Some(idx) => *idx,
651            None => continue,
652        };
653
654        // Add imports (works for all languages via CommonSemantics)
655        add_import_edges(&mut cg, file_node, *file_id, sem);
656
657        // Add functions (works for all languages via CommonSemantics)
658        add_function_nodes(&mut cg, file_node, *file_id, sem);
659
660        // Language-specific additions
661        match sem.as_ref() {
662            SourceSemantics::Python(py) => {
663                if let Some(fastapi) = &py.fastapi {
664                    add_fastapi_nodes(&mut cg, file_node, *file_id, py, fastapi);
665                }
666            }
667            SourceSemantics::Go(go) => {
668                // Add Go framework-specific nodes (Gin, Echo, Chi, Fiber, etc.)
669                if let Some(framework) = &go.go_framework {
670                    add_go_framework_nodes(&mut cg, file_node, *file_id, go, framework);
671                }
672            }
673            SourceSemantics::Rust(rs) => {
674                // Add Rust framework-specific nodes (Axum, Actix-web, Rocket, Warp, etc.)
675                if let Some(framework) = &rs.rust_framework {
676                    add_rust_framework_nodes(&mut cg, file_node, *file_id, rs, framework);
677                }
678            }
679            SourceSemantics::Typescript(ts) => {
680                if let Some(express) = &ts.express {
681                    add_express_nodes(&mut cg, file_node, *file_id, ts, express);
682                }
683            }
684        }
685    }
686
687    // Third pass: add Calls edges between functions
688    // First resolve intra-file calls (callee within the same file)
689    for (file_id, sem) in sem_entries {
690        let functions = match sem.as_ref() {
691            SourceSemantics::Python(py) => py.functions(),
692            SourceSemantics::Go(go) => go.functions(),
693            SourceSemantics::Rust(rs) => rs.functions(),
694            SourceSemantics::Typescript(ts) => ts.functions(),
695        };
696
697        for func in functions {
698            // Get the caller node
699            let caller_key = (*file_id, func.name.clone());
700            let Some(&caller_node) = cg.function_nodes.get(&caller_key) else {
701                continue;
702            };
703
704            // Process each call site
705            for call in &func.calls {
706                // Try to find callee in the same file (intra-file call resolution)
707                let callee_key = (*file_id, call.callee.clone());
708                if let Some(&callee_node) = cg.function_nodes.get(&callee_key) {
709                    // Add Calls edge: caller -> callee
710                    cg.graph
711                        .add_edge(caller_node, callee_node, GraphEdgeKind::Calls);
712                }
713            }
714        }
715    }
716
717    // Fourth pass: add cross-file Calls edges using import analysis
718    add_cross_file_call_edges(&mut cg, sem_entries);
719
720    cg
721}
722
723/// Add cross-file Calls edges by resolving function calls through imports.
724///
725/// For each call that wasn't resolved intra-file:
726/// 1. Check if the callee name matches an imported item
727/// 2. Find the source file of that import
728/// 3. Look for the function in that file
729/// 4. Add a Calls edge if found
730fn add_cross_file_call_edges(cg: &mut CodeGraph, sem_entries: &[(FileId, Arc<SourceSemantics>)]) {
731    // Build a lookup: file_id -> (file_path, Vec<Import>)
732    let imports_by_file: HashMap<FileId, (String, Vec<Import>)> = sem_entries
733        .iter()
734        .map(|(file_id, sem)| {
735            let (path, imports) = match sem.as_ref() {
736                SourceSemantics::Python(py) => (py.path.clone(), py.imports()),
737                SourceSemantics::Go(go) => (go.path.clone(), go.imports()),
738                SourceSemantics::Rust(rs) => (rs.path.clone(), rs.imports()),
739                SourceSemantics::Typescript(ts) => (ts.path.clone(), ts.imports()),
740            };
741            (*file_id, (path, imports))
742        })
743        .collect();
744
745    // For each file and its functions
746    for (file_id, sem) in sem_entries {
747        let functions = match sem.as_ref() {
748            SourceSemantics::Python(py) => py.functions(),
749            SourceSemantics::Go(go) => go.functions(),
750            SourceSemantics::Rust(rs) => rs.functions(),
751            SourceSemantics::Typescript(ts) => ts.functions(),
752        };
753
754        let empty_path = String::new();
755        let empty_imports = Vec::new();
756        let (file_path, imports) = imports_by_file
757            .get(file_id)
758            .map(|(p, i)| (p.as_str(), i.as_slice()))
759            .unwrap_or((empty_path.as_str(), empty_imports.as_slice()));
760
761        for func in functions {
762            // Get the caller node
763            let caller_key = (*file_id, func.name.clone());
764            let Some(&caller_node) = cg.function_nodes.get(&caller_key) else {
765                continue;
766            };
767
768            // Process each call site
769            for call in &func.calls {
770                // Skip if already resolved intra-file
771                let callee_key = (*file_id, call.callee.clone());
772                if cg.function_nodes.contains_key(&callee_key) {
773                    continue;
774                }
775
776                // Try to resolve through imports (with file path context for relative imports)
777                if let Some(callee_node) = resolve_call_through_imports(
778                    cg,
779                    &call.callee,
780                    &call.callee_expr,
781                    imports,
782                    file_path,
783                ) {
784                    cg.graph
785                        .add_edge(caller_node, callee_node, GraphEdgeKind::Calls);
786                }
787            }
788        }
789    }
790}
791
792/// Try to resolve a function call through imports.
793///
794/// Returns the NodeIndex of the callee function if found.
795///
796/// # Arguments
797///
798/// * `cg` - The code graph containing file and function nodes
799/// * `callee` - The simple function name being called (e.g., "add")
800/// * `callee_expr` - The full call expression (e.g., "utils.add" or just "add")
801/// * `imports` - The list of imports in the calling file
802/// * `importing_file_path` - The path of the file making the call (for relative import resolution)
803fn resolve_call_through_imports(
804    cg: &CodeGraph,
805    callee: &str,
806    callee_expr: &str,
807    imports: &[Import],
808    importing_file_path: &str,
809) -> Option<NodeIndex> {
810    // Strategy 1: Direct import match
811    // e.g., `from utils import process` then call `process()`
812    // or `from .utils import add` then call `add()`
813    for import in imports {
814        // Check if the callee is a directly imported item
815        if import.imports_item(callee) {
816            // Find the source file for this import, with context for relative imports
817            if let Some(source_file_idx) =
818                find_import_source_file_with_context(cg, &import.module_path, importing_file_path)
819            {
820                // Get the file_id from the source file node
821                if let GraphNode::File { file_id, .. } = &cg.graph[source_file_idx] {
822                    // Look for the function in that file
823                    let callee_key = (*file_id, callee.to_string());
824                    if let Some(&func_node) = cg.function_nodes.get(&callee_key) {
825                        return Some(func_node);
826                    }
827                }
828            }
829        }
830    }
831
832    // Strategy 2: Module attribute access
833    // e.g., `import utils` then call `utils.process()`
834    // The callee_expr would be "utils.process" and callee would be "process"
835    if callee_expr.contains('.') {
836        let parts: Vec<&str> = callee_expr.split('.').collect();
837        if parts.len() >= 2 {
838            let module_alias = parts[0];
839            let func_name = parts[parts.len() - 1];
840
841            for import in imports {
842                // Check if module was imported with this alias
843                let matches_alias = import.module_alias.as_deref() == Some(module_alias)
844                    || import.local_module_name() == Some(module_alias);
845
846                if matches_alias {
847                    if let Some(source_file_idx) = find_import_source_file_with_context(
848                        cg,
849                        &import.module_path,
850                        importing_file_path,
851                    ) {
852                        if let GraphNode::File { file_id, .. } = &cg.graph[source_file_idx] {
853                            let callee_key = (*file_id, func_name.to_string());
854                            if let Some(&func_node) = cg.function_nodes.get(&callee_key) {
855                                return Some(func_node);
856                            }
857                        }
858                    }
859                }
860            }
861        }
862    }
863
864    None
865}
866
867/// Find the file node for an import, with context for relative import resolution.
868///
869/// This function can resolve both absolute and relative imports.
870///
871/// # Arguments
872///
873/// * `cg` - The code graph containing file nodes
874/// * `module_path` - The module path from the import (e.g., ".utils", "pkg.models")
875/// * `importing_file_path` - The path of the file doing the import (for relative resolution)
876fn find_import_source_file_with_context(
877    cg: &CodeGraph,
878    module_path: &str,
879    importing_file_path: &str,
880) -> Option<NodeIndex> {
881    // Handle relative imports
882    if module_path.starts_with('.') {
883        // Use the same resolution logic as add_import_edges
884        let possible_paths = resolve_relative_import(importing_file_path, module_path);
885        for path in &possible_paths {
886            if let Some(idx) = cg.find_file_by_path(path) {
887                return Some(idx);
888            }
889        }
890        return None;
891    }
892
893    // For absolute imports, delegate to the existing function
894    find_import_source_file(cg, module_path)
895}
896
897/// Find the file node that corresponds to an import module path.
898///
899/// For relative imports, this function needs the importing file path to resolve
900/// the relative path. When called without that context (from cross-file call resolution),
901/// it only handles absolute imports.
902fn find_import_source_file(cg: &CodeGraph, module_path: &str) -> Option<NodeIndex> {
903    // Relative imports start with '.' - these can't be resolved without the importing file path
904    // The add_import_edges function handles relative imports directly
905    if module_path.starts_with('.') {
906        return None;
907    }
908
909    // Handle Rust use paths (crate::foo::bar, etc.)
910    if module_path.contains("::") {
911        let stripped = module_path.strip_prefix("crate::").unwrap_or(module_path);
912        for path in rust_path_candidates(stripped) {
913            if let Some(idx) = cg.find_file_by_path(&path) {
914                return Some(idx);
915            }
916        }
917        return None;
918    }
919
920    // Try various path patterns for absolute imports
921    let module_as_file = module_path.replace('.', "/");
922    let possible_paths = [
923        format!("{}.py", module_as_file),
924        format!("{}/__init__.py", module_as_file),
925        format!("{}.ts", module_as_file),
926        format!("{}.tsx", module_as_file),
927        format!("{}.js", module_as_file),
928        format!("{}.go", module_as_file),
929        format!("{}.rs", module_as_file),
930        module_as_file.clone(),
931    ];
932
933    for path in &possible_paths {
934        if let Some(idx) = cg.find_file_by_path(path) {
935            return Some(idx);
936        }
937    }
938
939    None
940}
941
942/// Add import edges from a file to other files or external modules
943fn add_import_edges(
944    cg: &mut CodeGraph,
945    file_node: NodeIndex,
946    _file_id: FileId,
947    sem: &Arc<SourceSemantics>,
948) {
949    // Get the file path to resolve relative imports
950    let file_path = match sem.as_ref() {
951        SourceSemantics::Python(py) => py.path.clone(),
952        SourceSemantics::Go(go) => go.path.clone(),
953        SourceSemantics::Rust(rs) => rs.path.clone(),
954        SourceSemantics::Typescript(ts) => ts.path.clone(),
955    };
956
957    // Get imports via CommonSemantics trait
958    let imports = match sem.as_ref() {
959        SourceSemantics::Python(py) => py.imports(),
960        SourceSemantics::Go(go) => go.imports(),
961        SourceSemantics::Rust(rs) => rs.imports(),
962        SourceSemantics::Typescript(ts) => ts.imports(),
963    };
964
965    for import in imports {
966        // First, try to find the imported module as a file in our graph
967        // This handles both:
968        // - Explicit local/relative imports (import.is_local() == true)
969        // - Absolute imports to local packages (e.g., "from myapp.task import Task")
970        //
971        // We try multiple path patterns to find the file:
972        // 1. Exact module path (e.g., "reliably_app.task" -> "reliably_app/task.py")
973        // 2. Module path as directory init (e.g., "reliably_app" -> "reliably_app/__init__.py")
974        // 3. Relative imports (e.g., ".utils" resolved relative to the importing file's directory)
975
976        let possible_paths = if import.module_path.starts_with('.') {
977            // Handle relative imports (Python-style: from .utils import foo)
978            resolve_relative_import(&file_path, &import.module_path)
979        } else if import.module_path.contains("::") {
980            // Handle Rust use paths (crate::foo::bar, super::foo, self::foo)
981            resolve_rust_use_path(&file_path, &import.module_path)
982        } else {
983            // Absolute imports (Python / JS / TS / Go)
984            let module_as_file = import.module_path.replace('.', "/");
985            vec![
986                format!("{}.py", module_as_file),
987                format!("{}/__init__.py", module_as_file),
988                format!("{}.ts", module_as_file),
989                format!("{}.tsx", module_as_file),
990                format!("{}.js", module_as_file),
991                module_as_file.clone(),
992            ]
993        };
994
995        let mut found_local_file = false;
996        for path in &possible_paths {
997            if let Some(target_idx) = cg.find_file_by_path(path) {
998                // Found as a local file - create import edge
999                if import.items.is_empty() {
1000                    cg.graph
1001                        .add_edge(file_node, target_idx, GraphEdgeKind::Imports);
1002                } else {
1003                    let items: Vec<String> = import.items.iter().map(|i| i.name.clone()).collect();
1004                    cg.graph
1005                        .add_edge(file_node, target_idx, GraphEdgeKind::ImportsFrom { items });
1006                }
1007                found_local_file = true;
1008                break;
1009            }
1010        }
1011
1012        // If not found as a local file, treat as external library
1013        if !found_local_file {
1014            let category = categorize_module(&import.module_path);
1015            let package_name = import.package_name().to_string();
1016            let module_idx = cg.get_or_create_external_module(&package_name, category);
1017            cg.graph
1018                .add_edge(file_node, module_idx, GraphEdgeKind::UsesLibrary);
1019        }
1020    }
1021}
1022
1023/// Resolve a relative import path to possible file paths.
1024///
1025/// Python relative imports use dots to indicate the relative level:
1026/// - `.utils` means `utils` in the same package
1027/// - `..utils` means `utils` in the parent package
1028/// - etc.
1029///
1030/// # Arguments
1031///
1032/// * `importing_file` - The path of the file doing the import (e.g., "app.py")
1033/// * `module_path` - The relative module path (e.g., ".utils", "..models.user")
1034///
1035/// # Returns
1036///
1037/// A vector of possible file paths to try for resolution.
1038fn resolve_relative_import(importing_file: &str, module_path: &str) -> Vec<String> {
1039    // Count leading dots to determine relative level
1040    let dots = module_path.chars().take_while(|&c| c == '.').count();
1041    let remaining = &module_path[dots..];
1042
1043    // Get the directory of the importing file
1044    let importing_dir = if let Some(last_slash) = importing_file.rfind('/') {
1045        &importing_file[..last_slash]
1046    } else {
1047        // File is in root directory
1048        ""
1049    };
1050
1051    // Go up `dots - 1` directories (one dot = same directory, two dots = parent, etc.)
1052    let mut base_dir = importing_dir.to_string();
1053    for _ in 0..(dots.saturating_sub(1)) {
1054        if let Some(last_slash) = base_dir.rfind('/') {
1055            base_dir = base_dir[..last_slash].to_string();
1056        } else {
1057            base_dir = String::new();
1058            break;
1059        }
1060    }
1061
1062    // Convert remaining module path to file path
1063    let module_as_path = remaining.replace('.', "/");
1064
1065    // Build the resolved path
1066    let resolved_base = if base_dir.is_empty() {
1067        module_as_path
1068    } else if module_as_path.is_empty() {
1069        // Just dots, no module name - refers to __init__.py
1070        base_dir
1071    } else {
1072        format!("{}/{}", base_dir, module_as_path)
1073    };
1074
1075    // Return possible file paths
1076    vec![
1077        format!("{}.py", resolved_base),
1078        format!("{}/__init__.py", resolved_base),
1079        format!("{}.ts", resolved_base),
1080        format!("{}.tsx", resolved_base),
1081        format!("{}.js", resolved_base),
1082        resolved_base,
1083    ]
1084}
1085
1086/// Resolve a Rust `use` path to candidate file paths.
1087///
1088/// Handles `crate::`, `super::`, and `self::` prefixes. Because Rust `use` paths
1089/// include the item name (e.g. `crate::parse::ast::FileId` where `FileId` is a
1090/// struct inside `parse/ast.rs`), we try progressively shorter prefixes so that
1091/// `parse/ast/FileId.rs` is tried first, then `parse/ast.rs` (which matches).
1092fn resolve_rust_use_path(importing_file: &str, module_path: &str) -> Vec<String> {
1093    if let Some(rest) = module_path.strip_prefix("crate::") {
1094        rust_path_candidates(rest)
1095    } else if module_path.starts_with("super::") {
1096        let mut rest: &str = module_path;
1097        let mut levels: usize = 0;
1098        while let Some(after) = rest.strip_prefix("super::") {
1099            levels += 1;
1100            rest = after;
1101        }
1102        if rest == "super" {
1103            levels += 1;
1104            rest = "";
1105        }
1106
1107        let importing_dir = importing_file
1108            .rfind('/')
1109            .map(|i| &importing_file[..i])
1110            .unwrap_or("");
1111        let is_mod_rs = importing_file.ends_with("/mod.rs") || importing_file == "mod.rs";
1112        let extra = if is_mod_rs { 1 } else { 0 };
1113
1114        let mut base_dir = importing_dir.to_string();
1115        for _ in 0..(levels + extra).saturating_sub(1) {
1116            if let Some(last_slash) = base_dir.rfind('/') {
1117                base_dir = base_dir[..last_slash].to_string();
1118            } else {
1119                base_dir = String::new();
1120                break;
1121            }
1122        }
1123
1124        let segments: Vec<&str> = if rest.is_empty() {
1125            vec![]
1126        } else {
1127            rest.split("::").collect()
1128        };
1129        let mut paths = Vec::new();
1130        for len in (1..=segments.len()).rev() {
1131            let module_as_path = segments[..len].join("/");
1132            let resolved = if base_dir.is_empty() {
1133                module_as_path
1134            } else {
1135                format!("{}/{}", base_dir, module_as_path)
1136            };
1137            paths.push(format!("{}.rs", resolved));
1138            paths.push(format!("{}/mod.rs", resolved));
1139        }
1140        if segments.is_empty() && !base_dir.is_empty() {
1141            paths.push(format!("{}.rs", base_dir));
1142            paths.push(format!("{}/mod.rs", base_dir));
1143        }
1144        paths
1145    } else if let Some(rest) = module_path.strip_prefix("self::") {
1146        let importing_dir = importing_file
1147            .rfind('/')
1148            .map(|i| &importing_file[..i])
1149            .unwrap_or("");
1150        let segments: Vec<&str> = rest.split("::").collect();
1151        let mut paths = Vec::new();
1152        for len in (1..=segments.len()).rev() {
1153            let module_as_path = segments[..len].join("/");
1154            let resolved = if importing_dir.is_empty() {
1155                module_as_path
1156            } else {
1157                format!("{}/{}", importing_dir, module_as_path)
1158            };
1159            paths.push(format!("{}.rs", resolved));
1160            paths.push(format!("{}/mod.rs", resolved));
1161        }
1162        paths
1163    } else {
1164        // External crate or bare path — try as-is
1165        rust_path_candidates(&module_path.replace("::", "/"))
1166    }
1167}
1168
1169/// Generate candidate file paths for a Rust module path (already stripped of
1170/// `crate::`/`super::`/`self::` prefix). Tries progressively shorter prefixes
1171/// so that item names at the end of the path (e.g. `FileId` in `parse/ast/FileId`)
1172/// are stripped until the actual module file is found.
1173fn rust_path_candidates(path: &str) -> Vec<String> {
1174    let segments: Vec<&str> = if path.contains("::") {
1175        path.split("::").collect()
1176    } else {
1177        path.split('/').collect()
1178    };
1179
1180    let mut paths = Vec::new();
1181    for len in (1..=segments.len()).rev() {
1182        let module_as_file = segments[..len].join("/");
1183        paths.push(format!("{}.rs", module_as_file));
1184        paths.push(format!("{}/mod.rs", module_as_file));
1185        paths.push(format!("src/{}.rs", module_as_file));
1186        paths.push(format!("src/{}/mod.rs", module_as_file));
1187    }
1188    paths
1189}
1190
1191/// Add function nodes from a file
1192///
1193/// For Python files with FastAPI and TypeScript files with Express.js, route handlers
1194/// are skipped here since they will be added by framework-specific functions with
1195/// HTTP method/path metadata.
1196fn add_function_nodes(
1197    cg: &mut CodeGraph,
1198    file_node: NodeIndex,
1199    file_id: FileId,
1200    sem: &Arc<SourceSemantics>,
1201) {
1202    // Get functions via CommonSemantics trait
1203    let functions = match sem.as_ref() {
1204        SourceSemantics::Python(py) => py.functions(),
1205        SourceSemantics::Go(go) => go.functions(),
1206        SourceSemantics::Rust(rs) => rs.functions(),
1207        SourceSemantics::Typescript(ts) => ts.functions(),
1208    };
1209
1210    // Collect framework route handler names to skip
1211    // (they'll be added by framework-specific functions with HTTP metadata)
1212    let handler_names_to_skip: std::collections::HashSet<&str> = match sem.as_ref() {
1213        SourceSemantics::Python(py) => {
1214            // Skip FastAPI route handlers
1215            if let Some(fastapi) = &py.fastapi {
1216                fastapi
1217                    .routes
1218                    .iter()
1219                    .map(|r| r.handler_name.as_str())
1220                    .collect()
1221            } else {
1222                std::collections::HashSet::new()
1223            }
1224        }
1225        SourceSemantics::Typescript(ts) => {
1226            // Skip Express route handlers
1227            if let Some(express) = &ts.express {
1228                express
1229                    .routes
1230                    .iter()
1231                    .filter_map(|r| r.handler_name.as_deref())
1232                    .collect()
1233            } else {
1234                std::collections::HashSet::new()
1235            }
1236        }
1237        SourceSemantics::Go(go) => {
1238            // Skip Go framework route handlers (Gin, Echo, Fiber, Chi)
1239            if let Some(framework) = &go.go_framework {
1240                framework
1241                    .routes
1242                    .iter()
1243                    .filter_map(|r| r.handler_name.as_deref())
1244                    .collect()
1245            } else {
1246                std::collections::HashSet::new()
1247            }
1248        }
1249        SourceSemantics::Rust(_rs) => {
1250            // Rust doesn't have framework route handlers yet
1251            std::collections::HashSet::new()
1252        }
1253    };
1254
1255    for func in functions {
1256        // Skip framework route handlers - they're added by framework-specific functions with HTTP metadata
1257        if handler_names_to_skip.contains(func.name.as_str()) {
1258            continue;
1259        }
1260
1261        let qualified_name = match &func.class_name {
1262            Some(class) => format!("{}.{}", class, func.name),
1263            None => func.name.clone(),
1264        };
1265
1266        let func_node = cg.graph.add_node(GraphNode::Function {
1267            file_id,
1268            name: func.name.clone(),
1269            qualified_name: qualified_name.clone(),
1270            is_async: func.is_async,
1271            is_handler: func.is_route_handler(),
1272            http_method: None,
1273            http_path: None,
1274        });
1275
1276        // File contains function
1277        cg.graph
1278            .add_edge(file_node, func_node, GraphEdgeKind::Contains);
1279
1280        // Store for lookup
1281        cg.function_nodes
1282            .insert((file_id, func.name.clone()), func_node);
1283    }
1284}
1285
1286/// Categorize a module based on its name
1287fn categorize_module(module_path: &str) -> ModuleCategory {
1288    let path_lower = module_path.to_lowercase();
1289
1290    // Logging - check first to catch "logging", "structlog" etc before other patterns
1291    if path_lower == "logging"
1292        || path_lower.starts_with("logging.")
1293        || path_lower.contains("structlog")
1294        || path_lower == "tracing"
1295        || path_lower.starts_with("tracing::")
1296        || path_lower.contains("uber.org/zap")
1297        || path_lower.contains("zerolog")
1298        || path_lower == "winston"
1299        || path_lower == "pino"
1300    {
1301        return ModuleCategory::Logging;
1302    }
1303
1304    // HTTP clients
1305    if path_lower.contains("requests")
1306        || path_lower.contains("httpx")
1307        || path_lower.contains("aiohttp")
1308        || path_lower.contains("urllib")
1309        || path_lower.contains("axios")
1310        || path_lower.contains("fetch")
1311        || path_lower.contains("got")
1312        || path_lower.contains("reqwest")
1313        || path_lower.contains("hyper")
1314    {
1315        return ModuleCategory::HttpClient;
1316    }
1317
1318    // Databases
1319    if path_lower.contains("sqlalchemy")
1320        || path_lower.contains("prisma")
1321        || path_lower.contains("typeorm")
1322        || path_lower.contains("sequelize")
1323        || path_lower.contains("diesel")
1324        || path_lower.contains("sqlx")
1325        || path_lower.contains("gorm")
1326        || path_lower.contains("database/sql")
1327    {
1328        return ModuleCategory::Database;
1329    }
1330
1331    // Web frameworks
1332    if path_lower.contains("fastapi")
1333        || path_lower.contains("flask")
1334        || path_lower.contains("django")
1335        || path_lower.contains("express")
1336        || path_lower.contains("nestjs")
1337        || path_lower.contains("gin")
1338        || path_lower.contains("echo")
1339        || path_lower.contains("chi")
1340        || path_lower.contains("axum")
1341        || path_lower.contains("actix")
1342    {
1343        return ModuleCategory::WebFramework;
1344    }
1345
1346    // Async runtimes
1347    if path_lower.contains("asyncio")
1348        || path_lower.contains("tokio")
1349        || path_lower.contains("async_std")
1350    {
1351        return ModuleCategory::AsyncRuntime;
1352    }
1353
1354    // Resilience
1355    if path_lower.contains("tenacity")
1356        || path_lower.contains("stamina")
1357        || path_lower.contains("retry")
1358        || path_lower.contains("backoff")
1359        || path_lower.contains("resilience")
1360    {
1361        return ModuleCategory::Resilience;
1362    }
1363
1364    // Standard library (approximate)
1365    if module_path.starts_with("os")
1366        || module_path.starts_with("sys")
1367        || module_path.starts_with("io")
1368        || module_path.starts_with("time")
1369        || module_path.starts_with("json")
1370        || module_path.starts_with("re")
1371        || module_path.starts_with("collections")
1372        || module_path.starts_with("typing")
1373        || module_path.starts_with("pathlib")
1374        || module_path.starts_with("fmt")
1375        || module_path.starts_with("net/")
1376        || module_path.starts_with("std::")
1377    {
1378        return ModuleCategory::StandardLib;
1379    }
1380
1381    ModuleCategory::Other
1382}
1383
1384fn add_fastapi_nodes(
1385    cg: &mut CodeGraph,
1386    file_node: NodeIndex,
1387    file_id: FileId,
1388    py: &PyFileSemantics,
1389    fastapi: &FastApiFileSummary,
1390) {
1391    // Map app var_name -> node index so we can wire routes/middlewares.
1392    let mut app_nodes: HashMap<String, NodeIndex> = HashMap::new();
1393
1394    // Apps
1395    for app in &fastapi.apps {
1396        let app_node = cg.graph.add_node(GraphNode::FastApiApp {
1397            file_id,
1398            var_name: app.var_name.clone(),
1399        });
1400
1401        // File contains app
1402        cg.graph
1403            .add_edge(file_node, app_node, GraphEdgeKind::Contains);
1404
1405        app_nodes.insert(app.var_name.clone(), app_node);
1406    }
1407
1408    // Routes
1409    for route in &fastapi.routes {
1410        let route_node = cg.graph.add_node(GraphNode::FastApiRoute {
1411            file_id,
1412            http_method: route.http_method.clone(),
1413            path: route.path.clone(),
1414        });
1415
1416        // File contains route
1417        cg.graph
1418            .add_edge(file_node, route_node, GraphEdgeKind::Contains);
1419
1420        // Try to find an app owner by heuristic.
1421        // For now, we don't know which app exactly, so we might associate all apps.
1422        // Later, we can refine using app.var_name, routers, and decorators.
1423        for app_node in app_nodes.values() {
1424            cg.graph
1425                .add_edge(*app_node, route_node, GraphEdgeKind::FastApiAppOwnsRoute);
1426        }
1427
1428        // Also create a function node for the route handler with HTTP metadata
1429        let qualified_name = route.handler_name.clone();
1430        let func_node = cg.graph.add_node(GraphNode::Function {
1431            file_id,
1432            name: route.handler_name.clone(),
1433            qualified_name,
1434            is_async: route.is_async,
1435            is_handler: true,
1436            http_method: Some(route.http_method.clone()),
1437            http_path: Some(route.path.clone()),
1438        });
1439
1440        // File contains function
1441        cg.graph
1442            .add_edge(file_node, func_node, GraphEdgeKind::Contains);
1443
1444        // Function is the route handler
1445        cg.graph
1446            .add_edge(func_node, route_node, GraphEdgeKind::Contains);
1447
1448        // Store for lookup (needed for call resolution)
1449        cg.function_nodes
1450            .insert((file_id, route.handler_name.clone()), func_node);
1451    }
1452
1453    // Middlewares
1454    for mw in &fastapi.middlewares {
1455        let mw_node = cg.graph.add_node(GraphNode::FastApiMiddleware {
1456            file_id,
1457            app_var_name: mw.app_var_name.clone(),
1458            middleware_type: mw.middleware_type.clone(),
1459        });
1460
1461        // File contains middleware
1462        cg.graph
1463            .add_edge(file_node, mw_node, GraphEdgeKind::Contains);
1464
1465        // Attach middleware to its app if we know it.
1466        if let Some(app_node) = app_nodes.get(&mw.app_var_name) {
1467            cg.graph
1468                .add_edge(*app_node, mw_node, GraphEdgeKind::FastApiAppHasMiddleware);
1469        }
1470    }
1471
1472    let _ = py; // unused for now, but we'll likely need it later.
1473}
1474
1475/// Add Express.js route handlers as function nodes with HTTP metadata.
1476///
1477/// This creates function nodes for Express route handlers with `http_method`
1478/// and `http_path` fields populated, similar to how FastAPI routes are handled.
1479fn add_express_nodes(
1480    cg: &mut CodeGraph,
1481    file_node: NodeIndex,
1482    file_id: FileId,
1483    _ts: &TsFileSemantics,
1484    express: &ExpressFileSummary,
1485) {
1486    // Routes - create function nodes with HTTP metadata
1487    for route in &express.routes {
1488        // Only add routes that have a handler name (named function handlers)
1489        let handler_name = match &route.handler_name {
1490            Some(name) => name.clone(),
1491            None => continue, // Skip inline anonymous handlers for now
1492        };
1493
1494        // Skip if this function was already added by add_function_nodes
1495        if cg
1496            .function_nodes
1497            .contains_key(&(file_id, handler_name.clone()))
1498        {
1499            // Update the existing node with HTTP metadata instead of creating a new one
1500            // For now, we skip - but ideally we'd update the existing node
1501            // This is a limitation we can fix later by refactoring
1502            continue;
1503        }
1504
1505        let http_method = route.method.to_uppercase();
1506        let http_path = route.path.clone();
1507
1508        let func_node = cg.graph.add_node(GraphNode::Function {
1509            file_id,
1510            name: handler_name.clone(),
1511            qualified_name: handler_name.clone(),
1512            is_async: route.is_async,
1513            is_handler: true,
1514            http_method: Some(http_method),
1515            http_path,
1516        });
1517
1518        // File contains function
1519        cg.graph
1520            .add_edge(file_node, func_node, GraphEdgeKind::Contains);
1521
1522        // Store for lookup (needed for call resolution)
1523        cg.function_nodes.insert((file_id, handler_name), func_node);
1524    }
1525}
1526
1527/// Add Go HTTP framework route handlers as function nodes with HTTP metadata.
1528///
1529/// This creates function nodes for Gin, Echo, Fiber, and Chi route handlers with
1530/// `http_method` and `http_path` fields populated, similar to how FastAPI routes
1531/// are handled.
1532fn add_go_framework_nodes(
1533    cg: &mut CodeGraph,
1534    file_node: NodeIndex,
1535    file_id: FileId,
1536    _go: &GoFileSemantics,
1537    framework: &GoFrameworkSummary,
1538) {
1539    // Routes - create function nodes with HTTP metadata
1540    for route in &framework.routes {
1541        // Only add routes that have a handler name (named function handlers)
1542        let handler_name = match &route.handler_name {
1543            Some(name) => name.clone(),
1544            None => continue, // Skip anonymous handlers
1545        };
1546
1547        // Skip if this function was already added by add_function_nodes
1548        if cg
1549            .function_nodes
1550            .contains_key(&(file_id, handler_name.clone()))
1551        {
1552            // We could update the existing node, but for simplicity we skip
1553            continue;
1554        }
1555
1556        let func_node = cg.graph.add_node(GraphNode::Function {
1557            file_id,
1558            name: handler_name.clone(),
1559            qualified_name: handler_name.clone(),
1560            is_async: false, // Go doesn't have async keyword
1561            is_handler: true,
1562            http_method: Some(route.http_method.clone()),
1563            http_path: Some(route.path.clone()),
1564        });
1565
1566        // File contains function
1567        cg.graph
1568            .add_edge(file_node, func_node, GraphEdgeKind::Contains);
1569
1570        // Store for lookup (needed for call resolution)
1571        cg.function_nodes.insert((file_id, handler_name), func_node);
1572    }
1573}
1574
1575/// Add Rust HTTP framework route handlers as function nodes with HTTP metadata.
1576///
1577/// This creates function nodes for Axum, Actix-web, Rocket, Warp, and Poem route handlers
1578/// with `http_method` and `http_path` fields populated, similar to how FastAPI routes
1579/// are handled.
1580fn add_rust_framework_nodes(
1581    cg: &mut CodeGraph,
1582    file_node: NodeIndex,
1583    file_id: FileId,
1584    _rs: &RustFileSemantics,
1585    framework: &RustFrameworkSummary,
1586) {
1587    // Routes - create function nodes with HTTP metadata
1588    for route in &framework.routes {
1589        let handler_name = route.handler_name.clone();
1590
1591        // Skip if this function was already added by add_function_nodes
1592        if cg
1593            .function_nodes
1594            .contains_key(&(file_id, handler_name.clone()))
1595        {
1596            // We could update the existing node, but for simplicity we skip
1597            continue;
1598        }
1599
1600        let func_node = cg.graph.add_node(GraphNode::Function {
1601            file_id,
1602            name: handler_name.clone(),
1603            qualified_name: handler_name.clone(),
1604            is_async: route.is_async,
1605            is_handler: true,
1606            http_method: Some(route.method.clone()),
1607            http_path: Some(route.path.clone()),
1608        });
1609
1610        // File contains function
1611        cg.graph
1612            .add_edge(file_node, func_node, GraphEdgeKind::Contains);
1613
1614        // Store for lookup (needed for call resolution)
1615        cg.function_nodes.insert((file_id, handler_name), func_node);
1616    }
1617}
1618
1619#[cfg(test)]
1620mod tests {
1621    use super::*;
1622    use crate::parse::ast::FileId;
1623    use crate::parse::python::parse_python_file;
1624    use crate::semantics::SourceSemantics;
1625    use crate::semantics::python::model::PyFileSemantics;
1626    use crate::types::context::{Language, SourceFile};
1627
1628    /// Helper to parse Python source and build semantics with framework analysis
1629    fn parse_and_build_semantics(path: &str, source: &str) -> (FileId, Arc<SourceSemantics>) {
1630        let sf = SourceFile {
1631            path: path.to_string(),
1632            language: Language::Python,
1633            content: source.to_string(),
1634        };
1635        let file_id = FileId(1);
1636        let parsed = parse_python_file(file_id, &sf).expect("parsing should succeed");
1637        let mut sem = PyFileSemantics::from_parsed(&parsed);
1638        sem.analyze_frameworks(&parsed)
1639            .expect("framework analysis should succeed");
1640        (file_id, Arc::new(SourceSemantics::Python(sem)))
1641    }
1642
1643    fn parse_python_with_id(path: &str, source: &str, id: u64) -> (FileId, Arc<SourceSemantics>) {
1644        let sf = SourceFile {
1645            path: path.to_string(),
1646            language: Language::Python,
1647            content: source.to_string(),
1648        };
1649        let file_id = FileId(id);
1650        let parsed = parse_python_file(file_id, &sf).expect("parsing should succeed");
1651        let mut sem = PyFileSemantics::from_parsed(&parsed);
1652        sem.analyze_frameworks(&parsed)
1653            .expect("framework analysis should succeed");
1654        (file_id, Arc::new(SourceSemantics::Python(sem)))
1655    }
1656
1657    // ==================== CodeGraph Tests ====================
1658
1659    #[test]
1660    fn code_graph_new_creates_empty_graph() {
1661        let cg = CodeGraph::new();
1662        assert_eq!(cg.graph.node_count(), 0);
1663        assert_eq!(cg.graph.edge_count(), 0);
1664        assert!(cg.file_nodes.is_empty());
1665    }
1666
1667    #[test]
1668    fn code_graph_debug_impl() {
1669        let cg = CodeGraph::new();
1670        let debug_str = format!("{:?}", cg);
1671        assert!(debug_str.contains("CodeGraph"));
1672    }
1673
1674    #[test]
1675    fn code_graph_default_impl() {
1676        let cg = CodeGraph::default();
1677        assert_eq!(cg.graph.node_count(), 0);
1678    }
1679
1680    // ==================== GraphNode Tests ====================
1681
1682    #[test]
1683    fn graph_node_file_debug() {
1684        let node = GraphNode::File {
1685            file_id: FileId(1),
1686            path: "test.py".to_string(),
1687            language: Language::Python,
1688        };
1689        let debug_str = format!("{:?}", node);
1690        assert!(debug_str.contains("File"));
1691        assert!(debug_str.contains("test.py"));
1692    }
1693
1694    #[test]
1695    fn graph_node_function_debug() {
1696        let node = GraphNode::Function {
1697            file_id: FileId(1),
1698            name: "my_func".to_string(),
1699            qualified_name: "MyClass.my_func".to_string(),
1700            is_async: true,
1701            is_handler: false,
1702            http_method: None,
1703            http_path: None,
1704        };
1705        let debug_str = format!("{:?}", node);
1706        assert!(debug_str.contains("Function"));
1707        assert!(debug_str.contains("my_func"));
1708    }
1709
1710    #[test]
1711    fn graph_node_class_debug() {
1712        let node = GraphNode::Class {
1713            file_id: FileId(1),
1714            name: "MyClass".to_string(),
1715        };
1716        let debug_str = format!("{:?}", node);
1717        assert!(debug_str.contains("Class"));
1718        assert!(debug_str.contains("MyClass"));
1719    }
1720
1721    #[test]
1722    fn graph_node_external_module_debug() {
1723        let node = GraphNode::ExternalModule {
1724            name: "requests".to_string(),
1725            category: ModuleCategory::HttpClient,
1726        };
1727        let debug_str = format!("{:?}", node);
1728        assert!(debug_str.contains("ExternalModule"));
1729        assert!(debug_str.contains("requests"));
1730    }
1731
1732    #[test]
1733    fn graph_node_fastapi_app_debug() {
1734        let node = GraphNode::FastApiApp {
1735            file_id: FileId(1),
1736            var_name: "app".to_string(),
1737        };
1738        let debug_str = format!("{:?}", node);
1739        assert!(debug_str.contains("FastApiApp"));
1740        assert!(debug_str.contains("app"));
1741    }
1742
1743    #[test]
1744    fn graph_node_fastapi_route_debug() {
1745        let node = GraphNode::FastApiRoute {
1746            file_id: FileId(1),
1747            http_method: "GET".to_string(),
1748            path: "/users".to_string(),
1749        };
1750        let debug_str = format!("{:?}", node);
1751        assert!(debug_str.contains("FastApiRoute"));
1752        assert!(debug_str.contains("GET"));
1753    }
1754
1755    #[test]
1756    fn graph_node_fastapi_middleware_debug() {
1757        let node = GraphNode::FastApiMiddleware {
1758            file_id: FileId(1),
1759            app_var_name: "app".to_string(),
1760            middleware_type: "CORSMiddleware".to_string(),
1761        };
1762        let debug_str = format!("{:?}", node);
1763        assert!(debug_str.contains("FastApiMiddleware"));
1764        assert!(debug_str.contains("CORSMiddleware"));
1765    }
1766
1767    #[test]
1768    fn graph_node_display_name() {
1769        let file = GraphNode::File {
1770            file_id: FileId(1),
1771            path: "src/main.py".to_string(),
1772            language: Language::Python,
1773        };
1774        assert_eq!(file.display_name(), "src/main.py");
1775
1776        let func = GraphNode::Function {
1777            file_id: FileId(1),
1778            name: "process".to_string(),
1779            qualified_name: "Handler.process".to_string(),
1780            is_async: false,
1781            is_handler: true,
1782            http_method: None,
1783            http_path: None,
1784        };
1785        assert_eq!(func.display_name(), "Handler.process");
1786
1787        let module = GraphNode::ExternalModule {
1788            name: "fastapi".to_string(),
1789            category: ModuleCategory::WebFramework,
1790        };
1791        assert_eq!(module.display_name(), "fastapi");
1792    }
1793
1794    #[test]
1795    fn graph_node_file_id() {
1796        let file = GraphNode::File {
1797            file_id: FileId(1),
1798            path: "test.py".to_string(),
1799            language: Language::Python,
1800        };
1801        assert_eq!(file.file_id(), Some(FileId(1)));
1802
1803        let module = GraphNode::ExternalModule {
1804            name: "requests".to_string(),
1805            category: ModuleCategory::HttpClient,
1806        };
1807        assert_eq!(module.file_id(), None);
1808    }
1809
1810    #[test]
1811    fn graph_node_is_file() {
1812        let file = GraphNode::File {
1813            file_id: FileId(1),
1814            path: "test.py".to_string(),
1815            language: Language::Python,
1816        };
1817        assert!(file.is_file());
1818
1819        let func = GraphNode::Function {
1820            file_id: FileId(1),
1821            name: "test".to_string(),
1822            qualified_name: "test".to_string(),
1823            is_async: false,
1824            is_handler: false,
1825            http_method: None,
1826            http_path: None,
1827        };
1828        assert!(!func.is_file());
1829    }
1830
1831    // ==================== GraphEdgeKind Tests ====================
1832
1833    #[test]
1834    fn graph_edge_kind_debug() {
1835        let edge = GraphEdgeKind::Contains;
1836        let debug_str = format!("{:?}", edge);
1837        assert!(debug_str.contains("Contains"));
1838
1839        let edge = GraphEdgeKind::Imports;
1840        let debug_str = format!("{:?}", edge);
1841        assert!(debug_str.contains("Imports"));
1842
1843        let edge = GraphEdgeKind::ImportsFrom {
1844            items: vec!["FastAPI".to_string()],
1845        };
1846        let debug_str = format!("{:?}", edge);
1847        assert!(debug_str.contains("ImportsFrom"));
1848        assert!(debug_str.contains("FastAPI"));
1849
1850        let edge = GraphEdgeKind::UsesLibrary;
1851        let debug_str = format!("{:?}", edge);
1852        assert!(debug_str.contains("UsesLibrary"));
1853    }
1854
1855    #[test]
1856    fn graph_edge_kind_eq() {
1857        assert_eq!(GraphEdgeKind::Contains, GraphEdgeKind::Contains);
1858        assert_eq!(GraphEdgeKind::Imports, GraphEdgeKind::Imports);
1859        assert_ne!(GraphEdgeKind::Contains, GraphEdgeKind::Imports);
1860
1861        let edge1 = GraphEdgeKind::ImportsFrom {
1862            items: vec!["A".to_string()],
1863        };
1864        let edge2 = GraphEdgeKind::ImportsFrom {
1865            items: vec!["A".to_string()],
1866        };
1867        let edge3 = GraphEdgeKind::ImportsFrom {
1868            items: vec!["B".to_string()],
1869        };
1870        assert_eq!(edge1, edge2);
1871        assert_ne!(edge1, edge3);
1872    }
1873
1874    // ==================== build_code_graph Tests ====================
1875
1876    #[test]
1877    fn build_code_graph_empty_semantics() {
1878        let sem_entries: Vec<(FileId, Arc<SourceSemantics>)> = vec![];
1879        let cg = build_code_graph(&sem_entries);
1880        assert_eq!(cg.graph.node_count(), 0);
1881        assert_eq!(cg.graph.edge_count(), 0);
1882    }
1883
1884    #[test]
1885    fn build_code_graph_single_file_no_fastapi() {
1886        let (file_id, sem) = parse_and_build_semantics("test.py", "x = 1\ny = 2");
1887        let sem_entries = vec![(file_id, sem)];
1888
1889        let cg = build_code_graph(&sem_entries);
1890
1891        // Should have one file node
1892        assert!(cg.graph.node_count() >= 1);
1893        assert!(cg.file_nodes.contains_key(&file_id));
1894    }
1895
1896    #[test]
1897    fn build_code_graph_with_function() {
1898        let src = r#"
1899def process_data(data):
1900    return data * 2
1901
1902async def fetch_user(user_id):
1903    return {"id": user_id}
1904"#;
1905        let (file_id, sem) = parse_and_build_semantics("handlers.py", src);
1906        let sem_entries = vec![(file_id, sem)];
1907
1908        let cg = build_code_graph(&sem_entries);
1909
1910        // Should have file node + 2 function nodes
1911        let stats = cg.stats();
1912        assert_eq!(stats.file_count, 1);
1913        assert!(stats.function_count >= 2);
1914
1915        // Functions should be in lookup
1916        assert!(
1917            cg.function_nodes
1918                .contains_key(&(file_id, "process_data".to_string()))
1919        );
1920        assert!(
1921            cg.function_nodes
1922                .contains_key(&(file_id, "fetch_user".to_string()))
1923        );
1924    }
1925
1926    #[test]
1927    fn build_code_graph_with_external_imports() {
1928        let src = r#"
1929import requests
1930from fastapi import FastAPI
1931import sqlalchemy
1932"#;
1933        let (file_id, sem) = parse_and_build_semantics("main.py", src);
1934        let sem_entries = vec![(file_id, sem)];
1935
1936        let cg = build_code_graph(&sem_entries);
1937
1938        // Should have external module nodes
1939        assert!(cg.external_modules.contains_key("requests"));
1940        assert!(cg.external_modules.contains_key("fastapi"));
1941        assert!(cg.external_modules.contains_key("sqlalchemy"));
1942
1943        // Should have UsesLibrary edges
1944        let stats = cg.stats();
1945        assert!(stats.uses_library_edge_count >= 3);
1946
1947        // Check categories
1948        if let Some(&idx) = cg.external_modules.get("requests") {
1949            if let GraphNode::ExternalModule { category, .. } = &cg.graph[idx] {
1950                assert_eq!(*category, ModuleCategory::HttpClient);
1951            }
1952        }
1953    }
1954
1955    #[test]
1956    fn build_code_graph_with_fastapi_app() {
1957        let src = r#"
1958from fastapi import FastAPI
1959
1960app = FastAPI()
1961"#;
1962        let (file_id, sem) = parse_and_build_semantics("main.py", src);
1963        let sem_entries = vec![(file_id, sem)];
1964
1965        let cg = build_code_graph(&sem_entries);
1966
1967        // Should have file node + app node + external module
1968        assert!(cg.graph.node_count() >= 2);
1969
1970        // Should have at least one edge (file contains app)
1971        assert!(cg.graph.edge_count() >= 1);
1972    }
1973
1974    #[test]
1975    fn build_code_graph_with_fastapi_middleware() {
1976        let src = r#"
1977from fastapi import FastAPI
1978from fastapi.middleware.cors import CORSMiddleware
1979
1980app = FastAPI()
1981
1982app.add_middleware(
1983    CORSMiddleware,
1984    allow_origins=["*"],
1985)
1986"#;
1987        let (file_id, sem) = parse_and_build_semantics("main.py", src);
1988        let sem_entries = vec![(file_id, sem)];
1989
1990        let cg = build_code_graph(&sem_entries);
1991
1992        // Should have file node + app node + middleware node
1993        assert!(cg.graph.node_count() >= 3);
1994
1995        // Should have edges: file->app, file->middleware, app->middleware
1996        assert!(cg.graph.edge_count() >= 3);
1997    }
1998
1999    #[test]
2000    fn build_code_graph_multiple_files() {
2001        let (file_id1, sem1) = parse_python_with_id("file1.py", "x = 1", 1);
2002        let (file_id2, sem2) = parse_python_with_id("file2.py", "y = 2", 2);
2003
2004        let sem_entries = vec![(file_id1, sem1), (file_id2, sem2)];
2005
2006        let cg = build_code_graph(&sem_entries);
2007
2008        // Should have two file nodes
2009        assert_eq!(cg.stats().file_count, 2);
2010        assert!(cg.file_nodes.contains_key(&file_id1));
2011        assert!(cg.file_nodes.contains_key(&file_id2));
2012    }
2013
2014    #[test]
2015    fn build_code_graph_with_fastapi_routes() {
2016        let src = r#"
2017from fastapi import FastAPI
2018
2019app = FastAPI()
2020
2021@app.get("/users")
2022async def get_users():
2023    return []
2024
2025@app.post("/users")
2026async def create_user():
2027    return {}
2028"#;
2029        let (file_id, sem) = parse_and_build_semantics("routes.py", src);
2030        let sem_entries = vec![(file_id, sem)];
2031
2032        let cg = build_code_graph(&sem_entries);
2033
2034        // Should have: file node + app node + 2 route nodes + 2 function nodes
2035        assert!(cg.graph.node_count() >= 4);
2036
2037        // Verify route nodes exist
2038        let mut route_count = 0;
2039        for node in cg.graph.node_weights() {
2040            if matches!(node, GraphNode::FastApiRoute { .. }) {
2041                route_count += 1;
2042            }
2043        }
2044        assert_eq!(route_count, 2);
2045    }
2046
2047    #[test]
2048    fn build_code_graph_middleware_attached_to_correct_app() {
2049        let src = r#"
2050from fastapi import FastAPI
2051from fastapi.middleware.cors import CORSMiddleware
2052
2053app = FastAPI()
2054
2055app.add_middleware(CORSMiddleware)
2056"#;
2057        let (file_id, sem) = parse_and_build_semantics("main.py", src);
2058        let sem_entries = vec![(file_id, sem)];
2059
2060        let cg = build_code_graph(&sem_entries);
2061
2062        // Find the middleware node and verify it's connected to the app
2063        let mut found_middleware_edge = false;
2064        for edge in cg.graph.edge_weights() {
2065            if matches!(edge, GraphEdgeKind::FastApiAppHasMiddleware) {
2066                found_middleware_edge = true;
2067                break;
2068            }
2069        }
2070        assert!(found_middleware_edge);
2071    }
2072
2073    #[test]
2074    fn build_code_graph_middleware_without_matching_app() {
2075        let src = r#"
2076from fastapi.middleware.cors import CORSMiddleware
2077
2078def setup_cors(app):
2079    app.add_middleware(CORSMiddleware)
2080"#;
2081        let (file_id, sem) = parse_and_build_semantics("cors_setup.py", src);
2082        let sem_entries = vec![(file_id, sem)];
2083
2084        let cg = build_code_graph(&sem_entries);
2085
2086        // Should still build without errors
2087        assert!(cg.graph.node_count() >= 1);
2088    }
2089
2090    // ==================== Graph Query Tests ====================
2091
2092    #[test]
2093    fn code_graph_get_external_dependencies() {
2094        let src = r#"
2095import requests
2096from fastapi import FastAPI
2097"#;
2098        let (file_id, sem) = parse_and_build_semantics("main.py", src);
2099        let sem_entries = vec![(file_id, sem)];
2100
2101        let cg = build_code_graph(&sem_entries);
2102
2103        let deps = cg.get_external_dependencies(file_id);
2104        assert!(deps.contains(&"requests".to_string()));
2105        assert!(deps.contains(&"fastapi".to_string()));
2106    }
2107
2108    #[test]
2109    fn code_graph_get_files_using_library() {
2110        let src = r#"import requests"#;
2111        let (file_id, sem) = parse_and_build_semantics("main.py", src);
2112        let sem_entries = vec![(file_id, sem)];
2113
2114        let cg = build_code_graph(&sem_entries);
2115
2116        let files = cg.get_files_using_library("requests");
2117        assert!(files.contains(&file_id));
2118    }
2119
2120    #[test]
2121    fn code_graph_stats() {
2122        let src = r#"
2123import requests
2124from fastapi import FastAPI
2125
2126app = FastAPI()
2127
2128def process():
2129    pass
2130"#;
2131        let (file_id, sem) = parse_and_build_semantics("main.py", src);
2132        let sem_entries = vec![(file_id, sem)];
2133
2134        let cg = build_code_graph(&sem_entries);
2135        let stats = cg.stats();
2136
2137        assert_eq!(stats.file_count, 1);
2138        assert!(stats.function_count >= 1);
2139        assert!(stats.external_module_count >= 2);
2140        assert!(stats.uses_library_edge_count >= 2);
2141        assert!(stats.contains_edge_count >= 1);
2142    }
2143
2144    // ==================== Module Category Tests ====================
2145
2146    #[test]
2147    fn categorize_module_http_client() {
2148        assert_eq!(categorize_module("requests"), ModuleCategory::HttpClient);
2149        assert_eq!(categorize_module("httpx"), ModuleCategory::HttpClient);
2150        assert_eq!(categorize_module("axios"), ModuleCategory::HttpClient);
2151        assert_eq!(categorize_module("reqwest"), ModuleCategory::HttpClient);
2152    }
2153
2154    #[test]
2155    fn categorize_module_database() {
2156        assert_eq!(categorize_module("sqlalchemy"), ModuleCategory::Database);
2157        assert_eq!(categorize_module("prisma"), ModuleCategory::Database);
2158        assert_eq!(categorize_module("diesel"), ModuleCategory::Database);
2159    }
2160
2161    #[test]
2162    fn categorize_module_web_framework() {
2163        assert_eq!(categorize_module("fastapi"), ModuleCategory::WebFramework);
2164        assert_eq!(categorize_module("express"), ModuleCategory::WebFramework);
2165        assert_eq!(categorize_module("gin"), ModuleCategory::WebFramework);
2166    }
2167
2168    #[test]
2169    fn categorize_module_async_runtime() {
2170        assert_eq!(categorize_module("asyncio"), ModuleCategory::AsyncRuntime);
2171        assert_eq!(categorize_module("tokio"), ModuleCategory::AsyncRuntime);
2172    }
2173
2174    #[test]
2175    fn categorize_module_logging() {
2176        assert_eq!(categorize_module("logging"), ModuleCategory::Logging);
2177        assert_eq!(categorize_module("structlog"), ModuleCategory::Logging);
2178        assert_eq!(categorize_module("tracing"), ModuleCategory::Logging);
2179    }
2180
2181    #[test]
2182    fn categorize_module_resilience() {
2183        assert_eq!(categorize_module("tenacity"), ModuleCategory::Resilience);
2184        assert_eq!(categorize_module("stamina"), ModuleCategory::Resilience);
2185    }
2186
2187    #[test]
2188    fn categorize_module_stdlib() {
2189        assert_eq!(categorize_module("os"), ModuleCategory::StandardLib);
2190        assert_eq!(categorize_module("json"), ModuleCategory::StandardLib);
2191        assert_eq!(categorize_module("typing"), ModuleCategory::StandardLib);
2192    }
2193
2194    #[test]
2195    fn categorize_module_other() {
2196        assert_eq!(categorize_module("some_random_lib"), ModuleCategory::Other);
2197    }
2198
2199    // ==================== Find File Tests ====================
2200
2201    #[test]
2202    fn find_file_by_path_exact() {
2203        let (file_id, sem) = parse_and_build_semantics("src/main.py", "x = 1");
2204        let sem_entries = vec![(file_id, sem)];
2205
2206        let cg = build_code_graph(&sem_entries);
2207
2208        assert!(cg.find_file_by_path("src/main.py").is_some());
2209        assert!(cg.find_file_by_path("nonexistent.py").is_none());
2210    }
2211
2212    #[test]
2213    fn find_file_by_path_suffix() {
2214        let (file_id, sem) = parse_and_build_semantics("src/auth/middleware.py", "x = 1");
2215        let sem_entries = vec![(file_id, sem)];
2216
2217        let cg = build_code_graph(&sem_entries);
2218
2219        // Should find by suffix
2220        assert!(cg.find_file_by_path("auth/middleware.py").is_some());
2221        assert!(cg.find_file_by_path("middleware.py").is_some());
2222    }
2223
2224    // ==================== Call Edge Tests ====================
2225
2226    #[test]
2227    fn calls_edge_count_starts_at_zero() {
2228        let src = r#"
2229def foo():
2230    pass
2231
2232def bar():
2233    pass
2234"#;
2235        let (file_id, sem) = parse_and_build_semantics("test.py", src);
2236        let sem_entries = vec![(file_id, sem)];
2237
2238        let cg = build_code_graph(&sem_entries);
2239        let stats = cg.stats();
2240
2241        // Currently we don't extract calls from source yet, so count is 0
2242        assert_eq!(stats.calls_edge_count, 0);
2243        // But we should have function nodes
2244        assert!(stats.function_count >= 2);
2245    }
2246
2247    #[test]
2248    fn calls_edge_manual_creation() {
2249        // Test that we can manually add Calls edges and count them
2250        let mut cg = CodeGraph::new();
2251
2252        let file_id = FileId(1);
2253        let file_node = cg.graph.add_node(GraphNode::File {
2254            file_id,
2255            path: "test.py".to_string(),
2256            language: Language::Python,
2257        });
2258
2259        let func_a = cg.graph.add_node(GraphNode::Function {
2260            file_id,
2261            name: "func_a".to_string(),
2262            qualified_name: "func_a".to_string(),
2263            is_async: false,
2264            is_handler: false,
2265            http_method: None,
2266            http_path: None,
2267        });
2268
2269        let func_b = cg.graph.add_node(GraphNode::Function {
2270            file_id,
2271            name: "func_b".to_string(),
2272            qualified_name: "func_b".to_string(),
2273            is_async: false,
2274            is_handler: false,
2275            http_method: None,
2276            http_path: None,
2277        });
2278
2279        // File contains both functions
2280        cg.graph
2281            .add_edge(file_node, func_a, GraphEdgeKind::Contains);
2282        cg.graph
2283            .add_edge(file_node, func_b, GraphEdgeKind::Contains);
2284
2285        // func_a calls func_b
2286        cg.graph.add_edge(func_a, func_b, GraphEdgeKind::Calls);
2287
2288        let stats = cg.stats();
2289        assert_eq!(stats.calls_edge_count, 1);
2290        assert_eq!(stats.function_count, 2);
2291        assert_eq!(stats.contains_edge_count, 2);
2292    }
2293
2294    #[test]
2295    fn graph_edge_kind_calls_debug() {
2296        let edge = GraphEdgeKind::Calls;
2297        let debug_str = format!("{:?}", edge);
2298        assert!(debug_str.contains("Calls"));
2299    }
2300
2301    #[test]
2302    fn graph_edge_kind_calls_eq() {
2303        assert_eq!(GraphEdgeKind::Calls, GraphEdgeKind::Calls);
2304        assert_ne!(GraphEdgeKind::Calls, GraphEdgeKind::Contains);
2305    }
2306
2307    // ==================== Serialization / Rebuild Tests ====================
2308
2309    #[test]
2310    fn rebuild_indexes_restores_lookups() {
2311        // Build a graph manually
2312        let mut cg = CodeGraph::new();
2313
2314        let file_id = FileId(1);
2315        let file_node = cg.graph.add_node(GraphNode::File {
2316            file_id,
2317            path: "test.py".to_string(),
2318            language: Language::Python,
2319        });
2320        cg.file_nodes.insert(file_id, file_node);
2321        cg.path_to_file.insert("test.py".to_string(), file_node);
2322
2323        let func_node = cg.graph.add_node(GraphNode::Function {
2324            file_id,
2325            name: "my_func".to_string(),
2326            qualified_name: "my_func".to_string(),
2327            is_async: false,
2328            is_handler: false,
2329            http_method: None,
2330            http_path: None,
2331        });
2332        cg.function_nodes
2333            .insert((file_id, "my_func".to_string()), func_node);
2334
2335        let class_node = cg.graph.add_node(GraphNode::Class {
2336            file_id,
2337            name: "MyClass".to_string(),
2338        });
2339        cg.class_nodes
2340            .insert((file_id, "MyClass".to_string()), class_node);
2341
2342        let ext_node = cg.graph.add_node(GraphNode::ExternalModule {
2343            name: "requests".to_string(),
2344            category: ModuleCategory::HttpClient,
2345        });
2346        cg.external_modules.insert("requests".to_string(), ext_node);
2347
2348        // Simulate serialization by clearing all the lookup maps
2349        cg.file_nodes.clear();
2350        cg.path_to_file.clear();
2351        cg.function_nodes.clear();
2352        cg.class_nodes.clear();
2353        cg.external_modules.clear();
2354
2355        // Verify lookups are empty
2356        assert!(cg.file_nodes.is_empty());
2357        assert!(cg.path_to_file.is_empty());
2358        assert!(cg.function_nodes.is_empty());
2359        assert!(cg.class_nodes.is_empty());
2360        assert!(cg.external_modules.is_empty());
2361
2362        // Rebuild indexes
2363        cg.rebuild_indexes();
2364
2365        // Verify lookups are restored
2366        assert!(cg.file_nodes.contains_key(&file_id));
2367        assert!(cg.path_to_file.contains_key("test.py"));
2368        assert!(
2369            cg.function_nodes
2370                .contains_key(&(file_id, "my_func".to_string()))
2371        );
2372        assert!(
2373            cg.class_nodes
2374                .contains_key(&(file_id, "MyClass".to_string()))
2375        );
2376        assert!(cg.external_modules.contains_key("requests"));
2377    }
2378
2379    #[test]
2380    fn rebuild_indexes_clears_stale_data() {
2381        let mut cg = CodeGraph::new();
2382
2383        // Add some stale data to lookups (not matching graph)
2384        cg.file_nodes.insert(FileId(999), NodeIndex::new(0));
2385        cg.external_modules
2386            .insert("stale_module".to_string(), NodeIndex::new(0));
2387
2388        // Add a real node
2389        let file_id = FileId(1);
2390        let _file_node = cg.graph.add_node(GraphNode::File {
2391            file_id,
2392            path: "real.py".to_string(),
2393            language: Language::Python,
2394        });
2395
2396        // Rebuild should clear stale data and add real data
2397        cg.rebuild_indexes();
2398
2399        // Stale data should be gone
2400        assert!(!cg.file_nodes.contains_key(&FileId(999)));
2401        assert!(!cg.external_modules.contains_key("stale_module"));
2402
2403        // Real data should be present
2404        assert!(cg.file_nodes.contains_key(&file_id));
2405        assert!(cg.path_to_file.contains_key("real.py"));
2406    }
2407
2408    #[test]
2409    fn code_graph_serde_roundtrip() {
2410        // Build a simple graph
2411        let src = r#"
2412import requests
2413
2414def process():
2415    pass
2416"#;
2417        let (file_id, sem) = parse_and_build_semantics("main.py", src);
2418        let sem_entries = vec![(file_id, sem)];
2419        let cg = build_code_graph(&sem_entries);
2420
2421        // Record stats before serialization
2422        let stats_before = cg.stats();
2423
2424        // Serialize to JSON
2425        let json = serde_json::to_string(&cg).expect("serialization should succeed");
2426
2427        // Deserialize
2428        let mut cg_restored: CodeGraph =
2429            serde_json::from_str(&json).expect("deserialization should succeed");
2430
2431        // Lookups should be empty after deserialization
2432        assert!(cg_restored.file_nodes.is_empty());
2433        assert!(cg_restored.external_modules.is_empty());
2434
2435        // Rebuild indexes
2436        cg_restored.rebuild_indexes();
2437
2438        // Stats should match
2439        let stats_after = cg_restored.stats();
2440        assert_eq!(stats_before.file_count, stats_after.file_count);
2441        assert_eq!(stats_before.function_count, stats_after.function_count);
2442        assert_eq!(
2443            stats_before.external_module_count,
2444            stats_after.external_module_count
2445        );
2446        assert_eq!(stats_before.total_nodes, stats_after.total_nodes);
2447        assert_eq!(stats_before.total_edges, stats_after.total_edges);
2448
2449        // Lookups should work
2450        assert!(cg_restored.file_nodes.contains_key(&file_id));
2451        assert!(cg_restored.external_modules.contains_key("requests"));
2452    }
2453
2454    // ==================== Cross-File Call Edge Tests ====================
2455
2456    #[test]
2457    fn cross_file_call_edge_direct_import() {
2458        // File 1 defines a helper function
2459        let helper_src = r#"
2460def helper_func():
2461    return 42
2462"#;
2463        let (helper_id, helper_sem) = parse_python_with_id("helpers.py", helper_src, 1);
2464
2465        // File 2 imports and calls the helper
2466        let main_src = r#"
2467from helpers import helper_func
2468
2469def main():
2470    result = helper_func()
2471    return result
2472"#;
2473        let (main_id, main_sem) = parse_python_with_id("main.py", main_src, 2);
2474
2475        let sem_entries = vec![(helper_id, helper_sem), (main_id, main_sem)];
2476        let cg = build_code_graph(&sem_entries);
2477
2478        // Both functions should exist
2479        assert!(
2480            cg.function_nodes
2481                .contains_key(&(helper_id, "helper_func".to_string()))
2482        );
2483        assert!(
2484            cg.function_nodes
2485                .contains_key(&(main_id, "main".to_string()))
2486        );
2487
2488        // Check that there's an import edge from main.py to helpers.py
2489        let stats = cg.stats();
2490        assert!(stats.import_edge_count >= 1);
2491    }
2492
2493    #[test]
2494    fn find_import_source_file_returns_none_for_external() {
2495        let src = "x = 1";
2496        let (file_id, sem) = parse_and_build_semantics("test.py", src);
2497        let sem_entries = vec![(file_id, sem)];
2498        let cg = build_code_graph(&sem_entries);
2499
2500        // Should return None for an external module
2501        assert!(find_import_source_file(&cg, "requests").is_none());
2502        assert!(find_import_source_file(&cg, "fastapi.FastAPI").is_none());
2503    }
2504
2505    #[test]
2506    fn find_import_source_file_finds_local_file() {
2507        let (file_id, sem) = parse_and_build_semantics("utils.py", "x = 1");
2508        let sem_entries = vec![(file_id, sem)];
2509        let cg = build_code_graph(&sem_entries);
2510
2511        // Should find the local file
2512        assert!(find_import_source_file(&cg, "utils").is_some());
2513    }
2514
2515    // ==================== Express.js Graph Tests ====================
2516
2517    use crate::parse::typescript::parse_typescript_file;
2518    use crate::semantics::typescript::model::TsFileSemantics;
2519
2520    fn parse_typescript_and_build_semantics(
2521        path: &str,
2522        source: &str,
2523    ) -> (FileId, Arc<SourceSemantics>) {
2524        let sf = SourceFile {
2525            path: path.to_string(),
2526            language: Language::Typescript,
2527            content: source.to_string(),
2528        };
2529        let file_id = FileId(1);
2530        let parsed = parse_typescript_file(file_id, &sf).expect("parsing should succeed");
2531        let mut sem = TsFileSemantics::from_parsed(&parsed);
2532        sem.analyze_frameworks(&parsed)
2533            .expect("framework analysis should succeed");
2534        (file_id, Arc::new(SourceSemantics::Typescript(sem)))
2535    }
2536
2537    #[test]
2538    fn build_code_graph_with_express_routes_with_http_metadata() {
2539        let src = r#"
2540import express from 'express';
2541
2542const app = express();
2543
2544async function getUsers(req, res) {
2545    res.json([]);
2546}
2547
2548function createUser(req, res) {
2549    res.json({});
2550}
2551
2552app.get('/users', getUsers);
2553app.post('/users', createUser);
2554"#;
2555        let (file_id, sem) = parse_typescript_and_build_semantics("app.ts", src);
2556        let sem_entries = vec![(file_id, sem)];
2557
2558        let cg = build_code_graph(&sem_entries);
2559
2560        // Should have: file node + 2 function nodes with HTTP metadata
2561        let stats = cg.stats();
2562        assert_eq!(stats.file_count, 1);
2563        assert_eq!(stats.function_count, 2);
2564
2565        // Check that getUsers has HTTP metadata
2566        let get_users_key = (file_id, "getUsers".to_string());
2567        assert!(cg.function_nodes.contains_key(&get_users_key));
2568        let get_users_idx = cg.function_nodes[&get_users_key];
2569        if let GraphNode::Function {
2570            http_method,
2571            http_path,
2572            is_handler,
2573            ..
2574        } = &cg.graph[get_users_idx]
2575        {
2576            assert_eq!(*http_method, Some("GET".to_string()));
2577            assert_eq!(*http_path, Some("/users".to_string()));
2578            assert!(*is_handler);
2579        } else {
2580            panic!("Expected Function node for getUsers");
2581        }
2582
2583        // Check that createUser has HTTP metadata
2584        let create_user_key = (file_id, "createUser".to_string());
2585        assert!(cg.function_nodes.contains_key(&create_user_key));
2586        let create_user_idx = cg.function_nodes[&create_user_key];
2587        if let GraphNode::Function {
2588            http_method,
2589            http_path,
2590            is_handler,
2591            ..
2592        } = &cg.graph[create_user_idx]
2593        {
2594            assert_eq!(*http_method, Some("POST".to_string()));
2595            assert_eq!(*http_path, Some("/users".to_string()));
2596            assert!(*is_handler);
2597        } else {
2598            panic!("Expected Function node for createUser");
2599        }
2600    }
2601
2602    // ==================== Relative Import Resolution Tests ====================
2603
2604    #[test]
2605    fn resolve_relative_import_single_dot_same_dir() {
2606        // from .utils import foo -> should resolve to utils.py in same directory
2607        let paths = resolve_relative_import("app.py", ".utils");
2608        assert!(paths.contains(&"utils.py".to_string()));
2609
2610        let paths = resolve_relative_import("pkg/app.py", ".utils");
2611        assert!(paths.contains(&"pkg/utils.py".to_string()));
2612    }
2613
2614    #[test]
2615    fn resolve_relative_import_double_dot_parent_dir() {
2616        // from ..utils import foo -> should resolve to utils.py in parent directory
2617        let paths = resolve_relative_import("pkg/sub/app.py", "..utils");
2618        assert!(paths.contains(&"pkg/utils.py".to_string()));
2619    }
2620
2621    #[test]
2622    fn resolve_relative_import_triple_dot() {
2623        // from ...utils import foo -> should resolve to utils.py two directories up
2624        let paths = resolve_relative_import("a/b/c/app.py", "...utils");
2625        assert!(paths.contains(&"a/utils.py".to_string()));
2626    }
2627
2628    #[test]
2629    fn resolve_relative_import_nested_module() {
2630        // from .models.user import User -> should resolve to models/user.py
2631        let paths = resolve_relative_import("pkg/app.py", ".models.user");
2632        assert!(paths.contains(&"pkg/models/user.py".to_string()));
2633    }
2634
2635    #[test]
2636    fn resolve_relative_import_package_init() {
2637        // from . import models -> should try __init__.py
2638        let paths = resolve_relative_import("pkg/app.py", ".");
2639        assert!(paths.contains(&"pkg/__init__.py".to_string()));
2640    }
2641
2642    #[test]
2643    fn resolve_relative_import_root_file() {
2644        // File in root directory
2645        let paths = resolve_relative_import("app.py", ".utils");
2646        assert!(paths.contains(&"utils.py".to_string()));
2647    }
2648
2649    #[test]
2650    fn build_code_graph_with_relative_import() {
2651        // utils.py defines a function
2652        let utils_src = r#"
2653def add(a, b):
2654    return a + b
2655"#;
2656        let (utils_id, utils_sem) = parse_python_with_id("utils.py", utils_src, 1);
2657
2658        // app.py imports from .utils
2659        let app_src = r#"
2660from .utils import add
2661
2662def main():
2663    return add(1, 2)
2664"#;
2665        let (app_id, app_sem) = parse_python_with_id("app.py", app_src, 2);
2666
2667        let sem_entries = vec![(utils_id, utils_sem), (app_id, app_sem)];
2668        let cg = build_code_graph(&sem_entries);
2669
2670        // Should have import edge from app.py to utils.py
2671        let stats = cg.stats();
2672        assert!(
2673            stats.import_edge_count >= 1,
2674            "Expected at least 1 import edge, got {}",
2675            stats.import_edge_count
2676        );
2677
2678        // Verify the edge is ImportsFrom with correct items
2679        let app_file_idx = cg.file_nodes.get(&app_id).expect("app file should exist");
2680        let mut found_import_edge = false;
2681        for edge in cg.graph.edges(*app_file_idx) {
2682            if let GraphEdgeKind::ImportsFrom { items } = edge.weight() {
2683                if items.contains(&"add".to_string()) {
2684                    found_import_edge = true;
2685                }
2686            }
2687        }
2688        assert!(
2689            found_import_edge,
2690            "Expected ImportsFrom edge with 'add' item"
2691        );
2692    }
2693
2694    #[test]
2695    fn build_code_graph_with_relative_import_nested() {
2696        // pkg/utils.py defines a function
2697        let utils_src = r#"
2698def helper():
2699    pass
2700"#;
2701        let (utils_id, utils_sem) = parse_python_with_id("pkg/utils.py", utils_src, 1);
2702
2703        // pkg/sub/app.py imports from ..utils
2704        let app_src = r#"
2705from ..utils import helper
2706"#;
2707        let (app_id, app_sem) = parse_python_with_id("pkg/sub/app.py", app_src, 2);
2708
2709        let sem_entries = vec![(utils_id, utils_sem), (app_id, app_sem)];
2710        let cg = build_code_graph(&sem_entries);
2711
2712        // Should have import edge from app.py to utils.py
2713        let stats = cg.stats();
2714        assert!(
2715            stats.import_edge_count >= 1,
2716            "Expected at least 1 import edge for ..utils"
2717        );
2718    }
2719
2720    #[test]
2721    fn find_import_source_file_returns_none_for_relative() {
2722        let src = "x = 1";
2723        let (file_id, sem) = parse_and_build_semantics("test.py", src);
2724        let sem_entries = vec![(file_id, sem)];
2725        let cg = build_code_graph(&sem_entries);
2726
2727        // Relative imports can't be resolved without the importing file context
2728        assert!(find_import_source_file(&cg, ".utils").is_none());
2729        assert!(find_import_source_file(&cg, "..models").is_none());
2730    }
2731
2732    #[test]
2733    fn cross_file_call_edge_with_relative_import() {
2734        // This is the exact scenario from the bug report:
2735        // utils.py defines add(), app.py imports via `from .utils import add` and calls it
2736
2737        // utils.py defines the add function
2738        let utils_src = r#"
2739def add(a, b):
2740    return a + b
2741"#;
2742        let (utils_id, utils_sem) = parse_python_with_id("utils.py", utils_src, 1);
2743
2744        // app.py imports add via relative import and calls it
2745        let app_src = r#"
2746from .utils import add
2747
2748def main():
2749    result = add(1, 2)
2750    return result
2751"#;
2752        let (app_id, app_sem) = parse_python_with_id("app.py", app_src, 2);
2753
2754        let sem_entries = vec![(utils_id, utils_sem), (app_id, app_sem)];
2755        let cg = build_code_graph(&sem_entries);
2756
2757        // Verify both functions exist
2758        assert!(
2759            cg.function_nodes
2760                .contains_key(&(utils_id, "add".to_string()))
2761        );
2762        assert!(
2763            cg.function_nodes
2764                .contains_key(&(app_id, "main".to_string()))
2765        );
2766
2767        // Key assertion: There should be a Calls edge from main() to add()
2768        let stats = cg.stats();
2769        assert!(
2770            stats.calls_edge_count >= 1,
2771            "Expected at least 1 Calls edge for cross-file call via relative import, got {}",
2772            stats.calls_edge_count
2773        );
2774
2775        // Verify the specific edge exists: main -> add
2776        let main_func_idx = cg
2777            .function_nodes
2778            .get(&(app_id, "main".to_string()))
2779            .expect("main function should exist");
2780        let add_func_idx = cg
2781            .function_nodes
2782            .get(&(utils_id, "add".to_string()))
2783            .expect("add function should exist");
2784
2785        let mut found_calls_edge = false;
2786        for edge in cg.graph.edges(*main_func_idx) {
2787            if matches!(edge.weight(), GraphEdgeKind::Calls) {
2788                if edge.target() == *add_func_idx {
2789                    found_calls_edge = true;
2790                    break;
2791                }
2792            }
2793        }
2794        assert!(found_calls_edge, "Expected Calls edge from main() to add()");
2795    }
2796
2797    #[test]
2798    fn find_import_source_file_with_context_resolves_relative() {
2799        // Set up a graph with a utils.py file
2800        let (utils_id, utils_sem) = parse_python_with_id("utils.py", "x = 1", 1);
2801        let sem_entries = vec![(utils_id, utils_sem)];
2802        let cg = build_code_graph(&sem_entries);
2803
2804        // Without context, relative import should fail
2805        assert!(find_import_source_file(&cg, ".utils").is_none());
2806
2807        // With context, relative import should succeed
2808        let result = find_import_source_file_with_context(&cg, ".utils", "app.py");
2809        assert!(
2810            result.is_some(),
2811            "Expected to find utils.py via relative import from app.py"
2812        );
2813    }
2814}