1use 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#[derive(Debug, Clone)]
17pub struct SemanticDiff {
18 pub entries: Vec<SemanticDiffEntry>,
20 pub affected_edges: Vec<AffectedEdge>,
22 pub stats: DiffStats,
24}
25
26#[derive(Debug, Clone)]
28pub struct SemanticDiffEntry {
29 pub diff: CodeDiffEntry,
31 pub classification: ChangeClassification,
33 pub parent_id: Option<String>,
35 pub children: Vec<String>,
37 pub file_path: Option<String>,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum ChangeClassification {
44 ApiBreaking,
46 Internal,
48 Addition,
50 Deletion,
52 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#[derive(Debug, Clone)]
70pub struct AffectedEdge {
71 pub source_id: String,
72 pub target_id: String,
73 pub predicate: String,
74 pub reason: AffectedReason,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum AffectedReason {
81 TargetRemoved,
83 SourceRemoved,
85 TargetModified,
87 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#[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
130pub fn semantic_diff(
136 diff: &CodeDiffResult,
137 nodes_batch: &RecordBatch,
138 edges_batch: &RecordBatch,
139) -> SemanticDiff {
140 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 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 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 let affected_edges = find_affected_edges(edges_batch, &removed_ids, &modified_ids);
189
190 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
222pub 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 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
272fn 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
300fn 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
328fn 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
361fn 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 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 match entry.kind.as_str() {
393 "function" | "class" | "method" => {
394 if let Some(kind) = kind_map.get(&entry.node_id) {
396 if kind == "method" {
397 ChangeClassification::Internal
399 } else {
400 ChangeClassification::ApiBreaking
401 }
402 } else {
403 ChangeClassification::ApiBreaking
404 }
405 }
406 _ => ChangeClassification::Internal,
407 }
408 }
409 }
410 }
411}
412
413fn 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 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 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 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 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()); 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 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 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 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 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()); 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 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 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()); 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}