1pub 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
25pub 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
64fn dispatch_map() -> HashMap<&'static str, &'static str> {
66 DISPATCH.iter().copied().collect()
67}
68
69pub 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
76pub 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 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
122pub 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 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 resolve_python_imports(&mut combined);
178
179 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
191fn resolve_python_imports(result: &mut ExtractionResult) {
196 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 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 let mut star_expansions: Vec<GraphEdge> = Vec::new();
228
229 for edge in &mut result.edges {
231 if edge.relation == "imports" {
232 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 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 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
277fn resolve_cross_file_imports(result: &mut ExtractionResult) {
284 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 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 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 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 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 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 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 let local_entities = match source_file_entities.get(source_file) {
420 Some(ids) => ids,
421 None => continue,
422 };
423
424 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
460fn 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 let label = import_label.split(" as ").next().unwrap_or(import_label);
475
476 let parts: Vec<&str> = label.split('/').collect();
477
478 if parts.len() >= 2 {
480 let module_stem = parts[0].trim_start_matches('.');
481 if let Some(entities) = stem_to_entities.get(module_stem) {
482 return entities.iter().collect();
483 }
484 }
485
486 if let Some(last) = parts.last() {
488 let stem = last.trim_start_matches('.');
489 if let Some(entities) = stem_to_entities.get(stem) {
490 return entities.iter().collect();
491 }
492 }
493
494 let simple = label.trim_start_matches("./").trim_start_matches("../");
496 if let Some(entities) = stem_to_entities.get(simple) {
497 return entities.iter().collect();
498 }
499
500 if let Some(entities) = stem_to_entities.get("index")
502 && (label.contains('/') || label.starts_with('.'))
503 {
504 return entities.iter().collect();
505 }
506
507 Vec::new()
508}
509
510fn resolve_go_import<'a>(
516 import_label: &str,
517 stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
518 go_pkg_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
519) -> Vec<&'a (String, String, NodeType)> {
520 let label = import_label
522 .trim_start_matches(". ")
523 .trim_start_matches("_ ");
524 let label = if label.contains('"') {
526 label.split('"').nth(1).unwrap_or(label)
527 } else {
528 label
529 };
530
531 let pkg_name = label.rsplit('/').next().unwrap_or(label);
532
533 if let Some(entities) = go_pkg_to_entities.get(pkg_name) {
534 return entities.iter().collect();
535 }
536
537 if let Some(entities) = stem_to_entities.get(pkg_name) {
538 return entities.iter().collect();
539 }
540
541 Vec::new()
542}
543
544fn resolve_rust_import<'a>(
549 import_label: &str,
550 stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
551) -> Vec<&'a (String, String, NodeType)> {
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 if segments.last() == Some(&"*") && segments.len() >= 2 {
559 let module = segments[segments.len() - 2];
560 if let Some(entities) = stem_to_entities.get(module) {
561 return entities.iter().collect();
562 }
563 }
564
565 if let Some(last) = segments.last()
567 && *last != "*"
568 && let Some(entities) = stem_to_entities.get(*last)
569 {
570 return entities.iter().collect();
571 }
572
573 if segments.len() >= 2 {
575 let module = segments[segments.len() - 2];
576 if let Some(entities) = stem_to_entities.get(module) {
577 let last = segments.last().unwrap();
578 let filtered: Vec<_> = entities.iter().filter(|(lbl, _, _)| lbl == last).collect();
579 if !filtered.is_empty() {
580 return filtered;
581 }
582 return entities.iter().collect();
583 }
584 }
585
586 Vec::new()
587}
588
589fn resolve_dot_import<'a>(
594 import_label: &str,
595 stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
596) -> Vec<&'a (String, String, NodeType)> {
597 let label = import_label.strip_prefix("static ").unwrap_or(import_label);
599 let label = if let Some(idx) = label.find(" = ") {
600 label[idx + 3..].trim()
601 } else {
602 label
603 };
604
605 let segments: Vec<&str> = label.split('.').collect();
606
607 if let Some(last) = segments.last()
609 && let Some(entities) = stem_to_entities.get(*last)
610 {
611 return entities.iter().collect();
612 }
613
614 if segments.len() >= 2 {
616 let module = segments[segments.len() - 2];
617 if let Some(entities) = stem_to_entities.get(module) {
618 let last = segments.last().unwrap();
619 let filtered: Vec<_> = entities.iter().filter(|(lbl, _, _)| lbl == last).collect();
620 if !filtered.is_empty() {
621 return filtered;
622 }
623 return entities.iter().collect();
624 }
625 }
626
627 Vec::new()
628}
629
630fn resolve_c_include<'a>(
635 import_label: &str,
636 stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
637) -> Vec<&'a (String, String, NodeType)> {
638 let label = import_label
640 .trim_start_matches('<')
641 .trim_end_matches('>')
642 .trim_start_matches('"')
643 .trim_end_matches('"');
644
645 let stem = std::path::Path::new(label)
647 .file_stem()
648 .and_then(|s| s.to_str())
649 .unwrap_or(label);
650
651 if let Some(entities) = stem_to_entities.get(stem) {
652 return entities.iter().collect();
653 }
654
655 Vec::new()
656}
657
658fn resolve_backslash_import<'a>(
662 import_label: &str,
663 stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
664) -> Vec<&'a (String, String, NodeType)> {
665 let segments: Vec<&str> = import_label.split('\\').collect();
666
667 if let Some(last) = segments.last()
669 && let Some(entities) = stem_to_entities.get(*last)
670 {
671 return entities.iter().collect();
672 }
673
674 if segments.len() >= 2 {
676 let module = segments[segments.len() - 2];
677 if let Some(entities) = stem_to_entities.get(module) {
678 return entities.iter().collect();
679 }
680 }
681
682 Vec::new()
683}
684
685fn resolve_dart_import<'a>(
690 import_label: &str,
691 stem_to_entities: &'a HashMap<String, Vec<(String, String, NodeType)>>,
692) -> Vec<&'a (String, String, NodeType)> {
693 let mut label = import_label;
695
696 if let Some(stripped) = label.strip_prefix("import ") {
698 label = stripped;
699 } else if let Some(stripped) = label.strip_prefix("export ") {
700 label = stripped;
701 } else if let Some(stripped) = label.strip_prefix("part ") {
702 label = stripped;
703 }
704
705 let path_and_alias = label;
708 let path_part = if let Some(idx) = path_and_alias.find(" as ") {
709 &path_and_alias[..idx]
710 } else {
711 path_and_alias
712 };
713
714 let path_deferred = path_part;
717 let path_no_deferred = if let Some(idx) = path_deferred.find(" deferred") {
718 &path_deferred[..idx]
719 } else {
720 path_deferred
721 };
722
723 let quoted = path_no_deferred.trim();
725 let unquoted = quoted
726 .trim_matches('\'') .trim_matches('"');
728
729 let normalized = if unquoted.contains("../") {
731 let last_segment = unquoted.rsplit('/').next().unwrap_or(unquoted);
734 last_segment.strip_suffix(".dart").unwrap_or(last_segment)
735 } else {
736 let path_part = unquoted.strip_prefix("package:").unwrap_or(unquoted);
738
739 let last_segment = path_part.rsplit('/').next().unwrap_or(path_part);
741
742 last_segment.strip_suffix(".dart").unwrap_or(last_segment)
744 };
745
746 if let Some(entities) = stem_to_entities.get(normalized) {
748 return entities.iter().collect();
749 }
750
751 Vec::new()
752}
753
754#[cfg(test)]
759mod tests {
760 use super::*;
761 use graphify_core::model::{GraphEdge, GraphNode};
762
763 #[test]
764 fn dispatch_table_covers_all_languages() {
765 let map = dispatch_map();
766 assert_eq!(map.get(".py"), Some(&"python"));
767 assert_eq!(map.get(".rs"), Some(&"rust"));
768 assert_eq!(map.get(".go"), Some(&"go"));
769 assert_eq!(map.get(".tsx"), Some(&"typescript"));
770 assert_eq!(map.get(".jl"), Some(&"julia"));
771 assert_eq!(map.get(".mm"), Some(&"objc"));
772 }
773
774 fn make_test_node(id: &str, label: &str, source_file: &str, node_type: NodeType) -> GraphNode {
779 GraphNode {
780 id: id.to_string(),
781 label: label.to_string(),
782 source_file: source_file.to_string(),
783 source_location: None,
784 node_type,
785 community: None,
786 extra: Default::default(),
787 }
788 }
789
790 fn make_test_edge(source: &str, target: &str, relation: &str, source_file: &str) -> GraphEdge {
791 GraphEdge {
792 source: source.to_string(),
793 target: target.to_string(),
794 relation: relation.to_string(),
795 confidence: Confidence::Extracted,
796 confidence_score: 1.0,
797 source_file: source_file.to_string(),
798 source_location: None,
799 weight: 1.0,
800 extra: Default::default(),
801 }
802 }
803
804 #[test]
809 fn jsts_cross_file_creates_uses_edges() {
810 let mut result = ExtractionResult {
813 nodes: vec![
814 make_test_node("file_app", "app", "src/app.ts", NodeType::File),
815 make_test_node("app_ctrl", "AppController", "src/app.ts", NodeType::Class),
816 make_test_node(
817 "import_utils",
818 "utils/parseDate",
819 "src/app.ts",
820 NodeType::Module,
821 ),
822 make_test_node("file_utils", "utils", "src/utils.ts", NodeType::File),
823 make_test_node(
824 "parse_date",
825 "parseDate",
826 "src/utils.ts",
827 NodeType::Function,
828 ),
829 make_test_node(
830 "format_date",
831 "formatDate",
832 "src/utils.ts",
833 NodeType::Function,
834 ),
835 ],
836 edges: vec![
837 make_test_edge("file_app", "app_ctrl", "defines", "src/app.ts"),
838 make_test_edge("file_app", "import_utils", "imports", "src/app.ts"),
839 make_test_edge("file_utils", "parse_date", "defines", "src/utils.ts"),
840 make_test_edge("file_utils", "format_date", "defines", "src/utils.ts"),
841 ],
842 hyperedges: vec![],
843 };
844
845 resolve_cross_file_imports(&mut result);
846
847 let uses_edges: Vec<_> = result
848 .edges
849 .iter()
850 .filter(|e| e.relation == "uses")
851 .collect();
852
853 assert_eq!(
855 uses_edges.len(),
856 2,
857 "expected 2 uses edges, got {}",
858 uses_edges.len()
859 );
860 assert!(
861 uses_edges
862 .iter()
863 .any(|e| e.source == "app_ctrl" && e.target == "parse_date")
864 );
865 assert!(
866 uses_edges
867 .iter()
868 .any(|e| e.source == "app_ctrl" && e.target == "format_date")
869 );
870
871 for edge in &uses_edges {
873 assert_eq!(edge.confidence, Confidence::Inferred);
874 assert!((edge.weight - 0.8).abs() < f64::EPSILON);
875 assert!((edge.confidence_score - 0.8).abs() < f64::EPSILON);
876 }
877 }
878
879 #[test]
884 fn go_cross_file_creates_uses_edges() {
885 let mut result = ExtractionResult {
888 nodes: vec![
889 make_test_node("file_main", "main", "cmd/main.go", NodeType::File),
890 make_test_node("server", "Server", "cmd/main.go", NodeType::Struct),
891 make_test_node(
892 "import_utils",
893 "myproject/pkg/utils",
894 "cmd/main.go",
895 NodeType::Package,
896 ),
897 make_test_node(
898 "file_helpers",
899 "helpers",
900 "pkg/utils/helpers.go",
901 NodeType::File,
902 ),
903 make_test_node(
904 "parse_config",
905 "ParseConfig",
906 "pkg/utils/helpers.go",
907 NodeType::Function,
908 ),
909 make_test_node(
910 "validate",
911 "Validate",
912 "pkg/utils/helpers.go",
913 NodeType::Function,
914 ),
915 ],
916 edges: vec![
917 make_test_edge("file_main", "server", "defines", "cmd/main.go"),
918 make_test_edge("file_main", "import_utils", "imports", "cmd/main.go"),
919 make_test_edge(
920 "file_helpers",
921 "parse_config",
922 "defines",
923 "pkg/utils/helpers.go",
924 ),
925 make_test_edge(
926 "file_helpers",
927 "validate",
928 "defines",
929 "pkg/utils/helpers.go",
930 ),
931 ],
932 hyperedges: vec![],
933 };
934
935 resolve_cross_file_imports(&mut result);
936
937 let uses_edges: Vec<_> = result
938 .edges
939 .iter()
940 .filter(|e| e.relation == "uses")
941 .collect();
942
943 assert_eq!(
945 uses_edges.len(),
946 2,
947 "expected 2 uses edges, got {}",
948 uses_edges.len()
949 );
950 assert!(
951 uses_edges
952 .iter()
953 .any(|e| e.source == "server" && e.target == "parse_config")
954 );
955 assert!(
956 uses_edges
957 .iter()
958 .any(|e| e.source == "server" && e.target == "validate")
959 );
960
961 for edge in &uses_edges {
962 assert_eq!(edge.confidence, Confidence::Inferred);
963 }
964 }
965
966 #[test]
971 fn rust_cross_file_creates_uses_edges() {
972 let mut result = ExtractionResult {
975 nodes: vec![
976 make_test_node("file_main", "main", "src/main.rs", NodeType::File),
977 make_test_node("app", "App", "src/main.rs", NodeType::Struct),
978 make_test_node(
979 "import_model",
980 "crate::model",
981 "src/main.rs",
982 NodeType::Module,
983 ),
984 make_test_node("file_model", "model", "src/model.rs", NodeType::File),
985 make_test_node("config", "Config", "src/model.rs", NodeType::Struct),
986 make_test_node("database", "Database", "src/model.rs", NodeType::Struct),
987 ],
988 edges: vec![
989 make_test_edge("file_main", "app", "defines", "src/main.rs"),
990 make_test_edge("file_main", "import_model", "imports", "src/main.rs"),
991 make_test_edge("file_model", "config", "defines", "src/model.rs"),
992 make_test_edge("file_model", "database", "defines", "src/model.rs"),
993 ],
994 hyperedges: vec![],
995 };
996
997 resolve_cross_file_imports(&mut result);
998
999 let uses_edges: Vec<_> = result
1000 .edges
1001 .iter()
1002 .filter(|e| e.relation == "uses")
1003 .collect();
1004
1005 assert_eq!(
1007 uses_edges.len(),
1008 2,
1009 "expected 2 uses edges, got {}",
1010 uses_edges.len()
1011 );
1012 assert!(
1013 uses_edges
1014 .iter()
1015 .any(|e| e.source == "app" && e.target == "config")
1016 );
1017 assert!(
1018 uses_edges
1019 .iter()
1020 .any(|e| e.source == "app" && e.target == "database")
1021 );
1022
1023 for edge in &uses_edges {
1024 assert_eq!(edge.confidence, Confidence::Inferred);
1025 assert!((edge.weight - 0.8).abs() < f64::EPSILON);
1026 }
1027 }
1028
1029 #[test]
1030 fn rust_cross_file_resolves_specific_type() {
1031 let mut result = ExtractionResult {
1033 nodes: vec![
1034 make_test_node("file_main", "main", "src/main.rs", NodeType::File),
1035 make_test_node("app", "App", "src/main.rs", NodeType::Struct),
1036 make_test_node(
1037 "import_config",
1038 "crate::model::Config",
1039 "src/main.rs",
1040 NodeType::Module,
1041 ),
1042 make_test_node("file_model", "model", "src/model.rs", NodeType::File),
1043 make_test_node("config", "Config", "src/model.rs", NodeType::Struct),
1044 make_test_node("database", "Database", "src/model.rs", NodeType::Struct),
1045 ],
1046 edges: vec![
1047 make_test_edge("file_main", "app", "defines", "src/main.rs"),
1048 make_test_edge("file_main", "import_config", "imports", "src/main.rs"),
1049 make_test_edge("file_model", "config", "defines", "src/model.rs"),
1050 make_test_edge("file_model", "database", "defines", "src/model.rs"),
1051 ],
1052 hyperedges: vec![],
1053 };
1054
1055 resolve_cross_file_imports(&mut result);
1056
1057 let uses_edges: Vec<_> = result
1058 .edges
1059 .iter()
1060 .filter(|e| e.relation == "uses")
1061 .collect();
1062
1063 assert_eq!(
1065 uses_edges.len(),
1066 1,
1067 "expected 1 uses edge, got {}",
1068 uses_edges.len()
1069 );
1070 assert_eq!(uses_edges[0].source, "app");
1071 assert_eq!(uses_edges[0].target, "config");
1072 }
1073
1074 #[test]
1075 fn cross_file_no_duplicate_edges() {
1076 let mut result = ExtractionResult {
1078 nodes: vec![
1079 make_test_node("file_app", "app", "src/app.ts", NodeType::File),
1080 make_test_node("ctrl", "Controller", "src/app.ts", NodeType::Class),
1081 make_test_node("import1", "utils/foo", "src/app.ts", NodeType::Module),
1082 make_test_node("import2", "utils/bar", "src/app.ts", NodeType::Module),
1083 make_test_node("file_utils", "utils", "src/utils.ts", NodeType::File),
1084 make_test_node("helper", "Helper", "src/utils.ts", NodeType::Class),
1085 ],
1086 edges: vec![
1087 make_test_edge("file_app", "ctrl", "defines", "src/app.ts"),
1088 make_test_edge("file_app", "import1", "imports", "src/app.ts"),
1089 make_test_edge("file_app", "import2", "imports", "src/app.ts"),
1090 make_test_edge("file_utils", "helper", "defines", "src/utils.ts"),
1091 ],
1092 hyperedges: vec![],
1093 };
1094
1095 resolve_cross_file_imports(&mut result);
1096
1097 let uses_edges: Vec<_> = result
1098 .edges
1099 .iter()
1100 .filter(|e| e.relation == "uses")
1101 .collect();
1102
1103 assert_eq!(
1105 uses_edges.len(),
1106 1,
1107 "expected 1 uses edge (no dups), got {}",
1108 uses_edges.len()
1109 );
1110 }
1111
1112 #[test]
1113 fn cross_file_unresolved_import_creates_no_edges() {
1114 let mut result = ExtractionResult {
1116 nodes: vec![
1117 make_test_node("file_main", "main", "src/main.rs", NodeType::File),
1118 make_test_node("app", "App", "src/main.rs", NodeType::Struct),
1119 make_test_node(
1120 "import_serde",
1121 "serde::Deserialize",
1122 "src/main.rs",
1123 NodeType::Module,
1124 ),
1125 ],
1126 edges: vec![
1127 make_test_edge("file_main", "app", "defines", "src/main.rs"),
1128 make_test_edge("file_main", "import_serde", "imports", "src/main.rs"),
1129 ],
1130 hyperedges: vec![],
1131 };
1132
1133 resolve_cross_file_imports(&mut result);
1134
1135 let uses_edges: Vec<_> = result
1136 .edges
1137 .iter()
1138 .filter(|e| e.relation == "uses")
1139 .collect();
1140
1141 assert!(
1142 uses_edges.is_empty(),
1143 "external imports should not create uses edges"
1144 );
1145 }
1146
1147 #[test]
1148 fn python_resolver_not_broken_by_cross_file() {
1149 let mut result = ExtractionResult {
1151 nodes: vec![
1152 make_test_node("file_a", "module_a", "src/a.py", NodeType::File),
1153 make_test_node("my_class", "MyClass", "src/a.py", NodeType::Class),
1154 ],
1155 edges: vec![make_test_edge("file_a", "MyClass", "imports", "src/a.py")],
1156 hyperedges: vec![],
1157 };
1158
1159 resolve_python_imports(&mut result);
1160
1161 assert_eq!(result.edges[0].target, "my_class");
1163 }
1164
1165 #[test]
1168 fn java_cross_file_creates_uses_edges() {
1169 let mut result = ExtractionResult {
1170 nodes: vec![
1171 make_test_node("file_app", "App", "src/App.java", NodeType::File),
1172 make_test_node("app_class", "App", "src/App.java", NodeType::Class),
1173 make_test_node(
1174 "import_util",
1175 "com.example.Util",
1176 "src/App.java",
1177 NodeType::Module,
1178 ),
1179 make_test_node("file_util", "Util", "src/Util.java", NodeType::File),
1180 make_test_node("util_class", "Util", "src/Util.java", NodeType::Class),
1181 ],
1182 edges: vec![
1183 make_test_edge("file_app", "app_class", "defines", "src/App.java"),
1184 make_test_edge("file_app", "import_util", "imports", "src/App.java"),
1185 make_test_edge("file_util", "util_class", "defines", "src/Util.java"),
1186 ],
1187 hyperedges: vec![],
1188 };
1189
1190 resolve_cross_file_imports(&mut result);
1191
1192 let uses_edges: Vec<_> = result
1193 .edges
1194 .iter()
1195 .filter(|e| e.relation == "uses")
1196 .collect();
1197 assert!(
1198 !uses_edges.is_empty(),
1199 "Java cross-file should create uses edges"
1200 );
1201 assert!(
1202 uses_edges
1203 .iter()
1204 .any(|e| e.source == "app_class" && e.target == "util_class")
1205 );
1206 }
1207
1208 #[test]
1211 fn c_include_creates_uses_edges() {
1212 let mut result = ExtractionResult {
1213 nodes: vec![
1214 make_test_node("file_main", "main", "src/main.c", NodeType::File),
1215 make_test_node("main_fn", "main", "src/main.c", NodeType::Function),
1216 make_test_node("import_utils", "utils.h", "src/main.c", NodeType::Module),
1217 make_test_node("file_utils", "utils", "src/utils.c", NodeType::File),
1218 make_test_node("helper_fn", "helper", "src/utils.c", NodeType::Function),
1219 ],
1220 edges: vec![
1221 make_test_edge("file_main", "main_fn", "defines", "src/main.c"),
1222 make_test_edge("file_main", "import_utils", "imports", "src/main.c"),
1223 make_test_edge("file_utils", "helper_fn", "defines", "src/utils.c"),
1224 ],
1225 hyperedges: vec![],
1226 };
1227
1228 resolve_cross_file_imports(&mut result);
1229
1230 let uses_edges: Vec<_> = result
1231 .edges
1232 .iter()
1233 .filter(|e| e.relation == "uses")
1234 .collect();
1235 assert!(!uses_edges.is_empty(), "C include should create uses edges");
1236 assert!(
1237 uses_edges
1238 .iter()
1239 .any(|e| e.source == "main_fn" && e.target == "helper_fn")
1240 );
1241 }
1242
1243 #[test]
1246 fn csharp_using_creates_uses_edges() {
1247 let mut result = ExtractionResult {
1248 nodes: vec![
1249 make_test_node("file_prog", "Program", "src/Program.cs", NodeType::File),
1250 make_test_node("prog_class", "Program", "src/Program.cs", NodeType::Class),
1251 make_test_node(
1252 "import_svc",
1253 "MyApp.Services.UserService",
1254 "src/Program.cs",
1255 NodeType::Module,
1256 ),
1257 make_test_node(
1258 "file_svc",
1259 "UserService",
1260 "src/UserService.cs",
1261 NodeType::File,
1262 ),
1263 make_test_node(
1264 "svc_class",
1265 "UserService",
1266 "src/UserService.cs",
1267 NodeType::Class,
1268 ),
1269 ],
1270 edges: vec![
1271 make_test_edge("file_prog", "prog_class", "defines", "src/Program.cs"),
1272 make_test_edge("file_prog", "import_svc", "imports", "src/Program.cs"),
1273 make_test_edge("file_svc", "svc_class", "defines", "src/UserService.cs"),
1274 ],
1275 hyperedges: vec![],
1276 };
1277
1278 resolve_cross_file_imports(&mut result);
1279
1280 let uses_edges: Vec<_> = result
1281 .edges
1282 .iter()
1283 .filter(|e| e.relation == "uses")
1284 .collect();
1285 assert!(!uses_edges.is_empty(), "C# using should create uses edges");
1286 assert!(
1287 uses_edges
1288 .iter()
1289 .any(|e| e.source == "prog_class" && e.target == "svc_class")
1290 );
1291 }
1292
1293 #[test]
1296 fn php_use_creates_uses_edges() {
1297 let mut result = ExtractionResult {
1298 nodes: vec![
1299 make_test_node(
1300 "file_ctrl",
1301 "Controller",
1302 "src/Controller.php",
1303 NodeType::File,
1304 ),
1305 make_test_node(
1306 "ctrl_class",
1307 "Controller",
1308 "src/Controller.php",
1309 NodeType::Class,
1310 ),
1311 make_test_node(
1312 "import_user",
1313 r"use App\Models\User",
1314 "src/Controller.php",
1315 NodeType::Module,
1316 ),
1317 make_test_node("file_user", "User", "src/User.php", NodeType::File),
1318 make_test_node("user_class", "User", "src/User.php", NodeType::Class),
1319 ],
1320 edges: vec![
1321 make_test_edge("file_ctrl", "ctrl_class", "defines", "src/Controller.php"),
1322 make_test_edge("file_ctrl", "import_user", "imports", "src/Controller.php"),
1323 make_test_edge("file_user", "user_class", "defines", "src/User.php"),
1324 ],
1325 hyperedges: vec![],
1326 };
1327
1328 resolve_cross_file_imports(&mut result);
1329
1330 let uses_edges: Vec<_> = result
1331 .edges
1332 .iter()
1333 .filter(|e| e.relation == "uses")
1334 .collect();
1335 assert!(!uses_edges.is_empty(), "PHP use should create uses edges");
1336 assert!(
1337 uses_edges
1338 .iter()
1339 .any(|e| e.source == "ctrl_class" && e.target == "user_class")
1340 );
1341 }
1342
1343 #[test]
1346 fn dart_import_creates_uses_edges() {
1347 let mut result = ExtractionResult {
1348 nodes: vec![
1349 make_test_node("file_main", "main", "lib/main.dart", NodeType::File),
1350 make_test_node("main_fn", "main", "lib/main.dart", NodeType::Function),
1351 make_test_node(
1352 "import_utils",
1353 "import 'package:myapp/utils.dart'",
1354 "lib/main.dart",
1355 NodeType::Module,
1356 ),
1357 make_test_node("file_utils", "utils", "lib/utils.dart", NodeType::File),
1358 make_test_node("helper_fn", "helper", "lib/utils.dart", NodeType::Function),
1359 ],
1360 edges: vec![
1361 make_test_edge("file_main", "main_fn", "defines", "lib/main.dart"),
1362 make_test_edge("file_main", "import_utils", "imports", "lib/main.dart"),
1363 make_test_edge("file_utils", "helper_fn", "defines", "lib/utils.dart"),
1364 ],
1365 hyperedges: vec![],
1366 };
1367
1368 resolve_cross_file_imports(&mut result);
1369
1370 let uses_edges: Vec<_> = result
1371 .edges
1372 .iter()
1373 .filter(|e| e.relation == "uses")
1374 .collect();
1375 assert!(
1376 !uses_edges.is_empty(),
1377 "Dart import should create uses edges"
1378 );
1379 assert!(
1380 uses_edges
1381 .iter()
1382 .any(|e| e.source == "main_fn" && e.target == "helper_fn")
1383 );
1384 }
1385
1386 #[test]
1389 fn kotlin_import_creates_uses_edges() {
1390 let mut result = ExtractionResult {
1391 nodes: vec![
1392 make_test_node("file_main", "Main", "src/Main.kt", NodeType::File),
1393 make_test_node("main_fn", "main", "src/Main.kt", NodeType::Function),
1394 make_test_node(
1395 "import_repo",
1396 "import com.example.UserRepo",
1397 "src/Main.kt",
1398 NodeType::Module,
1399 ),
1400 make_test_node("file_repo", "UserRepo", "src/UserRepo.kt", NodeType::File),
1401 make_test_node("repo_class", "UserRepo", "src/UserRepo.kt", NodeType::Class),
1402 ],
1403 edges: vec![
1404 make_test_edge("file_main", "main_fn", "defines", "src/Main.kt"),
1405 make_test_edge("file_main", "import_repo", "imports", "src/Main.kt"),
1406 make_test_edge("file_repo", "repo_class", "defines", "src/UserRepo.kt"),
1407 ],
1408 hyperedges: vec![],
1409 };
1410
1411 resolve_cross_file_imports(&mut result);
1412
1413 let uses_edges: Vec<_> = result
1414 .edges
1415 .iter()
1416 .filter(|e| e.relation == "uses")
1417 .collect();
1418 assert!(
1419 !uses_edges.is_empty(),
1420 "Kotlin import should create uses edges"
1421 );
1422 assert!(
1423 uses_edges
1424 .iter()
1425 .any(|e| e.source == "main_fn" && e.target == "repo_class")
1426 );
1427 }
1428
1429 #[test]
1432 fn python_star_import_expands_to_entities() {
1433 let mut result = ExtractionResult {
1434 nodes: vec![
1435 make_test_node("file_app", "app", "src/app.py", NodeType::File),
1436 make_test_node("app_fn", "run", "src/app.py", NodeType::Function),
1437 make_test_node("import_star", "utils.*", "src/app.py", NodeType::Module),
1438 make_test_node("file_utils", "utils", "src/utils.py", NodeType::File),
1439 make_test_node("helper1", "helper1", "src/utils.py", NodeType::Function),
1440 make_test_node("helper2", "helper2", "src/utils.py", NodeType::Function),
1441 ],
1442 edges: vec![
1443 make_test_edge("file_app", "app_fn", "defines", "src/app.py"),
1444 make_test_edge("file_app", "import_star", "imports", "src/app.py"),
1445 make_test_edge("file_utils", "helper1", "defines", "src/utils.py"),
1446 make_test_edge("file_utils", "helper2", "defines", "src/utils.py"),
1447 ],
1448 hyperedges: vec![],
1449 };
1450
1451 resolve_python_imports(&mut result);
1452
1453 let uses_edges: Vec<_> = result
1454 .edges
1455 .iter()
1456 .filter(|e| e.relation == "uses")
1457 .collect();
1458 assert_eq!(
1459 uses_edges.len(),
1460 2,
1461 "star import should expand to 2 uses edges, got {}",
1462 uses_edges.len()
1463 );
1464 }
1465
1466 #[test]
1469 fn scala_cross_file_creates_uses_edges() {
1470 let mut result = ExtractionResult {
1471 nodes: vec![
1472 make_test_node("file_main", "Main", "src/Main.scala", NodeType::File),
1473 make_test_node("main_fn", "main", "src/Main.scala", NodeType::Function),
1474 make_test_node(
1475 "import_calc",
1476 "import com.example.Calculator",
1477 "src/Main.scala",
1478 NodeType::Module,
1479 ),
1480 make_test_node(
1481 "file_calc",
1482 "Calculator",
1483 "src/Calculator.scala",
1484 NodeType::File,
1485 ),
1486 make_test_node(
1487 "calc_class",
1488 "Calculator",
1489 "src/Calculator.scala",
1490 NodeType::Class,
1491 ),
1492 ],
1493 edges: vec![
1494 make_test_edge("file_main", "main_fn", "defines", "src/Main.scala"),
1495 make_test_edge("file_main", "import_calc", "imports", "src/Main.scala"),
1496 make_test_edge("file_calc", "calc_class", "defines", "src/Calculator.scala"),
1497 ],
1498 hyperedges: vec![],
1499 };
1500
1501 resolve_cross_file_imports(&mut result);
1502
1503 let uses_edges: Vec<_> = result
1504 .edges
1505 .iter()
1506 .filter(|e| e.relation == "uses")
1507 .collect();
1508 assert!(
1509 !uses_edges.is_empty(),
1510 "Scala cross-file should create uses edges"
1511 );
1512 assert!(
1513 uses_edges
1514 .iter()
1515 .any(|e| e.source == "main_fn" && e.target == "calc_class")
1516 );
1517 }
1518
1519 #[test]
1522 fn swift_cross_file_creates_uses_edges() {
1523 let mut result = ExtractionResult {
1524 nodes: vec![
1525 make_test_node("file_app", "App", "src/App.swift", NodeType::File),
1526 make_test_node("app_fn", "run", "src/App.swift", NodeType::Function),
1527 make_test_node(
1528 "import_mgr",
1529 "import UserManager",
1530 "src/App.swift",
1531 NodeType::Module,
1532 ),
1533 make_test_node(
1534 "file_mgr",
1535 "UserManager",
1536 "src/UserManager.swift",
1537 NodeType::File,
1538 ),
1539 make_test_node(
1540 "mgr_class",
1541 "UserManager",
1542 "src/UserManager.swift",
1543 NodeType::Class,
1544 ),
1545 ],
1546 edges: vec![
1547 make_test_edge("file_app", "app_fn", "defines", "src/App.swift"),
1548 make_test_edge("file_app", "import_mgr", "imports", "src/App.swift"),
1549 make_test_edge("file_mgr", "mgr_class", "defines", "src/UserManager.swift"),
1550 ],
1551 hyperedges: vec![],
1552 };
1553
1554 resolve_cross_file_imports(&mut result);
1555
1556 let uses_edges: Vec<_> = result
1557 .edges
1558 .iter()
1559 .filter(|e| e.relation == "uses")
1560 .collect();
1561 assert!(
1562 !uses_edges.is_empty(),
1563 "Swift cross-file should create uses edges"
1564 );
1565 assert!(
1566 uses_edges
1567 .iter()
1568 .any(|e| e.source == "app_fn" && e.target == "mgr_class")
1569 );
1570 }
1571
1572 #[test]
1575 fn jsts_resolver_strips_alias() {
1576 let mut entities = HashMap::new();
1577 entities.insert(
1578 "utils".to_string(),
1579 vec![("parseDate".into(), "pd_id".into(), NodeType::Function)],
1580 );
1581 let result = resolve_jsts_import("utils/parseDate as pd", &entities);
1583 assert!(!result.is_empty(), "aliased JS import should resolve");
1584 }
1585
1586 #[test]
1587 fn go_resolver_handles_blank_import() {
1588 let mut entities = HashMap::new();
1589 entities.insert(
1590 "driver".to_string(),
1591 vec![("Register".into(), "reg_id".into(), NodeType::Function)],
1592 );
1593 let empty = HashMap::new();
1594 let result = resolve_go_import("_ database/sql/driver", &entities, &empty);
1596 assert!(!result.is_empty(), "Go blank import should resolve");
1597 }
1598
1599 #[test]
1600 fn go_resolver_handles_alias_import() {
1601 let mut entities = HashMap::new();
1602 entities.insert(
1603 "http".to_string(),
1604 vec![("Server".into(), "srv_id".into(), NodeType::Struct)],
1605 );
1606 let empty = HashMap::new();
1607 let result = resolve_go_import(r#"h "net/http""#, &entities, &empty);
1609 assert!(!result.is_empty(), "Go aliased import should resolve");
1610 }
1611
1612 #[test]
1613 fn rust_resolver_handles_glob() {
1614 let mut entities = HashMap::new();
1615 entities.insert(
1616 "model".to_string(),
1617 vec![
1618 ("Config".into(), "cfg_id".into(), NodeType::Struct),
1619 ("Database".into(), "db_id".into(), NodeType::Struct),
1620 ],
1621 );
1622 let result = resolve_rust_import("crate::model::*", &entities);
1624 assert_eq!(result.len(), 2, "glob import should return all entities");
1625 }
1626
1627 #[test]
1628 fn dot_resolver_handles_static_import() {
1629 let mut entities = HashMap::new();
1630 entities.insert(
1631 "Math".to_string(),
1632 vec![("sqrt".into(), "sqrt_id".into(), NodeType::Function)],
1633 );
1634 let result = resolve_dot_import("static java.lang.Math.sqrt", &entities);
1636 assert!(
1637 !result.is_empty(),
1638 "Java static import should resolve: got empty"
1639 );
1640 }
1641
1642 #[test]
1643 fn dot_resolver_handles_csharp_alias() {
1644 let mut entities = HashMap::new();
1645 entities.insert(
1646 "MySqlClient".to_string(),
1647 vec![("Connection".into(), "conn_id".into(), NodeType::Class)],
1648 );
1649 let result = resolve_dot_import("MySql = MySql.Data.MySqlClient", &entities);
1651 assert!(
1652 !result.is_empty(),
1653 "C# alias using should resolve: got empty"
1654 );
1655 }
1656
1657 #[test]
1658 fn dart_resolver_handles_relative_import() {
1659 let mut entities = HashMap::new();
1660 entities.insert(
1661 "user".to_string(),
1662 vec![("User".into(), "user_id".into(), NodeType::Class)],
1663 );
1664 let result = resolve_dart_import("import '../models/user.dart'", &entities);
1665 assert!(!result.is_empty(), "Dart relative import should resolve");
1666 }
1667
1668 #[test]
1669 fn dart_resolver_handles_deferred_import() {
1670 let mut entities = HashMap::new();
1671 entities.insert(
1672 "heavy".to_string(),
1673 vec![("compute".into(), "comp_id".into(), NodeType::Function)],
1674 );
1675 let result = resolve_dart_import(
1676 "import 'package:myapp/heavy.dart' deferred as heavy",
1677 &entities,
1678 );
1679 assert!(!result.is_empty(), "Dart deferred import should resolve");
1680 }
1681
1682 #[test]
1683 fn dart_resolver_handles_part_directive() {
1684 let mut entities = HashMap::new();
1685 entities.insert(
1686 "models".to_string(),
1687 vec![("Item".into(), "item_id".into(), NodeType::Class)],
1688 );
1689 let result = resolve_dart_import("part 'src/models.dart'", &entities);
1690 assert!(!result.is_empty(), "Dart part directive should resolve");
1691 }
1692}