Skip to main content

gid_core/
unify.rs

1//! Unify — convert CodeGraph to graph::Node/Edge for unified graph storage.
2//! Also provides reverse conversion (Graph → CodeGraph) for legacy command compatibility.
3
4use crate::graph::{Graph, Node, Edge, NodeStatus};
5use crate::code_graph::{CodeGraph, CodeNode, CodeEdge, NodeKind, EdgeRelation};
6
7
8/// Convert CodeGraph nodes and edges to graph-layer Nodes and Edges.
9/// All resulting nodes have `source: "extract"`, `node_type: "code"`, `status: Done`.
10pub fn codegraph_to_graph_nodes(cg: &CodeGraph, _project_root: &std::path::Path) -> (Vec<Node>, Vec<Edge>) {
11    let mut nodes = Vec::with_capacity(cg.nodes.len());
12    let mut edges = Vec::with_capacity(cg.edges.len());
13
14    for cn in &cg.nodes {
15        let mut node = Node::new(&cn.id, &cn.name);
16        node.source = Some("extract".to_string());
17        node.node_type = Some("code".to_string());
18        node.node_kind = Some(format!("{:?}", cn.kind)); // "File", "Class", "Function", etc.
19        node.status = NodeStatus::Done;
20        node.file_path = Some(cn.file_path.clone());
21        if let Some(line) = cn.line {
22            node.start_line = Some(line);
23        }
24        if let Some(ref sig) = cn.signature {
25            node.signature = Some(sig.clone());
26        }
27        if let Some(ref doc) = cn.docstring {
28            node.doc_comment = Some(doc.clone());
29        }
30        // Store additional fields in metadata
31        if cn.is_test {
32            node.metadata.insert("is_test".to_string(), serde_json::json!(true));
33        }
34        if cn.line_count > 0 {
35            node.metadata.insert("line_count".to_string(), serde_json::json!(cn.line_count));
36        }
37        if !cn.decorators.is_empty() {
38            node.metadata.insert("decorators".to_string(), serde_json::json!(cn.decorators));
39        }
40        // Copy code-graph enrichment fields
41        node.visibility = cn.visibility.clone();
42        node.lang = cn.lang.clone();
43        node.body_hash = cn.body_hash.clone();
44        node.end_line = cn.end_line;
45        // Derive is_public from visibility
46        if let Some(ref vis) = cn.visibility {
47            node.is_public = Some(vis == "pub" || vis == "export");
48        }
49        nodes.push(node);
50    }
51
52    for ce in &cg.edges {
53        let relation = ce.relation.to_string(); // "imports", "calls", "inherits", etc.
54        let mut edge = Edge::new(&ce.from, &ce.to, &relation);
55        // Set confidence and weight directly on the Edge struct (stored as dedicated SQLite columns)
56        edge.confidence = Some(ce.confidence as f64);
57        edge.weight = Some(ce.weight as f64);
58        let mut meta = serde_json::Map::new();
59        meta.insert("source".to_string(), serde_json::json!("extract"));
60        if ce.call_count > 1 {
61            meta.insert("call_count".to_string(), serde_json::json!(ce.call_count));
62        }
63        if ce.in_error_path {
64            meta.insert("in_error_path".to_string(), serde_json::json!(true));
65        }
66        edge.metadata = Some(serde_json::Value::Object(meta));
67        edges.push(edge);
68    }
69
70    (nodes, edges)
71}
72
73/// Reconstruct a CodeGraph from graph.yml code-layer nodes and edges.
74///
75/// This is the reverse of `codegraph_to_graph_nodes()`. Used by legacy CLI commands
76/// (schema, code-search, code-trace, etc.) that still operate on CodeGraph APIs.
77/// Reads only code-layer nodes (`source == "extract"`) and code-layer edges.
78pub fn graph_to_codegraph(graph: &Graph) -> CodeGraph {
79    let code_nodes_refs = graph.code_nodes();
80    let code_edges_refs = graph.code_edges();
81
82    let mut nodes = Vec::with_capacity(code_nodes_refs.len());
83    let mut edges = Vec::with_capacity(code_edges_refs.len());
84
85    for n in &code_nodes_refs {
86        let kind = match n.node_kind.as_deref() {
87            Some("File") => NodeKind::File,
88            Some("Class") => NodeKind::Class,
89            Some("Function") => NodeKind::Function,
90            Some("Module") => NodeKind::Module,
91            Some("Constant") => NodeKind::Constant,
92            Some("Interface") => NodeKind::Interface,
93            Some("Enum") => NodeKind::Enum,
94            Some("TypeAlias") => NodeKind::TypeAlias,
95            Some("Trait") => NodeKind::Trait,
96            Some("Method") => NodeKind::Function, // Methods map to Function in CodeGraph
97            _ => NodeKind::File, // safe fallback
98        };
99
100        let is_test = n.metadata.get("is_test")
101            .and_then(|v| v.as_bool())
102            .unwrap_or(false);
103        let line_count = n.metadata.get("line_count")
104            .and_then(|v| v.as_u64())
105            .unwrap_or(0) as usize;
106        let decorators: Vec<String> = n.metadata.get("decorators")
107            .and_then(|v| v.as_array())
108            .map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
109            .unwrap_or_default();
110
111        nodes.push(CodeNode {
112            id: n.id.clone(),
113            kind,
114            name: n.title.clone(),
115            file_path: n.file_path.as_deref().unwrap_or("").to_string(),
116            line: n.start_line,
117            decorators,
118            signature: n.signature.clone(),
119            docstring: n.doc_comment.clone(),
120            line_count,
121            is_test,
122            visibility: n.visibility.clone(),
123            lang: n.lang.clone(),
124            body_hash: n.body_hash.clone(),
125            end_line: n.end_line,
126            complexity: None,
127                    });
128    }
129
130    for e in &code_edges_refs {
131        let relation = match e.relation.as_str() {
132            "imports" => EdgeRelation::Imports,
133            "inherits" => EdgeRelation::Inherits,
134            "defined_in" => EdgeRelation::DefinedIn,
135            "calls" => EdgeRelation::Calls,
136            "tests_for" => EdgeRelation::TestsFor,
137            "overrides" => EdgeRelation::Overrides,
138            "implements" => EdgeRelation::Implements,
139            "belongs_to" => EdgeRelation::BelongsTo,
140            "type_reference" => EdgeRelation::TypeReference,
141            _ => continue, // skip non-code relations
142        };
143
144        let meta = e.metadata.as_ref();
145        // Read weight/confidence from Edge struct fields first (canonical), fallback to metadata for legacy graphs
146        let weight = e.weight.map(|w| w as f32)
147            .or_else(|| meta.and_then(|m| m.get("weight")).and_then(|v| v.as_f64()).map(|v| v as f32))
148            .unwrap_or(0.5);
149        let call_count = meta.and_then(|m| m.get("call_count")).and_then(|v| v.as_u64()).unwrap_or(1) as u32;
150        let in_error_path = meta.and_then(|m| m.get("in_error_path")).and_then(|v| v.as_bool()).unwrap_or(false);
151        let confidence = e.confidence.map(|c| c as f32)
152            .or_else(|| meta.and_then(|m| m.get("confidence")).and_then(|v| v.as_f64()).map(|v| v as f32))
153            .unwrap_or(1.0);
154
155        edges.push(CodeEdge {
156            from: e.from.clone(),
157            to: e.to.clone(),
158            relation,
159            weight,
160            call_count,
161            in_error_path,
162            confidence,
163            call_site_line: None,
164            call_site_column: None,
165        });
166    }
167
168    let mut cg = CodeGraph {
169        nodes,
170        edges,
171        outgoing: Default::default(),
172        incoming: Default::default(),
173        node_index: Default::default(),
174    };
175    cg.build_indexes();
176    cg
177}
178
179/// Merge code-layer nodes/edges into an existing graph.
180/// Removes old code nodes (`source == "extract"`) and bridge edges (`source == "auto-bridge"`),
181/// then appends new code nodes/edges. Also prunes dangling edges that reference removed code nodes.
182pub fn merge_code_layer(graph: &mut Graph, code_nodes: Vec<Node>, code_edges: Vec<Edge>) {
183    // Remove old code nodes
184    graph.nodes.retain(|n| n.source.as_deref() != Some("extract"));
185    // Remove old code edges and bridge edges
186    graph.edges.retain(|e| {
187        let src = e.source();
188        src != Some("extract") && src != Some("auto-bridge")
189    });
190    // Append new
191    graph.nodes.extend(code_nodes);
192    graph.edges.extend(code_edges);
193
194    // Prune dangling edges: edges whose from/to references a node that no longer exists.
195    // This catches stale edges left by deprecated code (e.g. old `code_*` node IDs from
196    // build_unified_graph/link_tasks_to_code that lacked a source tag).
197    // Only prune edges where the missing endpoint looks like it was a code node —
198    // project edges are allowed to reference forward-declared / not-yet-created task nodes.
199    let node_ids: std::collections::HashSet<&str> =
200        graph.nodes.iter().map(|n| n.id.as_str()).collect();
201    graph.edges.retain(|e| {
202        let from_ok = node_ids.contains(e.from.as_str());
203        let to_ok = node_ids.contains(e.to.as_str());
204        if from_ok && to_ok {
205            return true;
206        }
207        // Keep project-layer edges (no source tag, or source != extract/auto-bridge)
208        // even if their endpoints are missing — those are task/feature references.
209        let src = e.source();
210        if src != Some("extract") && src != Some("auto-bridge") {
211            // This is a project edge or unmarked edge. Only prune if endpoint
212            // looks like a stale code node (starts with "code_" prefix from the old
213            // code_node_to_task_id scheme).
214            let stale_from = !from_ok && e.from.starts_with("code_");
215            let stale_to = !to_ok && e.to.starts_with("code_");
216            if stale_from || stale_to {
217                return false; // prune stale code references
218            }
219            return true; // keep project edges with missing endpoints
220        }
221        // Extract/bridge edge with dangling endpoint → prune
222        false
223    });
224}
225
226/// Merge project-layer nodes into an existing graph (preserving code layer).
227/// Used by design --parse and ritual generate-graph.
228pub fn merge_project_layer(existing: &mut Graph, new_project: Graph) {
229    // Retain code-layer nodes and bridge edges
230    let code_nodes: Vec<Node> = existing.nodes.drain(..).filter(|n| n.source.as_deref() == Some("extract")).collect();
231    let code_and_bridge_edges: Vec<Edge> = existing.edges.drain(..).filter(|e| {
232        let src = e.source();
233        src == Some("extract") || src == Some("auto-bridge")
234    }).collect();
235
236    // Replace project layer with new project nodes/edges
237    // Set source on new project nodes
238    let mut project_nodes: Vec<Node> = new_project.nodes.into_iter().map(|mut n| {
239        if n.source.is_none() {
240            n.source = Some("project".to_string());
241        }
242        n
243    }).collect();
244
245    // Restore code nodes + new project nodes
246    existing.nodes = code_nodes;
247    existing.nodes.append(&mut project_nodes);
248
249    // Restore code/bridge edges + new project edges
250    existing.edges = code_and_bridge_edges;
251    existing.edges.extend(new_project.edges);
252}
253
254/// Generate bridge edges linking project nodes to code nodes.
255///
256/// 1. Delete all existing auto-bridge edges.
257/// 2. For each code node with `file_path`, check if any project node has a `code_paths`
258///    metadata entry that contains that path → create a `maps_to` edge (confidence 1.0).
259/// 3. Fallback: try ID prefix matching — extract path segments from code node id and
260///    look for project nodes whose id contains those segments (confidence 0.8).
261pub fn generate_bridge_edges(graph: &mut Graph) {
262    // 1. Remove existing auto-bridge edges
263    graph.edges.retain(|e| e.source() != Some("auto-bridge"));
264
265    // Collect project and code node info to avoid borrow issues
266    let project_info: Vec<(String, Vec<String>)> = graph.project_nodes().iter().map(|n| {
267        let code_paths: Vec<String> = n.metadata.get("code_paths")
268            .and_then(|v| v.as_array())
269            .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
270            .unwrap_or_default();
271        (n.id.clone(), code_paths)
272    }).collect();
273
274    let code_info: Vec<(String, Option<String>)> = graph.code_nodes().iter().map(|n| {
275        (n.id.clone(), n.file_path.clone())
276    }).collect();
277
278    let mut new_edges: Vec<Edge> = Vec::new();
279
280    for (code_id, file_path) in &code_info {
281        let mut matched = false;
282
283        // 2. Check code_paths metadata match
284        if let Some(fp) = file_path {
285            for (proj_id, code_paths) in &project_info {
286                if code_paths.iter().any(|cp| cp == fp) {
287                    let mut edge = Edge::new(proj_id, code_id, "maps_to");
288                    edge.metadata = Some(serde_json::json!({"source": "auto-bridge", "confidence": 1.0}));
289                    new_edges.push(edge);
290                    matched = true;
291                }
292            }
293        }
294
295        // 3. Fallback: ID prefix matching
296        if !matched {
297            // Extract meaningful path segments from code node id
298            // e.g. "file:src/auth/login.rs" → ["auth", "login"]
299            let id_path = code_id.split(':').nth(1).unwrap_or(code_id);
300            let segments: Vec<&str> = id_path
301                .split('/')
302                .filter(|s| *s != "src" && *s != "lib" && *s != "mod.rs" && *s != "index.ts" && *s != "index.js")
303                .filter_map(|s| {
304                    let name = s.split('.').next().unwrap_or(s);
305                    if name.is_empty() || name == "main" || name == "mod" || name == "index" {
306                        None
307                    } else {
308                        Some(name)
309                    }
310                })
311                .collect();
312
313            for segment in &segments {
314                let seg_lower = segment.to_lowercase();
315                for (proj_id, _) in &project_info {
316                    let proj_lower = proj_id.to_lowercase();
317                    if proj_lower.contains(&seg_lower) {
318                        // Avoid duplicate edges
319                        let already = new_edges.iter().any(|e| e.from == *proj_id && e.to == *code_id);
320                        if !already {
321                            let mut edge = Edge::new(proj_id, code_id, "maps_to");
322                            edge.metadata = Some(serde_json::json!({"source": "auto-bridge", "confidence": 0.8}));
323                            new_edges.push(edge);
324                        }
325                    }
326                }
327            }
328        }
329    }
330
331    graph.edges.extend(new_edges);
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::code_graph::{CodeNode, CodeEdge, CodeGraph, NodeKind, EdgeRelation};
338    use std::path::Path;
339
340    fn sample_codegraph() -> CodeGraph {
341        let mut cg = CodeGraph::default();
342        let file = CodeNode::new_file("src/main.rs");
343        let func = CodeNode::new_function("src/main.rs", "main", 5, false);
344        let class = CodeNode::new_class("src/auth.rs", "AuthService", 10);
345        cg.nodes = vec![file, func, class];
346        cg.edges = vec![
347            CodeEdge::new("func:src/main.rs:main", "file:src/main.rs", EdgeRelation::DefinedIn),
348            CodeEdge::new("func:src/main.rs:main", "class:src/auth.rs:AuthService", EdgeRelation::Calls),
349        ];
350        cg
351    }
352
353    #[test]
354    fn test_codegraph_to_graph_nodes_basic() {
355        let cg = sample_codegraph();
356        let (nodes, edges) = codegraph_to_graph_nodes(&cg, Path::new("/tmp/project"));
357        assert_eq!(nodes.len(), 3);
358        assert_eq!(edges.len(), 2);
359
360        // All nodes have source=extract, type=code, status=Done
361        for n in &nodes {
362            assert_eq!(n.source.as_deref(), Some("extract"));
363            assert_eq!(n.node_type.as_deref(), Some("code"));
364            assert_eq!(n.status, NodeStatus::Done);
365        }
366    }
367
368    #[test]
369    fn test_codegraph_node_kind_mapping() {
370        let cg = sample_codegraph();
371        let (nodes, _) = codegraph_to_graph_nodes(&cg, Path::new("/tmp"));
372
373        let file_node = nodes.iter().find(|n| n.id == "file:src/main.rs").unwrap();
374        assert_eq!(file_node.node_kind.as_deref(), Some("File"));
375
376        let func_node = nodes.iter().find(|n| n.id == "func:src/main.rs:main").unwrap();
377        assert_eq!(func_node.node_kind.as_deref(), Some("Function"));
378        assert_eq!(func_node.start_line, Some(5));
379    }
380
381    #[test]
382    fn test_codegraph_edge_conversion() {
383        let cg = sample_codegraph();
384        let (_, edges) = codegraph_to_graph_nodes(&cg, Path::new("/tmp"));
385
386        let defined_in = edges.iter().find(|e| e.relation == "defined_in").unwrap();
387        assert_eq!(defined_in.source(), Some("extract"));
388        // confidence and weight must be set on the Edge struct itself (not just metadata)
389        assert!(defined_in.confidence.is_some(), "confidence must be set on Edge");
390        assert!(defined_in.weight.is_some(), "weight must be set on Edge");
391
392        let calls = edges.iter().find(|e| e.relation == "calls").unwrap();
393        assert_eq!(calls.source(), Some("extract"));
394        assert!(calls.confidence.is_some(), "confidence must be set on Edge");
395        assert!(calls.weight.is_some(), "weight must be set on Edge");
396        // Default CodeEdge confidence=1.0, weight=0.5
397        assert_eq!(calls.confidence, Some(1.0));
398        assert_eq!(calls.weight, Some(0.5));
399    }
400
401    #[test]
402    fn test_merge_code_layer() {
403        let mut graph = Graph::new();
404        // Add a project node
405        let mut task = Node::new("task-1", "My Task");
406        task.source = Some("project".to_string());
407        graph.add_node(task);
408        graph.add_edge(Edge::new("task-1", "task-2", "depends_on"));
409
410        // Old code node that should be replaced
411        let mut old_code = Node::new("file:old.rs", "old file");
412        old_code.source = Some("extract".to_string());
413        graph.add_node(old_code);
414
415        let cg = sample_codegraph();
416        let (code_nodes, code_edges) = codegraph_to_graph_nodes(&cg, Path::new("/tmp"));
417        merge_code_layer(&mut graph, code_nodes, code_edges);
418
419        // Project node preserved
420        assert!(graph.nodes.iter().any(|n| n.id == "task-1"));
421        // Old code node gone
422        assert!(!graph.nodes.iter().any(|n| n.id == "file:old.rs"));
423        // New code nodes present
424        assert!(graph.nodes.iter().any(|n| n.id == "file:src/main.rs"));
425        // Project edge preserved
426        assert!(graph.edges.iter().any(|e| e.relation == "depends_on"));
427    }
428
429    #[test]
430    fn test_merge_project_layer() {
431        let mut existing = Graph::new();
432        // Code node
433        let mut code = Node::new("file:src/main.rs", "main.rs");
434        code.source = Some("extract".to_string());
435        existing.add_node(code);
436
437        let mut code_edge = Edge::new("file:src/main.rs", "func:main", "defined_in");
438        code_edge.metadata = Some(serde_json::json!({"source": "extract"}));
439        existing.add_edge(code_edge);
440
441        // Old project node
442        let mut old_task = Node::new("old-task", "Old");
443        old_task.source = Some("project".to_string());
444        existing.add_node(old_task);
445
446        // New project graph from LLM
447        let mut new_project = Graph::new();
448        new_project.add_node(Node::new("task-1", "New Task"));
449        new_project.add_edge(Edge::new("task-1", "task-2", "depends_on"));
450
451        merge_project_layer(&mut existing, new_project);
452
453        // Code node preserved
454        assert!(existing.nodes.iter().any(|n| n.id == "file:src/main.rs"));
455        // Old project node gone
456        assert!(!existing.nodes.iter().any(|n| n.id == "old-task"));
457        // New project node present with source=project
458        let task = existing.nodes.iter().find(|n| n.id == "task-1").unwrap();
459        assert_eq!(task.source.as_deref(), Some("project"));
460    }
461
462    #[test]
463    fn test_all_node_kinds() {
464        let mut cg = CodeGraph::default();
465        cg.nodes = vec![
466            CodeNode::new_file("src/lib.rs"),
467            CodeNode::new_class("src/lib.rs", "Foo", 1),
468            CodeNode::new_function("src/lib.rs", "bar", 10, false),
469            CodeNode::new_module("src/mod"),
470            CodeNode::new_constant("src/lib.rs", "MAX", 1),
471            CodeNode::new_interface("src/lib.rs", "IService", 20),
472            CodeNode::new_enum("src/e.rs", "Color", 1),
473            CodeNode::new_type_alias("src/t.rs", "Id", 1),
474            CodeNode::new_trait("src/tr.rs", "Storage", 1),
475        ];
476        let (nodes, _) = codegraph_to_graph_nodes(&cg, Path::new("/tmp"));
477        assert_eq!(nodes.len(), 9);
478        // Verify all have extract source
479        assert!(nodes.iter().all(|n| n.source.as_deref() == Some("extract")));
480    }
481
482    #[test]
483    fn test_edge_metadata_fields() {
484        let mut cg = CodeGraph::default();
485        cg.nodes = vec![
486            CodeNode::new_function("src/a.rs", "foo", 1, false),
487            CodeNode::new_function("src/b.rs", "bar", 1, false),
488        ];
489        let mut edge = CodeEdge::new("func:src/a.rs:foo", "func:src/b.rs:bar", EdgeRelation::Calls);
490        edge.call_count = 5;
491        edge.in_error_path = true;
492        edge.confidence = 0.8;
493        edge.weight = 0.9;
494        cg.edges = vec![edge];
495
496        let (_, edges) = codegraph_to_graph_nodes(&cg, Path::new("/tmp"));
497        assert_eq!(edges.len(), 1);
498        let e = &edges[0];
499        // confidence and weight are now on Edge struct fields, not in metadata
500        // Use approximate comparison due to f32→f64 cast precision
501        assert!((e.confidence.unwrap() - 0.8).abs() < 1e-6, "confidence should be ~0.8");
502        assert!((e.weight.unwrap() - 0.9).abs() < 1e-6, "weight should be ~0.9");
503        let meta = e.metadata.as_ref().unwrap();
504        assert_eq!(meta.get("source").unwrap(), "extract");
505        assert_eq!(meta.get("call_count").unwrap(), 5);
506        assert_eq!(meta.get("in_error_path").unwrap(), true);
507        // confidence/weight should NOT be duplicated in metadata
508        assert!(meta.get("confidence").is_none());
509        assert!(meta.get("weight").is_none());
510    }
511
512    #[test]
513    fn test_node_metadata_fields() {
514        let mut cg = CodeGraph::default();
515        let mut func = CodeNode::new_function("src/test.rs", "test_foo", 10, false);
516        func.is_test = true;
517        func.line_count = 25;
518        func.decorators = vec!["#[test]".to_string()];
519        func.signature = Some("fn test_foo()".to_string());
520        func.docstring = Some("A test function".to_string());
521        cg.nodes = vec![func];
522
523        let (nodes, _) = codegraph_to_graph_nodes(&cg, Path::new("/tmp"));
524        let n = &nodes[0];
525        assert_eq!(n.signature.as_deref(), Some("fn test_foo()"));
526        assert_eq!(n.doc_comment.as_deref(), Some("A test function"));
527        assert_eq!(n.metadata.get("is_test"), Some(&serde_json::json!(true)));
528        assert_eq!(n.metadata.get("line_count"), Some(&serde_json::json!(25)));
529        assert_eq!(n.metadata.get("decorators"), Some(&serde_json::json!(["#[test]"])));
530    }
531
532    #[test]
533    fn test_merge_code_layer_removes_bridge_edges() {
534        let mut graph = Graph::new();
535        // Add a bridge edge
536        let mut bridge_edge = Edge::new("task-1", "file:src/main.rs", "touches");
537        bridge_edge.metadata = Some(serde_json::json!({"source": "auto-bridge"}));
538        graph.add_edge(bridge_edge);
539
540        // Add a project edge
541        graph.add_edge(Edge::new("task-1", "task-2", "depends_on"));
542
543        let (code_nodes, code_edges) = codegraph_to_graph_nodes(&CodeGraph::default(), Path::new("/tmp"));
544        merge_code_layer(&mut graph, code_nodes, code_edges);
545
546        // Bridge edge removed
547        assert!(!graph.edges.iter().any(|e| e.source() == Some("auto-bridge")));
548        // Project edge preserved
549        assert!(graph.edges.iter().any(|e| e.relation == "depends_on"));
550    }
551
552    /// ADR-5: Cross-layer query integration test.
553    /// Verifies task→feature→code traversal via bridge edges.
554    #[test]
555    fn test_cross_layer_query_traversal() {
556        use crate::query::QueryEngine;
557
558        let mut graph = Graph::new();
559
560        // Project layer: feature + task
561        let mut feature = Node::new("feat-auth", "Auth Feature");
562        feature.source = Some("project".to_string());
563        feature.node_type = Some("feature".to_string());
564        graph.add_node(feature);
565
566        let mut task = Node::new("task-impl-auth", "Implement auth middleware");
567        task.source = Some("project".to_string());
568        task.node_type = Some("task".to_string());
569        task.description = Some("Implement JWT auth in src/auth.rs".to_string());
570        graph.add_node(task);
571
572        // task implements feature
573        graph.add_edge(Edge::new("task-impl-auth", "feat-auth", "implements"));
574
575        // Code layer: file + function
576        let mut code_file = Node::new("file:src/auth.rs", "src/auth.rs");
577        code_file.source = Some("extract".to_string());
578        code_file.node_type = Some("code".to_string());
579        code_file.file_path = Some("src/auth.rs".to_string());
580        code_file.status = NodeStatus::Done;
581        graph.add_node(code_file);
582
583        let mut code_fn = Node::new("fn:verify_jwt", "verify_jwt");
584        code_fn.source = Some("extract".to_string());
585        code_fn.node_type = Some("code".to_string());
586        code_fn.file_path = Some("src/auth.rs".to_string());
587        code_fn.status = NodeStatus::Done;
588        graph.add_node(code_fn);
589
590        // code edges
591        let mut code_edge = Edge::new("fn:verify_jwt", "file:src/auth.rs", "belongs_to");
592        code_edge.metadata = Some(serde_json::json!({"source": "extract"}));
593        graph.add_edge(code_edge);
594
595        // Bridge edge: task touches code file
596        let mut bridge = Edge::new("task-impl-auth", "file:src/auth.rs", "touches");
597        bridge.metadata = Some(serde_json::json!({"source": "auto-bridge", "confidence": 1.0}));
598        graph.add_edge(bridge);
599
600        // Now test: impact of code file should reach task (and feature)
601        let engine = QueryEngine::new(&graph);
602
603        // impact("file:src/auth.rs") should find task-impl-auth (touches edge, from=task, to=file)
604        let impacted = engine.impact("file:src/auth.rs");
605        let impacted_ids: Vec<&str> = impacted.iter().map(|n| n.id.as_str()).collect();
606        assert!(impacted_ids.contains(&"task-impl-auth"), "task should be impacted by code file change, got: {:?}", impacted_ids);
607
608        // Transitive: task implements feature, so feature should also be impacted
609        // (impact traverses "from" edges pointing at the current node)
610        // task-impl-auth → feat-auth (implements edge: from=task, to=feat)
611        // So feat-auth is NOT impacted (impact looks at edges where to==current)
612        // But if we look from feature perspective:
613        // deps of task-impl-auth should include feat-auth (via implements)
614        let deps = engine.deps("task-impl-auth", true);
615        let dep_ids: Vec<&str> = deps.iter().map(|n| n.id.as_str()).collect();
616        assert!(dep_ids.contains(&"feat-auth"), "feature should be a dep of task via implements, got: {:?}", dep_ids);
617        assert!(dep_ids.contains(&"file:src/auth.rs"), "code file should be a dep of task via touches, got: {:?}", dep_ids);
618
619        // Layer isolation: project_nodes should not include code nodes
620        assert_eq!(graph.project_nodes().len(), 2);
621        assert_eq!(graph.code_nodes().len(), 2);
622    }
623
624    /// T4.4: Performance benchmark — tasks listing with 0 vs 2000 code nodes.
625    /// Verifies overhead is < 2x (ADR-5 requirement).
626    #[test]
627    fn test_perf_tasks_with_code_nodes() {
628        // Build a project-only graph with 50 tasks
629        let mut project_graph = Graph::new();
630        for i in 0..50 {
631            let mut n = Node::new(&format!("task-{}", i), &format!("Task {}", i));
632            n.source = Some("project".to_string());
633            n.status = NodeStatus::Todo;
634            project_graph.add_node(n);
635        }
636        for i in 1..50 {
637            project_graph.add_edge(Edge::new(&format!("task-{}", i), &format!("task-{}", i - 1), "depends_on"));
638        }
639
640        // Build a mixed graph with 50 tasks + 2000 code nodes
641        let mut mixed_graph = project_graph.clone();
642        for i in 0..2000 {
643            let mut n = Node::new(&format!("fn:func_{}", i), &format!("func_{}", i));
644            n.source = Some("extract".to_string());
645            n.node_type = Some("code".to_string());
646            n.file_path = Some(format!("src/mod_{}.rs", i / 10));
647            n.status = NodeStatus::Done;
648            mixed_graph.add_node(n);
649        }
650        for i in 1..2000 {
651            let mut e = Edge::new(&format!("fn:func_{}", i), &format!("fn:func_{}", i - 1), "calls");
652            e.metadata = Some(serde_json::json!({"source": "extract"}));
653            mixed_graph.add_edge(e);
654        }
655
656        // Benchmark: project_nodes() on project-only vs mixed graph
657        let iterations = 100;
658
659        let start = std::time::Instant::now();
660        for _ in 0..iterations {
661            let _ = project_graph.project_nodes();
662        }
663        let project_only_time = start.elapsed();
664
665        let start = std::time::Instant::now();
666        for _ in 0..iterations {
667            let _ = mixed_graph.project_nodes();
668        }
669        let mixed_time = start.elapsed();
670
671        let ratio = mixed_time.as_nanos() as f64 / project_only_time.as_nanos() as f64;
672        println!("project_nodes() — project_only: {:?}, mixed(+2000 code): {:?}, ratio: {:.2}x", project_only_time, mixed_time, ratio);
673
674        // With 2000 code nodes, filtering should scan all nodes but it's still linear
675        // We allow some overhead but < 50x would indicate something is wrong  
676        // In practice, 2050 nodes vs 50 nodes → ~41x scan, but still sub-millisecond
677        assert!(mixed_time.as_millis() < 100, "project_nodes() on 2050-node graph should be < 100ms for {} iters, got {:?}", iterations, mixed_time);
678
679        // Also benchmark summary() which does status counting
680        let start = std::time::Instant::now();
681        for _ in 0..iterations {
682            let _ = project_graph.summary();
683        }
684        let summary_project = start.elapsed();
685
686        let start = std::time::Instant::now();
687        for _ in 0..iterations {
688            let _ = mixed_graph.summary();
689        }
690        let summary_mixed = start.elapsed();
691
692        let summary_ratio = summary_mixed.as_nanos() as f64 / summary_project.as_nanos() as f64;
693        println!("summary() — project_only: {:?}, mixed: {:?}, ratio: {:.2}x", summary_project, summary_mixed, summary_ratio);
694
695        assert!(summary_mixed.as_millis() < 100, "summary() on 2050-node graph should be < 100ms for {} iters", iterations);
696    }
697
698    #[test]
699    fn test_graph_to_codegraph_roundtrip() {
700        use crate::code_graph::{CodeGraph, CodeNode, CodeEdge, NodeKind, EdgeRelation};
701
702        // Build a CodeGraph
703        let mut cg = CodeGraph {
704            nodes: vec![
705                CodeNode {
706                    id: "file:src/main.rs".to_string(),
707                    kind: NodeKind::File,
708                    name: "main.rs".to_string(),
709                    file_path: "src/main.rs".to_string(),
710                    line: None,
711                    decorators: vec![],
712                    signature: None,
713                    docstring: None,
714                    line_count: 100,
715                    is_test: false,
716                    visibility: None,
717                    lang: Some("rust".to_string()),
718                    body_hash: None,
719                    end_line: None,
720                    complexity: None,
721                                    },
722                CodeNode {
723                    id: "fn:src/main.rs:main".to_string(),
724                    kind: NodeKind::Function,
725                    name: "main".to_string(),
726                    file_path: "src/main.rs".to_string(),
727                    line: Some(10),
728                    decorators: vec!["#[tokio::main]".to_string()],
729                    signature: Some("async fn main() -> Result<()>".to_string()),
730                    docstring: Some("Entry point".to_string()),
731                    line_count: 50,
732                    is_test: false,
733                    visibility: Some("pub".to_string()),
734                    lang: Some("rust".to_string()),
735                    body_hash: Some("abc123".to_string()),
736                    end_line: Some(60),
737                    complexity: None,
738                                    },
739                CodeNode {
740                    id: "class:src/lib.rs:Config".to_string(),
741                    kind: NodeKind::Class,
742                    name: "Config".to_string(),
743                    file_path: "src/lib.rs".to_string(),
744                    line: Some(1),
745                    decorators: vec![],
746                    signature: None,
747                    docstring: None,
748                    line_count: 20,
749                    is_test: true,
750                    visibility: Some("pub(crate)".to_string()),
751                    lang: Some("rust".to_string()),
752                    body_hash: Some("def456".to_string()),
753                    end_line: Some(20),
754                    complexity: None,
755                                    },
756            ],
757            edges: vec![
758                CodeEdge {
759                    from: "fn:src/main.rs:main".to_string(),
760                    to: "file:src/main.rs".to_string(),
761                    relation: EdgeRelation::DefinedIn,
762                    weight: 0.5,
763                    call_count: 1,
764                    in_error_path: false,
765                    confidence: 1.0,
766                    call_site_line: None,
767                    call_site_column: None,
768                },
769                CodeEdge {
770                    from: "fn:src/main.rs:main".to_string(),
771                    to: "class:src/lib.rs:Config".to_string(),
772                    relation: EdgeRelation::Calls,
773                    weight: 0.8,
774                    call_count: 3,
775                    in_error_path: true,
776                    confidence: 0.9,
777                    call_site_line: None,
778                    call_site_column: None,
779                },
780            ],
781            outgoing: Default::default(),
782            incoming: Default::default(),
783            node_index: Default::default(),
784        };
785        cg.build_indexes();
786
787        // Forward: CodeGraph → Graph nodes/edges
788        let (graph_nodes, graph_edges) = codegraph_to_graph_nodes(&cg, std::path::Path::new("."));
789        let mut graph = Graph::new();
790        graph.nodes = graph_nodes;
791        graph.edges = graph_edges;
792
793        // Reverse: Graph → CodeGraph
794        let roundtrip = graph_to_codegraph(&graph);
795
796        // Verify nodes
797        assert_eq!(roundtrip.nodes.len(), 3);
798        let file_node = roundtrip.nodes.iter().find(|n| n.id == "file:src/main.rs").unwrap();
799        assert_eq!(file_node.kind, NodeKind::File);
800        assert_eq!(file_node.name, "main.rs");
801        assert_eq!(file_node.line_count, 100);
802        assert!(!file_node.is_test);
803        assert_eq!(file_node.lang.as_deref(), Some("rust"));
804
805        let fn_node = roundtrip.nodes.iter().find(|n| n.id == "fn:src/main.rs:main").unwrap();
806        assert_eq!(fn_node.kind, NodeKind::Function);
807        assert_eq!(fn_node.signature.as_deref(), Some("async fn main() -> Result<()>"));
808        assert_eq!(fn_node.docstring.as_deref(), Some("Entry point"));
809        assert_eq!(fn_node.line, Some(10));
810        assert_eq!(fn_node.decorators, vec!["#[tokio::main]".to_string()]);
811        assert_eq!(fn_node.visibility.as_deref(), Some("pub"));
812        assert_eq!(fn_node.lang.as_deref(), Some("rust"));
813        assert_eq!(fn_node.body_hash.as_deref(), Some("abc123"));
814        assert_eq!(fn_node.end_line, Some(60));
815
816        let class_node = roundtrip.nodes.iter().find(|n| n.id == "class:src/lib.rs:Config").unwrap();
817        assert_eq!(class_node.kind, NodeKind::Class);
818        assert!(class_node.is_test);
819        assert_eq!(class_node.visibility.as_deref(), Some("pub(crate)"));
820        assert_eq!(class_node.body_hash.as_deref(), Some("def456"));
821
822        // Verify edges
823        assert_eq!(roundtrip.edges.len(), 2);
824        let defined_edge = roundtrip.edges.iter().find(|e| e.relation == EdgeRelation::DefinedIn).unwrap();
825        assert_eq!(defined_edge.from, "fn:src/main.rs:main");
826        assert_eq!(defined_edge.to, "file:src/main.rs");
827
828        let calls_edge = roundtrip.edges.iter().find(|e| e.relation == EdgeRelation::Calls).unwrap();
829        assert_eq!(calls_edge.call_count, 3);
830        assert!(calls_edge.in_error_path);
831        assert!((calls_edge.weight - 0.8).abs() < 0.01);
832        assert!((calls_edge.confidence - 0.9).abs() < 0.01);
833
834        // Verify indexes were built
835        assert!(!roundtrip.node_index.is_empty());
836        assert!(!roundtrip.outgoing.is_empty());
837    }
838}