Skip to main content

cgx_engine/
resolver.rs

1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3
4use crate::parser::{EdgeDef, EdgeKind, NodeDef, NodeKind};
5
6/// Returns true if the given relative file path looks like a test file.
7pub fn is_test_path(path: &str) -> bool {
8    let lower = path.to_lowercase();
9    lower.contains("/test/")
10        || lower.contains("/tests/")
11        || lower.contains("/__tests__/")
12        || lower.contains("/spec/")
13        || lower.ends_with(".test.ts")
14        || lower.ends_with(".spec.ts")
15        || lower.ends_with(".test.js")
16        || lower.ends_with(".spec.js")
17        || lower.ends_with(".test.tsx")
18        || lower.ends_with(".spec.tsx")
19        || lower.ends_with("_test.py")
20        || lower.ends_with("_test.rs")
21}
22
23pub fn resolve(
24    nodes: &[NodeDef],
25    edges: &[EdgeDef],
26    _repo_root: &Path,
27) -> anyhow::Result<Vec<EdgeDef>> {
28    let mut resolved_edges: Vec<EdgeDef> = Vec::new();
29
30    // Create a set of all known node IDs for validation
31    let node_ids: HashSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
32
33    // Build export index: name -> Vec<node_id>
34    let mut export_index: HashMap<String, Vec<String>> = HashMap::new();
35    for node in nodes {
36        export_index
37            .entry(node.name.clone())
38            .or_default()
39            .push(node.id.clone());
40    }
41
42    // Build file node set from actual files
43    let mut file_paths: HashSet<String> = HashSet::new();
44    for node in nodes {
45        file_paths.insert(node.path.clone());
46    }
47
48    // Create file node IDs we know about
49    let known_file_ids: HashSet<String> =
50        file_paths.iter().map(|p| format!("file:{}", p)).collect();
51
52    // Build a set of test-file paths for CALLS→TESTS reclassification
53    let test_paths: HashSet<&str> = nodes
54        .iter()
55        .filter(|n| is_test_path(&n.path))
56        .map(|n| n.path.as_str())
57        .collect();
58
59    // Also test source IDs (fn:/cls: nodes whose path is a test file)
60    let test_node_ids: HashSet<&str> = nodes
61        .iter()
62        .filter(|n| is_test_path(&n.path))
63        .map(|n| n.id.as_str())
64        .collect();
65    let _ = test_paths; // used via test_node_ids
66
67    // Process all edges
68    for edge in edges {
69        match edge.kind {
70            EdgeKind::Imports => {
71                // src = file:<current_file>, dst = file:<imported_file>
72                // Check if dst is a valid file ID, or try to resolve it
73                let dst_is_valid =
74                    node_ids.contains(edge.dst.as_str()) || known_file_ids.contains(&edge.dst);
75
76                if dst_is_valid {
77                    resolved_edges.push(EdgeDef {
78                        src: edge.src.clone(),
79                        dst: edge.dst.clone(),
80                        kind: EdgeKind::Imports,
81                        ..Default::default()
82                    });
83                } else {
84                    // Try with different extensions
85                    let import_target = edge.dst.trim_start_matches("file:");
86                    let mut found = false;
87                    for ext in &[".ts", ".tsx", ".js", ".jsx", ".py", ".rs"] {
88                        let alt = format!("file:{}{}", import_target, ext);
89                        if known_file_ids.contains(&alt) {
90                            resolved_edges.push(EdgeDef {
91                                src: edge.src.clone(),
92                                dst: alt,
93                                kind: EdgeKind::Imports,
94                                ..Default::default()
95                            });
96                            found = true;
97                            break;
98                        }
99                    }
100                    // Try directory index files (Node.js resolution)
101                    if !found {
102                        for index in &["/index.js", "/index.ts", "/index.jsx", "/index.tsx"] {
103                            let alt = format!("file:{}{}", import_target, index);
104                            if known_file_ids.contains(&alt) {
105                                resolved_edges.push(EdgeDef {
106                                    src: edge.src.clone(),
107                                    dst: alt,
108                                    kind: EdgeKind::Imports,
109                                    ..Default::default()
110                                });
111                                found = true;
112                                break;
113                            }
114                        }
115                    }
116                    if !found {
117                        // Include unresolvable imports too (they may still be useful)
118                        resolved_edges.push(edge.clone());
119                    }
120                }
121            }
122            EdgeKind::Exports => {
123                // src = file:<path>, dst = fn:path:name or cls:path:name
124                if node_ids.contains(edge.dst.as_str()) {
125                    resolved_edges.push(edge.clone());
126                } else {
127                    // Keep the edge but log a warning — may be resolved in a later pass
128                    tracing::debug!("Unresolved export edge: {} -> {}", edge.src, edge.dst);
129                    resolved_edges.push(edge.clone());
130                }
131            }
132            EdgeKind::Calls | EdgeKind::Inherits => {
133                // If source node is from a test file and destination is not, use TESTS edge
134                let src_is_test = test_node_ids.contains(edge.src.as_str());
135
136                // If dst is a valid node ID, keep it. Otherwise try to resolve by name.
137                if node_ids.contains(edge.dst.as_str()) {
138                    let dst_is_test = test_node_ids.contains(edge.dst.as_str());
139                    let kind = if src_is_test && !dst_is_test {
140                        EdgeKind::Tests
141                    } else {
142                        edge.kind.clone()
143                    };
144                    resolved_edges.push(EdgeDef {
145                        kind,
146                        ..edge.clone()
147                    });
148                } else if let Some(targets) = export_index.get(&edge.dst) {
149                    // Found matching names - create CALLS (or TESTS) edges with lower confidence
150                    for target_id in targets {
151                        let dst_is_test = test_node_ids.contains(target_id.as_str());
152                        let kind = if src_is_test && !dst_is_test {
153                            EdgeKind::Tests
154                        } else {
155                            EdgeKind::Calls
156                        };
157                        resolved_edges.push(EdgeDef {
158                            src: edge.src.clone(),
159                            dst: target_id.clone(),
160                            kind,
161                            confidence: 0.8,
162                            ..Default::default()
163                        });
164                    }
165                } else {
166                    // Keep the edge even if unresolved (maybe a future phase can handle)
167                    resolved_edges.push(edge.clone());
168                }
169            }
170            _ => {
171                // CoChanges, Owns, DependsOn — pass through unchanged
172                resolved_edges.push(edge.clone());
173            }
174        }
175    }
176
177    Ok(resolved_edges)
178}
179
180pub fn create_file_nodes(
181    file_paths: &HashSet<String>,
182    language: &HashMap<String, &str>,
183) -> Vec<NodeDef> {
184    let mut nodes = Vec::new();
185
186    for path in file_paths {
187        let id = format!("file:{}", path);
188        let _lang = language.get(path.as_str()).copied().unwrap_or("unknown");
189
190        nodes.push(NodeDef {
191            id,
192            kind: NodeKind::File,
193            name: path.clone(),
194            path: path.clone(),
195            line_start: 1,
196            line_end: 1,
197            ..Default::default()
198        });
199    }
200
201    nodes
202}
203
204pub fn build_language_map(nodes: &[NodeDef]) -> HashMap<String, &'static str> {
205    let mut map = HashMap::new();
206    for node in nodes {
207        let lang = match node.id.split(':').next().unwrap_or("") {
208            "fn" if node.path.ends_with(".ts") || node.path.ends_with(".tsx") => "typescript",
209            "fn" if node.path.ends_with(".js") || node.path.ends_with(".jsx") => "javascript",
210            "fn" if node.path.ends_with(".py") => "python",
211            "fn" if node.path.ends_with(".rs") => "rust",
212            "cls" if node.path.ends_with(".ts") || node.path.ends_with(".tsx") => "typescript",
213            "cls" if node.path.ends_with(".js") || node.path.ends_with(".jsx") => "javascript",
214            "cls" if node.path.ends_with(".py") => "python",
215            "cls" if node.path.ends_with(".rs") => "rust",
216            "file" if node.path.ends_with(".ts") || node.path.ends_with(".tsx") => "typescript",
217            "file" if node.path.ends_with(".js") || node.path.ends_with(".jsx") => "javascript",
218            "file" if node.path.ends_with(".py") => "python",
219            "file" if node.path.ends_with(".rs") => "rust",
220            _ => "unknown",
221        };
222        // Only insert if not already present (function/class nodes take priority)
223        map.entry(node.path.clone()).or_insert(lang);
224    }
225    map
226}