Skip to main content

graphify_extract/
lib.rs

1//! AST and semantic extraction engine for graphify.
2//!
3//! Implements a two-pass extraction pipeline ported from the Python `extract.py`:
4//!
5//! - **Pass 1** (deterministic): regex-based AST extraction of functions, classes,
6//!   imports, and call relationships from source code.
7//! - **Pass 2** (semantic): Claude API–based extraction of higher-level concepts
8//!   from documents, papers, and images.
9
10pub mod ast_extract;
11pub mod dedup;
12pub mod lang_config;
13pub mod parser;
14pub mod semantic;
15pub mod treesitter;
16
17use std::collections::{HashMap, HashSet};
18use std::path::{Path, PathBuf};
19
20use graphify_core::confidence::Confidence;
21use graphify_core::model::{ExtractionResult, GraphEdge, NodeType};
22use rayon::prelude::*;
23use tracing::{debug, info, warn};
24
25// ---------------------------------------------------------------------------
26// Extension → language dispatch table
27// ---------------------------------------------------------------------------
28
29/// Maps file extensions to language identifiers used by the extraction engine.
30pub const DISPATCH: &[(&str, &str)] = &[
31    (".py", "python"),
32    (".js", "javascript"),
33    (".jsx", "javascript"),
34    (".ts", "typescript"),
35    (".tsx", "typescript"),
36    (".go", "go"),
37    (".rs", "rust"),
38    (".java", "java"),
39    (".c", "c"),
40    (".h", "c"),
41    (".cpp", "cpp"),
42    (".cc", "cpp"),
43    (".cxx", "cpp"),
44    (".hpp", "cpp"),
45    (".rb", "ruby"),
46    (".cs", "csharp"),
47    (".kt", "kotlin"),
48    (".kts", "kotlin"),
49    (".scala", "scala"),
50    (".php", "php"),
51    (".swift", "swift"),
52    (".lua", "lua"),
53    (".toc", "lua"),
54    (".zig", "zig"),
55    (".ps1", "powershell"),
56    (".ex", "elixir"),
57    (".exs", "elixir"),
58    (".m", "objc"),
59    (".mm", "objc"),
60    (".jl", "julia"),
61    (".dart", "dart"),
62];
63
64/// Build a hashmap for fast extension lookup.
65fn dispatch_map() -> HashMap<&'static str, &'static str> {
66    DISPATCH.iter().copied().collect()
67}
68
69/// Return the language name for a file extension (e.g. `".py"` → `"python"`).
70pub fn language_for_path(path: &Path) -> Option<&'static str> {
71    let ext = path.extension()?.to_str()?;
72    let dotted = format!(".{ext}");
73    dispatch_map().get(dotted.as_str()).copied()
74}
75
76// ---------------------------------------------------------------------------
77// File collection
78// ---------------------------------------------------------------------------
79
80/// Recursively collect all supported source files under `target`.
81pub fn collect_files(target: &Path) -> Vec<PathBuf> {
82    let map = dispatch_map();
83    let mut files = Vec::new();
84    collect_files_inner(target, &map, &mut files);
85    files.sort();
86    files
87}
88
89fn collect_files_inner(dir: &Path, map: &HashMap<&str, &str>, out: &mut Vec<PathBuf>) {
90    let entries = match std::fs::read_dir(dir) {
91        Ok(e) => e,
92        Err(e) => {
93            warn!("cannot read directory {}: {e}", dir.display());
94            return;
95        }
96    };
97    for entry in entries.flatten() {
98        let path = entry.path();
99        if path.is_dir() {
100            // Skip hidden dirs and common vendor dirs
101            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
102            if name.starts_with('.')
103                || name == "node_modules"
104                || name == "__pycache__"
105                || name == "target"
106                || name == "vendor"
107                || name == "venv"
108                || name == ".git"
109            {
110                continue;
111            }
112            collect_files_inner(&path, map, out);
113        } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
114            let dotted = format!(".{ext}");
115            if map.contains_key(dotted.as_str()) {
116                out.push(path);
117            }
118        }
119    }
120}
121
122// ---------------------------------------------------------------------------
123// Main extraction entry point
124// ---------------------------------------------------------------------------
125
126/// Run Pass 1 extraction on a set of file paths.
127///
128/// Dispatches each file to the appropriate regex-based extractor, collects all
129/// nodes and edges, deduplicates, and runs cross-file import resolution for Python.
130///
131/// Files are processed in parallel using rayon for improved throughput on
132/// multi-core machines.
133pub fn extract(paths: &[PathBuf]) -> ExtractionResult {
134    let results: Vec<ExtractionResult> = paths
135        .par_iter()
136        .filter_map(|path| {
137            let lang = match language_for_path(path) {
138                Some(l) => l,
139                None => {
140                    debug!("skipping unsupported file: {}", path.display());
141                    return None;
142                }
143            };
144
145            let source = match std::fs::read(path) {
146                Ok(s) => s,
147                Err(e) => {
148                    warn!("cannot read {}: {e}", path.display());
149                    return None;
150                }
151            };
152
153            debug!("extracting {} ({})", path.display(), lang);
154
155            // Try tree-sitter first, fall back to regex
156            let mut result = if let Some(ts_result) = treesitter::try_extract(path, &source, lang) {
157                debug!("used tree-sitter for {} ({})", path.display(), lang);
158                ts_result
159            } else {
160                let source_str = String::from_utf8_lossy(&source);
161                ast_extract::extract_file(path, source_str.as_ref(), lang)
162            };
163            dedup::dedup_file(&mut result);
164
165            Some(result)
166        })
167        .collect();
168
169    let mut combined = ExtractionResult::default();
170    for r in results {
171        combined.nodes.extend(r.nodes);
172        combined.edges.extend(r.edges);
173        combined.hyperedges.extend(r.hyperedges);
174    }
175
176    // Cross-file import resolution for Python
177    resolve_python_imports(&mut combined);
178
179    // Cross-file import resolution for JS/TS, Go, and Rust
180    resolve_cross_file_imports(&mut combined);
181
182    info!(
183        "extraction complete: {} nodes, {} edges",
184        combined.nodes.len(),
185        combined.edges.len()
186    );
187
188    combined
189}
190
191/// Resolve Python `import` / `from ... import` edges to actual module/function
192/// nodes discovered across files.
193///
194/// Also handles `from x import *` by expanding to all entities in module x.
195fn resolve_python_imports(result: &mut ExtractionResult) {
196    // Build a lookup from node label → node id
197    let label_to_id: HashMap<String, String> = result
198        .nodes
199        .iter()
200        .map(|n| (n.label.clone(), n.id.clone()))
201        .collect();
202
203    // Build module stem → [entity_id] for star import expansion
204    let mut stem_to_entity_ids: HashMap<String, Vec<String>> = HashMap::new();
205    let defined_targets: HashSet<String> = result
206        .edges
207        .iter()
208        .filter(|e| e.relation == "defines")
209        .map(|e| e.target.clone())
210        .collect();
211    for node in &result.nodes {
212        if !defined_targets.contains(&node.id) {
213            continue;
214        }
215        let stem = std::path::Path::new(&node.source_file)
216            .file_stem()
217            .and_then(|s| s.to_str())
218            .unwrap_or("")
219            .to_string();
220        stem_to_entity_ids
221            .entry(stem)
222            .or_default()
223            .push(node.id.clone());
224    }
225
226    // Collect star import edges for expansion
227    let mut star_expansions: Vec<GraphEdge> = Vec::new();
228
229    // For every edge with relation "imports", try to resolve the target
230    for edge in &mut result.edges {
231        if edge.relation == "imports" {
232            // Check for star import: target label contains "*"
233            let import_label = result
234                .nodes
235                .iter()
236                .find(|n| n.id == edge.target)
237                .map(|n| n.label.as_str())
238                .unwrap_or("");
239
240            if import_label.contains('*') {
241                // `from module import *` — expand to all entities in module
242                let module_name = import_label.trim_end_matches(".*").trim_end_matches(" *");
243                if let Some(entity_ids) = stem_to_entity_ids.get(module_name) {
244                    for target_id in entity_ids {
245                        star_expansions.push(GraphEdge {
246                            source: edge.source.clone(),
247                            target: target_id.clone(),
248                            relation: "uses".to_string(),
249                            confidence: Confidence::Inferred,
250                            confidence_score: 0.7,
251                            source_file: edge.source_file.clone(),
252                            source_location: None,
253                            weight: 0.7,
254                            extra: Default::default(),
255                        });
256                    }
257                }
258            } else {
259                // Regular import — resolve by label
260                if let Some(resolved_id) = label_to_id.get(&edge.target) {
261                    edge.target = resolved_id.clone();
262                    edge.confidence = graphify_core::confidence::Confidence::Extracted;
263                }
264            }
265        }
266    }
267
268    if !star_expansions.is_empty() {
269        debug!(
270            "python star import expansion: created {} uses edges",
271            star_expansions.len()
272        );
273        result.edges.extend(star_expansions);
274    }
275}
276
277/// Resolve cross-file imports for JS/TS, Go, and Rust.
278///
279/// For each `imports` edge, tries to match the imported module name to a file
280/// stem and then creates `uses` edges from entities in the importing file to
281/// entities defined in the target module. This turns file-level import edges
282/// into entity-level relationship edges.
283fn resolve_cross_file_imports(result: &mut ExtractionResult) {
284    // Step 1: Build lookup indexes in a single pass over nodes.
285    //   - id_to_label: node_id → label (for fast import label lookup)
286    //   - stem_to_entities: file_stem → [(label, node_id, node_type)]
287    //   - go_pkg_to_entities: go_dir_name → [(label, node_id, node_type)]
288    let mut id_to_label: HashMap<String, String> = HashMap::new();
289    let mut stem_to_entities: HashMap<String, Vec<(String, String, NodeType)>> = HashMap::new();
290    let mut go_pkg_to_entities: HashMap<String, Vec<(String, String, NodeType)>> = HashMap::new();
291    let mut source_file_to_stem: HashMap<String, String> = HashMap::new();
292    let mut file_id_to_source: HashMap<String, String> = HashMap::new();
293
294    // Collect defined entity IDs from edges (one pass)
295    let defined_entity_ids: HashSet<String> = result
296        .edges
297        .iter()
298        .filter(|e| e.relation == "defines")
299        .map(|e| e.target.clone())
300        .collect();
301
302    // Build source_file → [entity_node_id] and id_to_label in one pass over edges
303    let mut source_file_entities: HashMap<String, Vec<String>> = HashMap::new();
304    for edge in &result.edges {
305        if edge.relation == "defines" {
306            source_file_entities
307                .entry(edge.source_file.clone())
308                .or_default()
309                .push(edge.target.clone());
310        }
311    }
312
313    // Single pass over nodes to build all indexes
314    for node in &result.nodes {
315        id_to_label.insert(node.id.clone(), node.label.clone());
316
317        if node.node_type == NodeType::File {
318            let stem = Path::new(&node.source_file)
319                .file_stem()
320                .and_then(|s| s.to_str())
321                .unwrap_or("")
322                .to_string();
323            source_file_to_stem.insert(node.source_file.clone(), stem);
324            file_id_to_source.insert(node.id.clone(), node.source_file.clone());
325            continue;
326        }
327
328        if !defined_entity_ids.contains(&node.id) {
329            continue;
330        }
331
332        let path = Path::new(&node.source_file);
333        let stem = path
334            .file_stem()
335            .and_then(|s| s.to_str())
336            .unwrap_or("")
337            .to_string();
338
339        stem_to_entities.entry(stem).or_default().push((
340            node.label.clone(),
341            node.id.clone(),
342            node.node_type.clone(),
343        ));
344
345        // Go package grouping
346        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
347        if ext == "go"
348            && let Some(dir) = path
349                .parent()
350                .and_then(|d| d.file_name())
351                .and_then(|d| d.to_str())
352        {
353            go_pkg_to_entities
354                .entry(dir.to_string())
355                .or_default()
356                .push((node.label.clone(), node.id.clone(), node.node_type.clone()));
357        }
358    }
359
360    // Step 2: Resolve imports → create uses edges
361    let mut new_edges: Vec<GraphEdge> = Vec::new();
362    let mut seen = HashSet::new();
363
364    for edge in &result.edges {
365        if edge.relation != "imports" {
366            continue;
367        }
368
369        let source_file = &edge.source_file;
370        let ext = Path::new(source_file)
371            .extension()
372            .and_then(|e| e.to_str())
373            .unwrap_or("");
374
375        // O(1) lookup instead of linear scan
376        let import_label = match id_to_label.get(&edge.target) {
377            Some(label) => label.as_str(),
378            None => continue,
379        };
380
381        if import_label.is_empty() {
382            continue;
383        }
384
385        let target_entities = match ext {
386            "js" | "jsx" | "ts" | "tsx" => resolve_jsts_import(import_label, &stem_to_entities),
387            "go" => resolve_go_import(import_label, &stem_to_entities, &go_pkg_to_entities),
388            "rs" => resolve_rust_import(import_label, &stem_to_entities),
389            "java" => resolve_dot_import(import_label, &stem_to_entities),
390            "cs" => resolve_dot_import(import_label, &stem_to_entities),
391            "c" | "h" | "cpp" | "cc" | "cxx" | "hpp" => {
392                resolve_c_include(import_label, &stem_to_entities)
393            }
394            "kt" | "kts" => {
395                let cleaned = import_label.strip_prefix("import ").unwrap_or(import_label);
396                resolve_dot_import(cleaned.trim(), &stem_to_entities)
397            }
398            "php" => {
399                let cleaned = import_label.strip_prefix("use ").unwrap_or(import_label);
400                resolve_backslash_import(cleaned.trim(), &stem_to_entities)
401            }
402            "dart" => resolve_dart_import(import_label, &stem_to_entities),
403            "scala" => {
404                let cleaned = import_label.strip_prefix("import ").unwrap_or(import_label);
405                resolve_dot_import(cleaned.trim(), &stem_to_entities)
406            }
407            "swift" => {
408                let cleaned = import_label.strip_prefix("import ").unwrap_or(import_label);
409                resolve_dot_import(cleaned.trim(), &stem_to_entities)
410            }
411            _ => continue,
412        };
413
414        if target_entities.is_empty() {
415            continue;
416        }
417
418        // Get the importing file's own entities
419        let local_entities = match source_file_entities.get(source_file) {
420            Some(ids) => ids,
421            None => continue,
422        };
423
424        // Create uses edges: each entity in the importing file → each entity in the target module
425        for local_id in local_entities {
426            for (_, target_id, _) in &target_entities {
427                if local_id == target_id {
428                    continue;
429                }
430                let key = (local_id.clone(), target_id.clone());
431                if seen.contains(&key) {
432                    continue;
433                }
434                seen.insert(key);
435                new_edges.push(GraphEdge {
436                    source: local_id.clone(),
437                    target: target_id.clone(),
438                    relation: "uses".to_string(),
439                    confidence: Confidence::Inferred,
440                    confidence_score: 0.8,
441                    source_file: source_file.clone(),
442                    source_location: None,
443                    weight: 0.8,
444                    extra: Default::default(),
445                });
446            }
447        }
448    }
449
450    if !new_edges.is_empty() {
451        debug!(
452            "cross-file import resolution: created {} inferred uses edges",
453            new_edges.len()
454        );
455    }
456
457    result.edges.extend(new_edges);
458}
459
460/// Resolve a JS/TS import label to target entities.
461///
462/// Import labels can be:
463/// - `"module/ExportedName"` (named import from module)
464/// - `"DefaultName"` (default import, label is the local binding name)
465/// - `"./relative/path"` module path
466///
467/// Also handles barrel exports: if import points to a directory, tries to
468/// resolve via `index` file (index.js/index.ts).
469fn resolve_jsts_import<'a>(
470    import_label: &str,
471    stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
472) -> Vec<&'a (String, String, NodeType)> {
473    // For named imports like "utils/parseDate", the stem is "utils"
474    // For path imports like "./components/Button", the stem is "Button"
475    let parts: Vec<&str> = import_label.split('/').collect();
476
477    // Try the first segment as module stem (for "module/Name" patterns)
478    if parts.len() >= 2 {
479        let module_stem = parts[0].trim_start_matches('.');
480        if let Some(entities) = stem_to_entities.get(module_stem) {
481            return entities.iter().collect();
482        }
483    }
484
485    // Try the last segment as file stem (for path-style imports)
486    if let Some(last) = parts.last() {
487        let stem = last.trim_start_matches('.');
488        if let Some(entities) = stem_to_entities.get(stem) {
489            return entities.iter().collect();
490        }
491    }
492
493    // Try the whole label as a stem (for simple imports like "React")
494    let simple = import_label
495        .trim_start_matches("./")
496        .trim_start_matches("../");
497    if let Some(entities) = stem_to_entities.get(simple) {
498        return entities.iter().collect();
499    }
500
501    // Barrel export: if the last segment matches a directory, check for "index" file
502    // This handles `import { Foo } from './components'` → resolves to components/index.ts
503    if let Some(entities) = stem_to_entities.get("index") {
504        // Only use index if the import looks like a directory path
505        if import_label.contains('/') || import_label.starts_with('.') {
506            return entities.iter().collect();
507        }
508    }
509
510    Vec::new()
511}
512
513/// Resolve a Go import to target entities.
514///
515/// Go import labels are like `"fmt"`, `"net/http"`, or `"myproject/pkg/utils"`.
516/// The last path segment is the package name.
517/// Also handles dot imports (`import . "pkg"`) which bring all entities into scope.
518fn resolve_go_import<'a>(
519    import_label: &str,
520    stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
521    go_pkg_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
522) -> Vec<&'a (String, String, NodeType)> {
523    // Handle dot import: strip leading "." prefix
524    let label = import_label.trim_start_matches(". ");
525
526    // Extract the last path segment as the package name
527    let pkg_name = label.rsplit('/').next().unwrap_or(label);
528
529    // Try matching against Go package directory names
530    if let Some(entities) = go_pkg_to_entities.get(pkg_name) {
531        return entities.iter().collect();
532    }
533
534    // Fall back to file stem matching
535    if let Some(entities) = stem_to_entities.get(pkg_name) {
536        return entities.iter().collect();
537    }
538
539    Vec::new()
540}
541
542/// Resolve a Rust `use` import to target entities.
543///
544/// Rust import labels are like `"std::collections"`, `"crate::model"`, or `"super::utils"`.
545/// Handles `pub use` re-exports: if import label starts with `pub use`, resolves
546/// transitively through the re-exporting module to the original definition.
547fn resolve_rust_import<'a>(
548    import_label: &str,
549    stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
550) -> Vec<&'a (String, String, NodeType)> {
551    // Strip `pub use ` prefix if present (re-export)
552    let label = import_label
553        .strip_prefix("pub use ")
554        .unwrap_or(import_label);
555    let segments: Vec<&str> = label.split("::").collect();
556
557    // Try the last segment as a module/file stem
558    if let Some(last) = segments.last()
559        && let Some(entities) = stem_to_entities.get(*last)
560    {
561        return entities.iter().collect();
562    }
563
564    // Try the second-to-last segment (for `crate::module::Type` patterns)
565    if segments.len() >= 2 {
566        let module = segments[segments.len() - 2];
567        if let Some(entities) = stem_to_entities.get(module) {
568            // Filter to only return entities whose label matches the last segment
569            let last = segments.last().unwrap();
570            let filtered: Vec<_> = entities
571                .iter()
572                .filter(|(label, _, _)| label == last)
573                .collect();
574            if !filtered.is_empty() {
575                return filtered;
576            }
577            // If no exact match, return all entities from the module
578            return entities.iter().collect();
579        }
580    }
581
582    Vec::new()
583}
584
585/// Resolve a dot-separated import (Java, C#, Kotlin, Scala, Swift).
586///
587/// Import labels like `"java.util.List"` or `"System.Collections.Generic"`.
588/// Tries the last segment as a type name, then second-to-last as module stem.
589fn resolve_dot_import<'a>(
590    import_label: &str,
591    stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
592) -> Vec<&'a (String, String, NodeType)> {
593    let segments: Vec<&str> = import_label.split('.').collect();
594
595    // Try the last segment as a type/entity name matching a file stem
596    if let Some(last) = segments.last()
597        && let Some(entities) = stem_to_entities.get(*last)
598    {
599        return entities.iter().collect();
600    }
601
602    // Try second-to-last as module, filter to last segment
603    if segments.len() >= 2 {
604        let module = segments[segments.len() - 2];
605        if let Some(entities) = stem_to_entities.get(module) {
606            let last = segments.last().unwrap();
607            let filtered: Vec<_> = entities
608                .iter()
609                .filter(|(label, _, _)| label == last)
610                .collect();
611            if !filtered.is_empty() {
612                return filtered;
613            }
614            return entities.iter().collect();
615        }
616    }
617
618    Vec::new()
619}
620
621/// Resolve a C/C++ `#include` to target entities.
622///
623/// Include labels are like `"stdio.h"` or `"myheader.h"`.
624/// Strips the extension and matches the stem to file entities.
625fn resolve_c_include<'a>(
626    import_label: &str,
627    stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
628) -> Vec<&'a (String, String, NodeType)> {
629    // Strip angle brackets and quotes
630    let label = import_label
631        .trim_start_matches('<')
632        .trim_end_matches('>')
633        .trim_start_matches('"')
634        .trim_end_matches('"');
635
636    // Strip extension (.h, .hpp, etc.)
637    let stem = std::path::Path::new(label)
638        .file_stem()
639        .and_then(|s| s.to_str())
640        .unwrap_or(label);
641
642    if let Some(entities) = stem_to_entities.get(stem) {
643        return entities.iter().collect();
644    }
645
646    Vec::new()
647}
648
649/// Resolve a PHP backslash-separated import.
650///
651/// Labels like `"App\Models\User"` → try "User" as stem, then "Models".
652fn resolve_backslash_import<'a>(
653    import_label: &str,
654    stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
655) -> Vec<&'a (String, String, NodeType)> {
656    let segments: Vec<&str> = import_label.split('\\').collect();
657
658    // Try the last segment as entity name
659    if let Some(last) = segments.last()
660        && let Some(entities) = stem_to_entities.get(*last)
661    {
662        return entities.iter().collect();
663    }
664
665    // Try second-to-last as module
666    if segments.len() >= 2 {
667        let module = segments[segments.len() - 2];
668        if let Some(entities) = stem_to_entities.get(module) {
669            return entities.iter().collect();
670        }
671    }
672
673    Vec::new()
674}
675
676/// Resolve a Dart import.
677///
678/// Labels like `"import 'package:foo/bar.dart'"` or `"import 'bar.dart'"`.
679/// Extracts the file stem from the path.
680fn resolve_dart_import<'a>(
681    import_label: &str,
682    stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
683) -> Vec<&'a (String, String, NodeType)> {
684    // Strip "import " prefix and quotes
685    let label = import_label
686        .strip_prefix("import ")
687        .unwrap_or(import_label)
688        .trim_matches('\'')
689        .trim_matches('"');
690
691    // Strip "package:" prefix and extract last path segment
692    let path_part = label.strip_prefix("package:").unwrap_or(label);
693    let last_segment = path_part.rsplit('/').next().unwrap_or(path_part);
694
695    // Strip .dart extension
696    let stem = last_segment.strip_suffix(".dart").unwrap_or(last_segment);
697
698    if let Some(entities) = stem_to_entities.get(stem) {
699        return entities.iter().collect();
700    }
701
702    Vec::new()
703}
704
705// ---------------------------------------------------------------------------
706// Tests
707// ---------------------------------------------------------------------------
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712    use graphify_core::model::{GraphEdge, GraphNode};
713
714    #[test]
715    fn dispatch_table_covers_all_languages() {
716        let map = dispatch_map();
717        assert_eq!(map.get(".py"), Some(&"python"));
718        assert_eq!(map.get(".rs"), Some(&"rust"));
719        assert_eq!(map.get(".go"), Some(&"go"));
720        assert_eq!(map.get(".tsx"), Some(&"typescript"));
721        assert_eq!(map.get(".jl"), Some(&"julia"));
722        assert_eq!(map.get(".mm"), Some(&"objc"));
723    }
724
725    // -----------------------------------------------------------------------
726    // Helpers for cross-file import resolution tests
727    // -----------------------------------------------------------------------
728
729    fn make_test_node(id: &str, label: &str, source_file: &str, node_type: NodeType) -> GraphNode {
730        GraphNode {
731            id: id.to_string(),
732            label: label.to_string(),
733            source_file: source_file.to_string(),
734            source_location: None,
735            node_type,
736            community: None,
737            extra: Default::default(),
738        }
739    }
740
741    fn make_test_edge(source: &str, target: &str, relation: &str, source_file: &str) -> GraphEdge {
742        GraphEdge {
743            source: source.to_string(),
744            target: target.to_string(),
745            relation: relation.to_string(),
746            confidence: Confidence::Extracted,
747            confidence_score: 1.0,
748            source_file: source_file.to_string(),
749            source_location: None,
750            weight: 1.0,
751            extra: Default::default(),
752        }
753    }
754
755    // -----------------------------------------------------------------------
756    // JS/TS cross-file resolution
757    // -----------------------------------------------------------------------
758
759    #[test]
760    fn jsts_cross_file_creates_uses_edges() {
761        // File: src/app.ts defines AppController, imports from "utils"
762        // File: src/utils.ts defines parseDate, formatDate
763        let mut result = ExtractionResult {
764            nodes: vec![
765                make_test_node("file_app", "app", "src/app.ts", NodeType::File),
766                make_test_node("app_ctrl", "AppController", "src/app.ts", NodeType::Class),
767                make_test_node(
768                    "import_utils",
769                    "utils/parseDate",
770                    "src/app.ts",
771                    NodeType::Module,
772                ),
773                make_test_node("file_utils", "utils", "src/utils.ts", NodeType::File),
774                make_test_node(
775                    "parse_date",
776                    "parseDate",
777                    "src/utils.ts",
778                    NodeType::Function,
779                ),
780                make_test_node(
781                    "format_date",
782                    "formatDate",
783                    "src/utils.ts",
784                    NodeType::Function,
785                ),
786            ],
787            edges: vec![
788                make_test_edge("file_app", "app_ctrl", "defines", "src/app.ts"),
789                make_test_edge("file_app", "import_utils", "imports", "src/app.ts"),
790                make_test_edge("file_utils", "parse_date", "defines", "src/utils.ts"),
791                make_test_edge("file_utils", "format_date", "defines", "src/utils.ts"),
792            ],
793            hyperedges: vec![],
794        };
795
796        resolve_cross_file_imports(&mut result);
797
798        let uses_edges: Vec<_> = result
799            .edges
800            .iter()
801            .filter(|e| e.relation == "uses")
802            .collect();
803
804        // AppController should use both parseDate and formatDate
805        assert_eq!(
806            uses_edges.len(),
807            2,
808            "expected 2 uses edges, got {}",
809            uses_edges.len()
810        );
811        assert!(
812            uses_edges
813                .iter()
814                .any(|e| e.source == "app_ctrl" && e.target == "parse_date")
815        );
816        assert!(
817            uses_edges
818                .iter()
819                .any(|e| e.source == "app_ctrl" && e.target == "format_date")
820        );
821
822        // All uses edges should be Inferred with weight 0.8
823        for edge in &uses_edges {
824            assert_eq!(edge.confidence, Confidence::Inferred);
825            assert!((edge.weight - 0.8).abs() < f64::EPSILON);
826            assert!((edge.confidence_score - 0.8).abs() < f64::EPSILON);
827        }
828    }
829
830    // -----------------------------------------------------------------------
831    // Go cross-file resolution
832    // -----------------------------------------------------------------------
833
834    #[test]
835    fn go_cross_file_creates_uses_edges() {
836        // File: cmd/main.go defines Server, imports "myproject/pkg/utils"
837        // File: pkg/utils/helpers.go defines ParseConfig, Validate
838        let mut result = ExtractionResult {
839            nodes: vec![
840                make_test_node("file_main", "main", "cmd/main.go", NodeType::File),
841                make_test_node("server", "Server", "cmd/main.go", NodeType::Struct),
842                make_test_node(
843                    "import_utils",
844                    "myproject/pkg/utils",
845                    "cmd/main.go",
846                    NodeType::Package,
847                ),
848                make_test_node(
849                    "file_helpers",
850                    "helpers",
851                    "pkg/utils/helpers.go",
852                    NodeType::File,
853                ),
854                make_test_node(
855                    "parse_config",
856                    "ParseConfig",
857                    "pkg/utils/helpers.go",
858                    NodeType::Function,
859                ),
860                make_test_node(
861                    "validate",
862                    "Validate",
863                    "pkg/utils/helpers.go",
864                    NodeType::Function,
865                ),
866            ],
867            edges: vec![
868                make_test_edge("file_main", "server", "defines", "cmd/main.go"),
869                make_test_edge("file_main", "import_utils", "imports", "cmd/main.go"),
870                make_test_edge(
871                    "file_helpers",
872                    "parse_config",
873                    "defines",
874                    "pkg/utils/helpers.go",
875                ),
876                make_test_edge(
877                    "file_helpers",
878                    "validate",
879                    "defines",
880                    "pkg/utils/helpers.go",
881                ),
882            ],
883            hyperedges: vec![],
884        };
885
886        resolve_cross_file_imports(&mut result);
887
888        let uses_edges: Vec<_> = result
889            .edges
890            .iter()
891            .filter(|e| e.relation == "uses")
892            .collect();
893
894        // Server should use both ParseConfig and Validate
895        assert_eq!(
896            uses_edges.len(),
897            2,
898            "expected 2 uses edges, got {}",
899            uses_edges.len()
900        );
901        assert!(
902            uses_edges
903                .iter()
904                .any(|e| e.source == "server" && e.target == "parse_config")
905        );
906        assert!(
907            uses_edges
908                .iter()
909                .any(|e| e.source == "server" && e.target == "validate")
910        );
911
912        for edge in &uses_edges {
913            assert_eq!(edge.confidence, Confidence::Inferred);
914        }
915    }
916
917    // -----------------------------------------------------------------------
918    // Rust cross-file resolution
919    // -----------------------------------------------------------------------
920
921    #[test]
922    fn rust_cross_file_creates_uses_edges() {
923        // File: src/main.rs defines App, imports "crate::model"
924        // File: src/model.rs defines Config, Database
925        let mut result = ExtractionResult {
926            nodes: vec![
927                make_test_node("file_main", "main", "src/main.rs", NodeType::File),
928                make_test_node("app", "App", "src/main.rs", NodeType::Struct),
929                make_test_node(
930                    "import_model",
931                    "crate::model",
932                    "src/main.rs",
933                    NodeType::Module,
934                ),
935                make_test_node("file_model", "model", "src/model.rs", NodeType::File),
936                make_test_node("config", "Config", "src/model.rs", NodeType::Struct),
937                make_test_node("database", "Database", "src/model.rs", NodeType::Struct),
938            ],
939            edges: vec![
940                make_test_edge("file_main", "app", "defines", "src/main.rs"),
941                make_test_edge("file_main", "import_model", "imports", "src/main.rs"),
942                make_test_edge("file_model", "config", "defines", "src/model.rs"),
943                make_test_edge("file_model", "database", "defines", "src/model.rs"),
944            ],
945            hyperedges: vec![],
946        };
947
948        resolve_cross_file_imports(&mut result);
949
950        let uses_edges: Vec<_> = result
951            .edges
952            .iter()
953            .filter(|e| e.relation == "uses")
954            .collect();
955
956        // App should use both Config and Database
957        assert_eq!(
958            uses_edges.len(),
959            2,
960            "expected 2 uses edges, got {}",
961            uses_edges.len()
962        );
963        assert!(
964            uses_edges
965                .iter()
966                .any(|e| e.source == "app" && e.target == "config")
967        );
968        assert!(
969            uses_edges
970                .iter()
971                .any(|e| e.source == "app" && e.target == "database")
972        );
973
974        for edge in &uses_edges {
975            assert_eq!(edge.confidence, Confidence::Inferred);
976            assert!((edge.weight - 0.8).abs() < f64::EPSILON);
977        }
978    }
979
980    #[test]
981    fn rust_cross_file_resolves_specific_type() {
982        // `use crate::model::Config` should prefer Config over all entities in model
983        let mut result = ExtractionResult {
984            nodes: vec![
985                make_test_node("file_main", "main", "src/main.rs", NodeType::File),
986                make_test_node("app", "App", "src/main.rs", NodeType::Struct),
987                make_test_node(
988                    "import_config",
989                    "crate::model::Config",
990                    "src/main.rs",
991                    NodeType::Module,
992                ),
993                make_test_node("file_model", "model", "src/model.rs", NodeType::File),
994                make_test_node("config", "Config", "src/model.rs", NodeType::Struct),
995                make_test_node("database", "Database", "src/model.rs", NodeType::Struct),
996            ],
997            edges: vec![
998                make_test_edge("file_main", "app", "defines", "src/main.rs"),
999                make_test_edge("file_main", "import_config", "imports", "src/main.rs"),
1000                make_test_edge("file_model", "config", "defines", "src/model.rs"),
1001                make_test_edge("file_model", "database", "defines", "src/model.rs"),
1002            ],
1003            hyperedges: vec![],
1004        };
1005
1006        resolve_cross_file_imports(&mut result);
1007
1008        let uses_edges: Vec<_> = result
1009            .edges
1010            .iter()
1011            .filter(|e| e.relation == "uses")
1012            .collect();
1013
1014        // Should only create edge to Config, not Database
1015        assert_eq!(
1016            uses_edges.len(),
1017            1,
1018            "expected 1 uses edge, got {}",
1019            uses_edges.len()
1020        );
1021        assert_eq!(uses_edges[0].source, "app");
1022        assert_eq!(uses_edges[0].target, "config");
1023    }
1024
1025    #[test]
1026    fn cross_file_no_duplicate_edges() {
1027        // Two imports from the same module shouldn't create duplicate uses edges
1028        let mut result = ExtractionResult {
1029            nodes: vec![
1030                make_test_node("file_app", "app", "src/app.ts", NodeType::File),
1031                make_test_node("ctrl", "Controller", "src/app.ts", NodeType::Class),
1032                make_test_node("import1", "utils/foo", "src/app.ts", NodeType::Module),
1033                make_test_node("import2", "utils/bar", "src/app.ts", NodeType::Module),
1034                make_test_node("file_utils", "utils", "src/utils.ts", NodeType::File),
1035                make_test_node("helper", "Helper", "src/utils.ts", NodeType::Class),
1036            ],
1037            edges: vec![
1038                make_test_edge("file_app", "ctrl", "defines", "src/app.ts"),
1039                make_test_edge("file_app", "import1", "imports", "src/app.ts"),
1040                make_test_edge("file_app", "import2", "imports", "src/app.ts"),
1041                make_test_edge("file_utils", "helper", "defines", "src/utils.ts"),
1042            ],
1043            hyperedges: vec![],
1044        };
1045
1046        resolve_cross_file_imports(&mut result);
1047
1048        let uses_edges: Vec<_> = result
1049            .edges
1050            .iter()
1051            .filter(|e| e.relation == "uses")
1052            .collect();
1053
1054        // Only one edge Controller → Helper even though there are two imports from utils
1055        assert_eq!(
1056            uses_edges.len(),
1057            1,
1058            "expected 1 uses edge (no dups), got {}",
1059            uses_edges.len()
1060        );
1061    }
1062
1063    #[test]
1064    fn cross_file_unresolved_import_creates_no_edges() {
1065        // Import from external module (not in our files) should create no uses edges
1066        let mut result = ExtractionResult {
1067            nodes: vec![
1068                make_test_node("file_main", "main", "src/main.rs", NodeType::File),
1069                make_test_node("app", "App", "src/main.rs", NodeType::Struct),
1070                make_test_node(
1071                    "import_serde",
1072                    "serde::Deserialize",
1073                    "src/main.rs",
1074                    NodeType::Module,
1075                ),
1076            ],
1077            edges: vec![
1078                make_test_edge("file_main", "app", "defines", "src/main.rs"),
1079                make_test_edge("file_main", "import_serde", "imports", "src/main.rs"),
1080            ],
1081            hyperedges: vec![],
1082        };
1083
1084        resolve_cross_file_imports(&mut result);
1085
1086        let uses_edges: Vec<_> = result
1087            .edges
1088            .iter()
1089            .filter(|e| e.relation == "uses")
1090            .collect();
1091
1092        assert!(
1093            uses_edges.is_empty(),
1094            "external imports should not create uses edges"
1095        );
1096    }
1097
1098    #[test]
1099    fn python_resolver_not_broken_by_cross_file() {
1100        // Ensure the Python resolver still works independently
1101        let mut result = ExtractionResult {
1102            nodes: vec![
1103                make_test_node("file_a", "module_a", "src/a.py", NodeType::File),
1104                make_test_node("my_class", "MyClass", "src/a.py", NodeType::Class),
1105            ],
1106            edges: vec![make_test_edge("file_a", "MyClass", "imports", "src/a.py")],
1107            hyperedges: vec![],
1108        };
1109
1110        resolve_python_imports(&mut result);
1111
1112        // The import edge target should resolve to the node ID "my_class"
1113        assert_eq!(result.edges[0].target, "my_class");
1114    }
1115
1116    // ===== Java cross-file resolution =====
1117
1118    #[test]
1119    fn java_cross_file_creates_uses_edges() {
1120        let mut result = ExtractionResult {
1121            nodes: vec![
1122                make_test_node("file_app", "App", "src/App.java", NodeType::File),
1123                make_test_node("app_class", "App", "src/App.java", NodeType::Class),
1124                make_test_node(
1125                    "import_util",
1126                    "com.example.Util",
1127                    "src/App.java",
1128                    NodeType::Module,
1129                ),
1130                make_test_node("file_util", "Util", "src/Util.java", NodeType::File),
1131                make_test_node("util_class", "Util", "src/Util.java", NodeType::Class),
1132            ],
1133            edges: vec![
1134                make_test_edge("file_app", "app_class", "defines", "src/App.java"),
1135                make_test_edge("file_app", "import_util", "imports", "src/App.java"),
1136                make_test_edge("file_util", "util_class", "defines", "src/Util.java"),
1137            ],
1138            hyperedges: vec![],
1139        };
1140
1141        resolve_cross_file_imports(&mut result);
1142
1143        let uses_edges: Vec<_> = result
1144            .edges
1145            .iter()
1146            .filter(|e| e.relation == "uses")
1147            .collect();
1148        assert!(
1149            !uses_edges.is_empty(),
1150            "Java cross-file should create uses edges"
1151        );
1152        assert!(
1153            uses_edges
1154                .iter()
1155                .any(|e| e.source == "app_class" && e.target == "util_class")
1156        );
1157    }
1158
1159    // ===== C/C++ cross-file resolution =====
1160
1161    #[test]
1162    fn c_include_creates_uses_edges() {
1163        let mut result = ExtractionResult {
1164            nodes: vec![
1165                make_test_node("file_main", "main", "src/main.c", NodeType::File),
1166                make_test_node("main_fn", "main", "src/main.c", NodeType::Function),
1167                make_test_node("import_utils", "utils.h", "src/main.c", NodeType::Module),
1168                make_test_node("file_utils", "utils", "src/utils.c", NodeType::File),
1169                make_test_node("helper_fn", "helper", "src/utils.c", NodeType::Function),
1170            ],
1171            edges: vec![
1172                make_test_edge("file_main", "main_fn", "defines", "src/main.c"),
1173                make_test_edge("file_main", "import_utils", "imports", "src/main.c"),
1174                make_test_edge("file_utils", "helper_fn", "defines", "src/utils.c"),
1175            ],
1176            hyperedges: vec![],
1177        };
1178
1179        resolve_cross_file_imports(&mut result);
1180
1181        let uses_edges: Vec<_> = result
1182            .edges
1183            .iter()
1184            .filter(|e| e.relation == "uses")
1185            .collect();
1186        assert!(!uses_edges.is_empty(), "C include should create uses edges");
1187        assert!(
1188            uses_edges
1189                .iter()
1190                .any(|e| e.source == "main_fn" && e.target == "helper_fn")
1191        );
1192    }
1193
1194    // ===== C# cross-file resolution =====
1195
1196    #[test]
1197    fn csharp_using_creates_uses_edges() {
1198        let mut result = ExtractionResult {
1199            nodes: vec![
1200                make_test_node("file_prog", "Program", "src/Program.cs", NodeType::File),
1201                make_test_node("prog_class", "Program", "src/Program.cs", NodeType::Class),
1202                make_test_node(
1203                    "import_svc",
1204                    "MyApp.Services.UserService",
1205                    "src/Program.cs",
1206                    NodeType::Module,
1207                ),
1208                make_test_node(
1209                    "file_svc",
1210                    "UserService",
1211                    "src/UserService.cs",
1212                    NodeType::File,
1213                ),
1214                make_test_node(
1215                    "svc_class",
1216                    "UserService",
1217                    "src/UserService.cs",
1218                    NodeType::Class,
1219                ),
1220            ],
1221            edges: vec![
1222                make_test_edge("file_prog", "prog_class", "defines", "src/Program.cs"),
1223                make_test_edge("file_prog", "import_svc", "imports", "src/Program.cs"),
1224                make_test_edge("file_svc", "svc_class", "defines", "src/UserService.cs"),
1225            ],
1226            hyperedges: vec![],
1227        };
1228
1229        resolve_cross_file_imports(&mut result);
1230
1231        let uses_edges: Vec<_> = result
1232            .edges
1233            .iter()
1234            .filter(|e| e.relation == "uses")
1235            .collect();
1236        assert!(!uses_edges.is_empty(), "C# using should create uses edges");
1237        assert!(
1238            uses_edges
1239                .iter()
1240                .any(|e| e.source == "prog_class" && e.target == "svc_class")
1241        );
1242    }
1243
1244    // ===== PHP cross-file resolution =====
1245
1246    #[test]
1247    fn php_use_creates_uses_edges() {
1248        let mut result = ExtractionResult {
1249            nodes: vec![
1250                make_test_node(
1251                    "file_ctrl",
1252                    "Controller",
1253                    "src/Controller.php",
1254                    NodeType::File,
1255                ),
1256                make_test_node(
1257                    "ctrl_class",
1258                    "Controller",
1259                    "src/Controller.php",
1260                    NodeType::Class,
1261                ),
1262                make_test_node(
1263                    "import_user",
1264                    r"use App\Models\User",
1265                    "src/Controller.php",
1266                    NodeType::Module,
1267                ),
1268                make_test_node("file_user", "User", "src/User.php", NodeType::File),
1269                make_test_node("user_class", "User", "src/User.php", NodeType::Class),
1270            ],
1271            edges: vec![
1272                make_test_edge("file_ctrl", "ctrl_class", "defines", "src/Controller.php"),
1273                make_test_edge("file_ctrl", "import_user", "imports", "src/Controller.php"),
1274                make_test_edge("file_user", "user_class", "defines", "src/User.php"),
1275            ],
1276            hyperedges: vec![],
1277        };
1278
1279        resolve_cross_file_imports(&mut result);
1280
1281        let uses_edges: Vec<_> = result
1282            .edges
1283            .iter()
1284            .filter(|e| e.relation == "uses")
1285            .collect();
1286        assert!(!uses_edges.is_empty(), "PHP use should create uses edges");
1287        assert!(
1288            uses_edges
1289                .iter()
1290                .any(|e| e.source == "ctrl_class" && e.target == "user_class")
1291        );
1292    }
1293
1294    // ===== Dart cross-file resolution =====
1295
1296    #[test]
1297    fn dart_import_creates_uses_edges() {
1298        let mut result = ExtractionResult {
1299            nodes: vec![
1300                make_test_node("file_main", "main", "lib/main.dart", NodeType::File),
1301                make_test_node("main_fn", "main", "lib/main.dart", NodeType::Function),
1302                make_test_node(
1303                    "import_utils",
1304                    "import 'package:myapp/utils.dart'",
1305                    "lib/main.dart",
1306                    NodeType::Module,
1307                ),
1308                make_test_node("file_utils", "utils", "lib/utils.dart", NodeType::File),
1309                make_test_node("helper_fn", "helper", "lib/utils.dart", NodeType::Function),
1310            ],
1311            edges: vec![
1312                make_test_edge("file_main", "main_fn", "defines", "lib/main.dart"),
1313                make_test_edge("file_main", "import_utils", "imports", "lib/main.dart"),
1314                make_test_edge("file_utils", "helper_fn", "defines", "lib/utils.dart"),
1315            ],
1316            hyperedges: vec![],
1317        };
1318
1319        resolve_cross_file_imports(&mut result);
1320
1321        let uses_edges: Vec<_> = result
1322            .edges
1323            .iter()
1324            .filter(|e| e.relation == "uses")
1325            .collect();
1326        assert!(
1327            !uses_edges.is_empty(),
1328            "Dart import should create uses edges"
1329        );
1330        assert!(
1331            uses_edges
1332                .iter()
1333                .any(|e| e.source == "main_fn" && e.target == "helper_fn")
1334        );
1335    }
1336
1337    // ===== Kotlin cross-file resolution =====
1338
1339    #[test]
1340    fn kotlin_import_creates_uses_edges() {
1341        let mut result = ExtractionResult {
1342            nodes: vec![
1343                make_test_node("file_main", "Main", "src/Main.kt", NodeType::File),
1344                make_test_node("main_fn", "main", "src/Main.kt", NodeType::Function),
1345                make_test_node(
1346                    "import_repo",
1347                    "import com.example.UserRepo",
1348                    "src/Main.kt",
1349                    NodeType::Module,
1350                ),
1351                make_test_node("file_repo", "UserRepo", "src/UserRepo.kt", NodeType::File),
1352                make_test_node("repo_class", "UserRepo", "src/UserRepo.kt", NodeType::Class),
1353            ],
1354            edges: vec![
1355                make_test_edge("file_main", "main_fn", "defines", "src/Main.kt"),
1356                make_test_edge("file_main", "import_repo", "imports", "src/Main.kt"),
1357                make_test_edge("file_repo", "repo_class", "defines", "src/UserRepo.kt"),
1358            ],
1359            hyperedges: vec![],
1360        };
1361
1362        resolve_cross_file_imports(&mut result);
1363
1364        let uses_edges: Vec<_> = result
1365            .edges
1366            .iter()
1367            .filter(|e| e.relation == "uses")
1368            .collect();
1369        assert!(
1370            !uses_edges.is_empty(),
1371            "Kotlin import should create uses edges"
1372        );
1373        assert!(
1374            uses_edges
1375                .iter()
1376                .any(|e| e.source == "main_fn" && e.target == "repo_class")
1377        );
1378    }
1379
1380    // ===== Python star import expansion =====
1381
1382    #[test]
1383    fn python_star_import_expands_to_entities() {
1384        let mut result = ExtractionResult {
1385            nodes: vec![
1386                make_test_node("file_app", "app", "src/app.py", NodeType::File),
1387                make_test_node("app_fn", "run", "src/app.py", NodeType::Function),
1388                make_test_node("import_star", "utils.*", "src/app.py", NodeType::Module),
1389                make_test_node("file_utils", "utils", "src/utils.py", NodeType::File),
1390                make_test_node("helper1", "helper1", "src/utils.py", NodeType::Function),
1391                make_test_node("helper2", "helper2", "src/utils.py", NodeType::Function),
1392            ],
1393            edges: vec![
1394                make_test_edge("file_app", "app_fn", "defines", "src/app.py"),
1395                make_test_edge("file_app", "import_star", "imports", "src/app.py"),
1396                make_test_edge("file_utils", "helper1", "defines", "src/utils.py"),
1397                make_test_edge("file_utils", "helper2", "defines", "src/utils.py"),
1398            ],
1399            hyperedges: vec![],
1400        };
1401
1402        resolve_python_imports(&mut result);
1403
1404        let uses_edges: Vec<_> = result
1405            .edges
1406            .iter()
1407            .filter(|e| e.relation == "uses")
1408            .collect();
1409        assert_eq!(
1410            uses_edges.len(),
1411            2,
1412            "star import should expand to 2 uses edges, got {}",
1413            uses_edges.len()
1414        );
1415    }
1416
1417    // ===== Scala cross-file resolution =====
1418
1419    #[test]
1420    fn scala_cross_file_creates_uses_edges() {
1421        let mut result = ExtractionResult {
1422            nodes: vec![
1423                make_test_node("file_main", "Main", "src/Main.scala", NodeType::File),
1424                make_test_node("main_fn", "main", "src/Main.scala", NodeType::Function),
1425                make_test_node(
1426                    "import_calc",
1427                    "import com.example.Calculator",
1428                    "src/Main.scala",
1429                    NodeType::Module,
1430                ),
1431                make_test_node(
1432                    "file_calc",
1433                    "Calculator",
1434                    "src/Calculator.scala",
1435                    NodeType::File,
1436                ),
1437                make_test_node(
1438                    "calc_class",
1439                    "Calculator",
1440                    "src/Calculator.scala",
1441                    NodeType::Class,
1442                ),
1443            ],
1444            edges: vec![
1445                make_test_edge("file_main", "main_fn", "defines", "src/Main.scala"),
1446                make_test_edge("file_main", "import_calc", "imports", "src/Main.scala"),
1447                make_test_edge("file_calc", "calc_class", "defines", "src/Calculator.scala"),
1448            ],
1449            hyperedges: vec![],
1450        };
1451
1452        resolve_cross_file_imports(&mut result);
1453
1454        let uses_edges: Vec<_> = result
1455            .edges
1456            .iter()
1457            .filter(|e| e.relation == "uses")
1458            .collect();
1459        assert!(
1460            !uses_edges.is_empty(),
1461            "Scala cross-file should create uses edges"
1462        );
1463        assert!(
1464            uses_edges
1465                .iter()
1466                .any(|e| e.source == "main_fn" && e.target == "calc_class")
1467        );
1468    }
1469
1470    // ===== Swift cross-file resolution =====
1471
1472    #[test]
1473    fn swift_cross_file_creates_uses_edges() {
1474        let mut result = ExtractionResult {
1475            nodes: vec![
1476                make_test_node("file_app", "App", "src/App.swift", NodeType::File),
1477                make_test_node("app_fn", "run", "src/App.swift", NodeType::Function),
1478                make_test_node(
1479                    "import_mgr",
1480                    "import UserManager",
1481                    "src/App.swift",
1482                    NodeType::Module,
1483                ),
1484                make_test_node(
1485                    "file_mgr",
1486                    "UserManager",
1487                    "src/UserManager.swift",
1488                    NodeType::File,
1489                ),
1490                make_test_node(
1491                    "mgr_class",
1492                    "UserManager",
1493                    "src/UserManager.swift",
1494                    NodeType::Class,
1495                ),
1496            ],
1497            edges: vec![
1498                make_test_edge("file_app", "app_fn", "defines", "src/App.swift"),
1499                make_test_edge("file_app", "import_mgr", "imports", "src/App.swift"),
1500                make_test_edge("file_mgr", "mgr_class", "defines", "src/UserManager.swift"),
1501            ],
1502            hyperedges: vec![],
1503        };
1504
1505        resolve_cross_file_imports(&mut result);
1506
1507        let uses_edges: Vec<_> = result
1508            .edges
1509            .iter()
1510            .filter(|e| e.relation == "uses")
1511            .collect();
1512        assert!(
1513            !uses_edges.is_empty(),
1514            "Swift cross-file should create uses edges"
1515        );
1516        assert!(
1517            uses_edges
1518                .iter()
1519                .any(|e| e.source == "app_fn" && e.target == "mgr_class")
1520        );
1521    }
1522}