Skip to main content

nusy_codegraph/
semantic_diff.rs

1//! Semantic diff — enriches object-level diffs with structural context.
2//!
3//! Takes a `CodeDiffResult` (which nodes changed) and produces a `SemanticDiff`
4//! that adds:
5//! - **Impact analysis**: which edges are affected by each change
6//! - **Containment context**: parent/child relationships for changed nodes
7//! - **Change classification**: API-breaking vs internal, signature vs body
8//! - **Human-readable summary**: grouped by file/module for review
9
10use crate::git_tools::{CodeDiffChangeType, CodeDiffEntry, CodeDiffResult};
11use crate::schema::{CodeEdgePredicate, CodeNodeKind, edge_col, extract_file_path, node_col};
12use arrow::array::{Array, RecordBatch, StringArray};
13use std::collections::{HashMap, HashSet};
14
15/// A semantically enriched diff.
16#[derive(Debug, Clone)]
17pub struct SemanticDiff {
18    /// Enriched entries (one per changed node).
19    pub entries: Vec<SemanticDiffEntry>,
20    /// Edges affected by the changes (broken imports, stale calls, etc.).
21    pub affected_edges: Vec<AffectedEdge>,
22    /// Summary statistics.
23    pub stats: DiffStats,
24}
25
26/// A single enriched diff entry.
27#[derive(Debug, Clone)]
28pub struct SemanticDiffEntry {
29    /// The underlying object-level diff entry.
30    pub diff: CodeDiffEntry,
31    /// Classification of the change.
32    pub classification: ChangeClassification,
33    /// Parent node ID (containment).
34    pub parent_id: Option<String>,
35    /// Child node IDs that are contained within this changed node.
36    pub children: Vec<String>,
37    /// File path extracted from the node ID.
38    pub file_path: Option<String>,
39}
40
41/// Classification of a code change.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum ChangeClassification {
44    /// Public API change (function/class/method added/removed/signature changed).
45    ApiBreaking,
46    /// Internal implementation change (body modified, private function).
47    Internal,
48    /// New code (added node).
49    Addition,
50    /// Code removal.
51    Deletion,
52    /// Test change.
53    TestChange,
54}
55
56impl std::fmt::Display for ChangeClassification {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        match self {
59            Self::ApiBreaking => f.write_str("API-BREAKING"),
60            Self::Internal => f.write_str("internal"),
61            Self::Addition => f.write_str("addition"),
62            Self::Deletion => f.write_str("deletion"),
63            Self::TestChange => f.write_str("test"),
64        }
65    }
66}
67
68/// An edge affected by the diff (e.g., a call to a removed function).
69#[derive(Debug, Clone)]
70pub struct AffectedEdge {
71    pub source_id: String,
72    pub target_id: String,
73    pub predicate: String,
74    /// Why this edge is affected.
75    pub reason: AffectedReason,
76}
77
78/// Why an edge is affected.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum AffectedReason {
81    /// The target was removed — this edge is now dangling.
82    TargetRemoved,
83    /// The source was removed — this edge is now dangling.
84    SourceRemoved,
85    /// The target was modified — callers may need updating.
86    TargetModified,
87    /// The source was modified — callees may be called differently.
88    SourceModified,
89}
90
91impl std::fmt::Display for AffectedReason {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        match self {
94            Self::TargetRemoved => f.write_str("target removed"),
95            Self::SourceRemoved => f.write_str("source removed"),
96            Self::TargetModified => f.write_str("target modified"),
97            Self::SourceModified => f.write_str("source modified"),
98        }
99    }
100}
101
102/// Summary statistics for a semantic diff.
103#[derive(Debug, Clone, Default)]
104pub struct DiffStats {
105    pub added: usize,
106    pub removed: usize,
107    pub modified: usize,
108    pub api_breaking: usize,
109    pub test_changes: usize,
110    pub affected_edges: usize,
111    pub files_touched: usize,
112}
113
114impl std::fmt::Display for DiffStats {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        write!(
117            f,
118            "{} added, {} modified, {} removed ({} API-breaking), {} test changes, {} affected edges across {} files",
119            self.added,
120            self.modified,
121            self.removed,
122            self.api_breaking,
123            self.test_changes,
124            self.affected_edges,
125            self.files_touched,
126        )
127    }
128}
129
130/// Compute a semantic diff from an object-level diff and the CodeGraph state.
131///
132/// `diff` is the object-level diff (from `codegraph_diff`).
133/// `nodes_batch` is the head CodeNodes batch (current state).
134/// `edges_batch` is the head CodeEdges batch (current state).
135pub fn semantic_diff(
136    diff: &CodeDiffResult,
137    nodes_batch: &RecordBatch,
138    edges_batch: &RecordBatch,
139) -> SemanticDiff {
140    // Build lookup maps from the nodes batch
141    let parent_map = build_parent_map(nodes_batch);
142    let children_map = build_children_map(nodes_batch);
143    let kind_map = build_kind_map(nodes_batch);
144
145    // Collect changed node IDs by change type
146    let mut removed_ids: HashSet<&str> = HashSet::new();
147    let mut modified_ids: HashSet<&str> = HashSet::new();
148    let mut added_ids: HashSet<&str> = HashSet::new();
149
150    for entry in &diff.entries {
151        match entry.change_type {
152            CodeDiffChangeType::Removed => {
153                removed_ids.insert(&entry.node_id);
154            }
155            CodeDiffChangeType::Modified => {
156                modified_ids.insert(&entry.node_id);
157            }
158            CodeDiffChangeType::Added => {
159                added_ids.insert(&entry.node_id);
160            }
161        }
162    }
163
164    // Enrich each diff entry
165    let entries: Vec<SemanticDiffEntry> = diff
166        .entries
167        .iter()
168        .map(|entry| {
169            let parent_id = parent_map.get(entry.node_id.as_str()).cloned();
170            let children = children_map
171                .get(entry.node_id.as_str())
172                .cloned()
173                .unwrap_or_default();
174            let file_path = extract_file_path(&entry.node_id);
175            let classification = classify_change(entry, &kind_map);
176
177            SemanticDiffEntry {
178                diff: entry.clone(),
179                classification,
180                parent_id,
181                children,
182                file_path,
183            }
184        })
185        .collect();
186
187    // Find affected edges
188    let affected_edges = find_affected_edges(edges_batch, &removed_ids, &modified_ids);
189
190    // Compute stats
191    let mut files: HashSet<&str> = HashSet::new();
192    let mut stats = DiffStats {
193        affected_edges: affected_edges.len(),
194        ..Default::default()
195    };
196
197    for entry in &entries {
198        match entry.diff.change_type {
199            CodeDiffChangeType::Added => stats.added += 1,
200            CodeDiffChangeType::Removed => stats.removed += 1,
201            CodeDiffChangeType::Modified => stats.modified += 1,
202        }
203        if entry.classification == ChangeClassification::ApiBreaking {
204            stats.api_breaking += 1;
205        }
206        if entry.classification == ChangeClassification::TestChange {
207            stats.test_changes += 1;
208        }
209        if let Some(ref fp) = entry.file_path {
210            files.insert(fp.as_str());
211        }
212    }
213    stats.files_touched = files.len();
214
215    SemanticDiff {
216        entries,
217        affected_edges,
218        stats,
219    }
220}
221
222/// Format a semantic diff as a human-readable review summary.
223pub fn format_semantic_diff(diff: &SemanticDiff) -> String {
224    let mut out = String::new();
225
226    out.push_str(&format!("## Semantic Diff Summary\n\n{}\n\n", diff.stats));
227
228    // Group entries by file
229    let mut by_file: HashMap<String, Vec<&SemanticDiffEntry>> = HashMap::new();
230    for entry in &diff.entries {
231        let file = entry
232            .file_path
233            .clone()
234            .unwrap_or_else(|| "(unknown)".to_string());
235        by_file.entry(file).or_default().push(entry);
236    }
237
238    let mut files: Vec<&String> = by_file.keys().collect();
239    files.sort();
240
241    for file in files {
242        let entries = &by_file[file];
243        out.push_str(&format!("### {file}\n\n"));
244        for entry in entries {
245            let symbol = match entry.diff.change_type {
246                CodeDiffChangeType::Added => "+",
247                CodeDiffChangeType::Removed => "-",
248                CodeDiffChangeType::Modified => "~",
249            };
250            out.push_str(&format!(
251                "  {symbol} {} `{}` ({})\n",
252                entry.diff.kind, entry.diff.name, entry.classification,
253            ));
254        }
255        out.push('\n');
256    }
257
258    if !diff.affected_edges.is_empty() {
259        out.push_str("### Affected Edges\n\n");
260        for edge in &diff.affected_edges {
261            out.push_str(&format!(
262                "  ! {} → {} [{}] — {}\n",
263                edge.source_id, edge.target_id, edge.predicate, edge.reason,
264            ));
265        }
266        out.push('\n');
267    }
268
269    out
270}
271
272// ── Internal helpers ────────────────────────────────────────────────────────
273
274/// Build node_id → parent_id map from a CodeNodes batch.
275fn build_parent_map(batch: &RecordBatch) -> HashMap<String, String> {
276    let mut map = HashMap::new();
277    if batch.num_rows() == 0 {
278        return map;
279    }
280
281    let ids = batch
282        .column(node_col::ID)
283        .as_any()
284        .downcast_ref::<StringArray>()
285        .expect("id column");
286    let parents = batch
287        .column(node_col::PARENT_ID)
288        .as_any()
289        .downcast_ref::<StringArray>()
290        .expect("parent_id column");
291
292    for i in 0..batch.num_rows() {
293        if !parents.is_null(i) {
294            map.insert(ids.value(i).to_string(), parents.value(i).to_string());
295        }
296    }
297    map
298}
299
300/// Build parent_id → [child_ids] map.
301fn build_children_map(batch: &RecordBatch) -> HashMap<String, Vec<String>> {
302    let mut map: HashMap<String, Vec<String>> = HashMap::new();
303    if batch.num_rows() == 0 {
304        return map;
305    }
306
307    let ids = batch
308        .column(node_col::ID)
309        .as_any()
310        .downcast_ref::<StringArray>()
311        .expect("id column");
312    let parents = batch
313        .column(node_col::PARENT_ID)
314        .as_any()
315        .downcast_ref::<StringArray>()
316        .expect("parent_id column");
317
318    for i in 0..batch.num_rows() {
319        if !parents.is_null(i) {
320            map.entry(parents.value(i).to_string())
321                .or_default()
322                .push(ids.value(i).to_string());
323        }
324    }
325    map
326}
327
328/// Build node_id → CodeNodeKind string map.
329fn build_kind_map(batch: &RecordBatch) -> HashMap<String, String> {
330    let mut map = HashMap::new();
331    if batch.num_rows() == 0 {
332        return map;
333    }
334
335    let ids = batch
336        .column(node_col::ID)
337        .as_any()
338        .downcast_ref::<StringArray>()
339        .expect("id column");
340    let kind_col = batch.column(node_col::KIND);
341    let kind_dict = kind_col
342        .as_any()
343        .downcast_ref::<arrow::array::Int8DictionaryArray>()
344        .expect("kind dict");
345    let kind_values = kind_dict
346        .values()
347        .as_any()
348        .downcast_ref::<StringArray>()
349        .expect("kind values");
350
351    for i in 0..batch.num_rows() {
352        let kind_key = kind_dict.keys().value(i) as usize;
353        map.insert(
354            ids.value(i).to_string(),
355            kind_values.value(kind_key).to_string(),
356        );
357    }
358    map
359}
360
361/// Classify a change based on the node kind and change type.
362fn classify_change(
363    entry: &CodeDiffEntry,
364    kind_map: &HashMap<String, String>,
365) -> ChangeClassification {
366    match entry.change_type {
367        CodeDiffChangeType::Added => {
368            if entry.kind == CodeNodeKind::Test.as_str() {
369                ChangeClassification::TestChange
370            } else {
371                ChangeClassification::Addition
372            }
373        }
374        CodeDiffChangeType::Removed => {
375            if entry.kind == CodeNodeKind::Test.as_str() {
376                ChangeClassification::TestChange
377            } else {
378                // Removing a public function/class/method is API-breaking
379                match entry.kind.as_str() {
380                    "function" | "class" | "method" => ChangeClassification::ApiBreaking,
381                    _ => ChangeClassification::Deletion,
382                }
383            }
384        }
385        CodeDiffChangeType::Modified => {
386            if entry.kind == CodeNodeKind::Test.as_str() {
387                ChangeClassification::TestChange
388            } else {
389                // Modified public API items are potentially breaking
390                // (we can't distinguish signature vs body changes at this level,
391                // but the hash changed so the body definitely changed)
392                match entry.kind.as_str() {
393                    "function" | "class" | "method" => {
394                        // Check if this is a nested (private) item
395                        if let Some(kind) = kind_map.get(&entry.node_id) {
396                            if kind == "method" {
397                                // Methods inside classes — internal by default
398                                ChangeClassification::Internal
399                            } else {
400                                ChangeClassification::ApiBreaking
401                            }
402                        } else {
403                            ChangeClassification::ApiBreaking
404                        }
405                    }
406                    _ => ChangeClassification::Internal,
407                }
408            }
409        }
410    }
411}
412
413/// Find edges affected by node removals and modifications.
414fn find_affected_edges(
415    edges_batch: &RecordBatch,
416    removed_ids: &HashSet<&str>,
417    modified_ids: &HashSet<&str>,
418) -> Vec<AffectedEdge> {
419    if edges_batch.num_rows() == 0 {
420        return Vec::new();
421    }
422
423    let sources = edges_batch
424        .column(edge_col::SOURCE_ID)
425        .as_any()
426        .downcast_ref::<StringArray>()
427        .expect("source_id");
428    let targets = edges_batch
429        .column(edge_col::TARGET_ID)
430        .as_any()
431        .downcast_ref::<StringArray>()
432        .expect("target_id");
433    let pred_col = edges_batch.column(edge_col::PREDICATE);
434    let pred_dict = pred_col
435        .as_any()
436        .downcast_ref::<arrow::array::Int8DictionaryArray>()
437        .expect("predicate dict");
438    let pred_values = pred_dict
439        .values()
440        .as_any()
441        .downcast_ref::<StringArray>()
442        .expect("pred values");
443
444    // Check weights for tombstoned edges (weight == -1)
445    let weights = edges_batch
446        .column(edge_col::WEIGHT)
447        .as_any()
448        .downcast_ref::<arrow::array::Float32Array>()
449        .expect("weight");
450
451    let mut affected = Vec::new();
452
453    for i in 0..edges_batch.num_rows() {
454        // Skip tombstoned edges
455        if !weights.is_null(i) && weights.value(i) < 0.0 {
456            continue;
457        }
458
459        let source = sources.value(i);
460        let target = targets.value(i);
461        let pred_key = pred_dict.keys().value(i) as usize;
462        let predicate = pred_values.value(pred_key).to_string();
463
464        // Skip containment edges — they're structural, not semantic
465        if predicate == CodeEdgePredicate::Contains.as_str() {
466            continue;
467        }
468
469        if removed_ids.contains(target) {
470            affected.push(AffectedEdge {
471                source_id: source.to_string(),
472                target_id: target.to_string(),
473                predicate,
474                reason: AffectedReason::TargetRemoved,
475            });
476        } else if removed_ids.contains(source) {
477            affected.push(AffectedEdge {
478                source_id: source.to_string(),
479                target_id: target.to_string(),
480                predicate,
481                reason: AffectedReason::SourceRemoved,
482            });
483        } else if modified_ids.contains(target) {
484            affected.push(AffectedEdge {
485                source_id: source.to_string(),
486                target_id: target.to_string(),
487                predicate,
488                reason: AffectedReason::TargetModified,
489            });
490        } else if modified_ids.contains(source) {
491            affected.push(AffectedEdge {
492                source_id: source.to_string(),
493                target_id: target.to_string(),
494                predicate,
495                reason: AffectedReason::SourceModified,
496            });
497        }
498    }
499
500    // Sort for deterministic output
501    affected.sort_by(|a, b| {
502        a.source_id
503            .cmp(&b.source_id)
504            .then_with(|| a.target_id.cmp(&b.target_id))
505    });
506
507    affected
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513    use crate::git_tools::codegraph_diff;
514    use crate::schema::{
515        CodeEdge, CodeEdgePredicate, CodeNode, CodeNodeKind, build_code_edges_batch,
516        build_code_nodes_batch,
517    };
518
519    fn base_nodes() -> Vec<CodeNode> {
520        vec![
521            CodeNode {
522                id: "mod:brain/signal.py".into(),
523                kind: CodeNodeKind::Module,
524                parent_id: None,
525                name: "signal".into(),
526                signature: None,
527                docstring: None,
528                body_hash: Some("mod_hash".into()),
529                body: None,
530                loc: Some(100),
531                cyclomatic_complexity: None,
532                coverage_pct: None,
533                last_modified: None,
534                ..Default::default()
535            },
536            CodeNode {
537                id: "func:brain/signal.py::fuse".into(),
538                kind: CodeNodeKind::Function,
539                parent_id: Some("mod:brain/signal.py".into()),
540                name: "fuse".into(),
541                signature: Some("def fuse(signals)".into()),
542                docstring: Some("Fuse signals.".into()),
543                body_hash: Some("fuse_v1".into()),
544                body: None,
545                loc: Some(30),
546                cyclomatic_complexity: Some(5),
547                coverage_pct: Some(0.80),
548                last_modified: None,
549                ..Default::default()
550            },
551            CodeNode {
552                id: "func:brain/signal.py::validate".into(),
553                kind: CodeNodeKind::Function,
554                parent_id: Some("mod:brain/signal.py".into()),
555                name: "validate".into(),
556                signature: Some("def validate(sig)".into()),
557                docstring: None,
558                body_hash: Some("val_v1".into()),
559                body: None,
560                loc: Some(15),
561                cyclomatic_complexity: Some(2),
562                coverage_pct: Some(0.90),
563                last_modified: None,
564                ..Default::default()
565            },
566            CodeNode {
567                id: "test:brain/signal.py::test_fuse".into(),
568                kind: CodeNodeKind::Test,
569                parent_id: Some("mod:brain/signal.py".into()),
570                name: "test_fuse".into(),
571                signature: None,
572                docstring: None,
573                body_hash: Some("test_v1".into()),
574                body: None,
575                loc: Some(10),
576                cyclomatic_complexity: Some(1),
577                coverage_pct: None,
578                last_modified: None,
579                ..Default::default()
580            },
581        ]
582    }
583
584    fn base_edges() -> Vec<CodeEdge> {
585        vec![
586            CodeEdge {
587                source_id: "func:brain/signal.py::fuse".into(),
588                target_id: "func:brain/signal.py::validate".into(),
589                predicate: CodeEdgePredicate::Calls,
590                weight: Some(2.0),
591                commit_id: None,
592            },
593            CodeEdge {
594                source_id: "test:brain/signal.py::test_fuse".into(),
595                target_id: "func:brain/signal.py::fuse".into(),
596                predicate: CodeEdgePredicate::Tests,
597                weight: Some(1.0),
598                commit_id: None,
599            },
600            CodeEdge {
601                source_id: "mod:brain/signal.py".into(),
602                target_id: "func:brain/signal.py::fuse".into(),
603                predicate: CodeEdgePredicate::Contains,
604                weight: None,
605                commit_id: None,
606            },
607        ]
608    }
609
610    #[test]
611    fn test_semantic_diff_no_changes() {
612        let nodes = build_code_nodes_batch(&base_nodes()).unwrap();
613        let edges = build_code_edges_batch(&base_edges()).unwrap();
614        let diff = codegraph_diff(&nodes, &nodes).unwrap();
615
616        let result = semantic_diff(&diff, &nodes, &edges);
617        assert!(result.entries.is_empty());
618        assert!(result.affected_edges.is_empty());
619        assert_eq!(result.stats.files_touched, 0);
620    }
621
622    #[test]
623    fn test_semantic_diff_modified_function() {
624        let base = build_code_nodes_batch(&base_nodes()).unwrap();
625        let mut head_nodes = base_nodes();
626        head_nodes[1].body_hash = Some("fuse_v2".into()); // modify fuse
627        let head = build_code_nodes_batch(&head_nodes).unwrap();
628        let edges = build_code_edges_batch(&base_edges()).unwrap();
629
630        let diff = codegraph_diff(&base, &head).unwrap();
631        let result = semantic_diff(&diff, &head, &edges);
632
633        assert_eq!(result.entries.len(), 1);
634        assert_eq!(result.entries[0].diff.name, "fuse");
635        assert_eq!(
636            result.entries[0].classification,
637            ChangeClassification::ApiBreaking
638        );
639        assert_eq!(
640            result.entries[0].parent_id,
641            Some("mod:brain/signal.py".to_string())
642        );
643        assert_eq!(
644            result.entries[0].file_path,
645            Some("brain/signal.py".to_string())
646        );
647
648        // fuse is modified → edges where fuse is source or target are affected
649        // fuse→validate (source modified), test_fuse→fuse (target modified)
650        // contains edge is skipped
651        assert_eq!(result.affected_edges.len(), 2);
652        assert_eq!(result.stats.api_breaking, 1);
653        assert_eq!(result.stats.files_touched, 1);
654    }
655
656    #[test]
657    fn test_semantic_diff_removed_function() {
658        let base = build_code_nodes_batch(&base_nodes()).unwrap();
659        // Remove validate (keep fuse, module, test)
660        let head_nodes: Vec<CodeNode> = base_nodes()
661            .into_iter()
662            .filter(|n| n.name != "validate")
663            .collect();
664        let head = build_code_nodes_batch(&head_nodes).unwrap();
665        let edges = build_code_edges_batch(&base_edges()).unwrap();
666
667        let diff = codegraph_diff(&base, &head).unwrap();
668        let result = semantic_diff(&diff, &head, &edges);
669
670        assert_eq!(result.entries.len(), 1);
671        assert_eq!(result.entries[0].diff.name, "validate");
672        assert_eq!(
673            result.entries[0].classification,
674            ChangeClassification::ApiBreaking
675        );
676
677        // fuse→validate edge is affected (target removed)
678        assert!(
679            result
680                .affected_edges
681                .iter()
682                .any(|e| e.reason == AffectedReason::TargetRemoved)
683        );
684        assert_eq!(result.stats.removed, 1);
685    }
686
687    #[test]
688    fn test_semantic_diff_added_function() {
689        let base = build_code_nodes_batch(&base_nodes()).unwrap();
690        let mut head_nodes = base_nodes();
691        head_nodes.push(CodeNode {
692            id: "func:brain/signal.py::normalize".into(),
693            kind: CodeNodeKind::Function,
694            parent_id: Some("mod:brain/signal.py".into()),
695            name: "normalize".into(),
696            signature: Some("def normalize(data)".into()),
697            docstring: None,
698            body_hash: Some("norm_v1".into()),
699            body: None,
700            loc: Some(20),
701            cyclomatic_complexity: Some(3),
702            coverage_pct: None,
703            last_modified: None,
704            ..Default::default()
705        });
706        let head = build_code_nodes_batch(&head_nodes).unwrap();
707        let edges = build_code_edges_batch(&base_edges()).unwrap();
708
709        let diff = codegraph_diff(&base, &head).unwrap();
710        let result = semantic_diff(&diff, &head, &edges);
711
712        assert_eq!(result.entries.len(), 1);
713        assert_eq!(result.entries[0].diff.name, "normalize");
714        assert_eq!(
715            result.entries[0].classification,
716            ChangeClassification::Addition
717        );
718        assert_eq!(result.stats.added, 1);
719        // No affected edges — new function isn't referenced yet
720        assert!(result.affected_edges.is_empty());
721    }
722
723    #[test]
724    fn test_semantic_diff_test_change_classified() {
725        let base = build_code_nodes_batch(&base_nodes()).unwrap();
726        let mut head_nodes = base_nodes();
727        head_nodes[3].body_hash = Some("test_v2".into()); // modify test
728        let head = build_code_nodes_batch(&head_nodes).unwrap();
729        let edges = build_code_edges_batch(&base_edges()).unwrap();
730
731        let diff = codegraph_diff(&base, &head).unwrap();
732        let result = semantic_diff(&diff, &head, &edges);
733
734        assert_eq!(result.entries.len(), 1);
735        assert_eq!(
736            result.entries[0].classification,
737            ChangeClassification::TestChange
738        );
739        assert_eq!(result.stats.test_changes, 1);
740    }
741
742    #[test]
743    fn test_semantic_diff_containment_edges_skipped() {
744        let base = build_code_nodes_batch(&base_nodes()).unwrap();
745        let mut head_nodes = base_nodes();
746        head_nodes[1].body_hash = Some("fuse_v2".into());
747        let head = build_code_nodes_batch(&head_nodes).unwrap();
748
749        // Only containment edges
750        let edges = build_code_edges_batch(&[CodeEdge {
751            source_id: "mod:brain/signal.py".into(),
752            target_id: "func:brain/signal.py::fuse".into(),
753            predicate: CodeEdgePredicate::Contains,
754            weight: None,
755            commit_id: None,
756        }])
757        .unwrap();
758
759        let diff = codegraph_diff(&base, &head).unwrap();
760        let result = semantic_diff(&diff, &head, &edges);
761
762        // Containment edges are structural, not affected
763        assert!(result.affected_edges.is_empty());
764    }
765
766    #[test]
767    fn test_extract_file_path() {
768        assert_eq!(
769            extract_file_path("func:brain/signal.py::fuse"),
770            Some("brain/signal.py".to_string())
771        );
772        assert_eq!(
773            extract_file_path("mod:brain/signal.py"),
774            Some("brain/signal.py".to_string())
775        );
776        assert_eq!(
777            extract_file_path("method:brain/store.py::Dual::get"),
778            Some("brain/store.py".to_string())
779        );
780        assert_eq!(extract_file_path("no-prefix"), None);
781    }
782
783    #[test]
784    fn test_format_semantic_diff_readable() {
785        let base = build_code_nodes_batch(&base_nodes()).unwrap();
786        let mut head_nodes = base_nodes();
787        head_nodes[1].body_hash = Some("fuse_v2".into());
788        head_nodes.push(CodeNode {
789            id: "func:brain/signal.py::helper".into(),
790            kind: CodeNodeKind::Function,
791            parent_id: Some("mod:brain/signal.py".into()),
792            name: "helper".into(),
793            signature: None,
794            docstring: None,
795            body_hash: Some("h_v1".into()),
796            body: None,
797            loc: Some(5),
798            cyclomatic_complexity: Some(1),
799            coverage_pct: None,
800            last_modified: None,
801            ..Default::default()
802        });
803        let head = build_code_nodes_batch(&head_nodes).unwrap();
804        let edges = build_code_edges_batch(&base_edges()).unwrap();
805
806        let diff = codegraph_diff(&base, &head).unwrap();
807        let result = semantic_diff(&diff, &head, &edges);
808        let formatted = format_semantic_diff(&result);
809
810        assert!(formatted.contains("## Semantic Diff Summary"));
811        assert!(formatted.contains("brain/signal.py"));
812        assert!(formatted.contains("fuse"));
813        assert!(formatted.contains("helper"));
814    }
815
816    #[test]
817    fn test_semantic_diff_multiple_files() {
818        let base = build_code_nodes_batch(&base_nodes()).unwrap();
819        let mut head_nodes = base_nodes();
820        head_nodes[1].body_hash = Some("fuse_v2".into()); // brain/signal.py
821        head_nodes.push(CodeNode {
822            id: "func:brain/store.py::save".into(),
823            kind: CodeNodeKind::Function,
824            parent_id: None,
825            name: "save".into(),
826            signature: None,
827            docstring: None,
828            body_hash: Some("save_v1".into()),
829            body: None,
830            loc: Some(10),
831            cyclomatic_complexity: Some(1),
832            coverage_pct: None,
833            last_modified: None,
834            ..Default::default()
835        });
836        let head = build_code_nodes_batch(&head_nodes).unwrap();
837        let edges = build_code_edges_batch(&base_edges()).unwrap();
838
839        let diff = codegraph_diff(&base, &head).unwrap();
840        let result = semantic_diff(&diff, &head, &edges);
841
842        assert_eq!(result.stats.files_touched, 2);
843    }
844}