1use std::collections::hash_map::DefaultHasher;
42use std::collections::{BTreeMap, BTreeSet, HashMap};
43use std::hash::{Hash, Hasher};
44use std::path::{Path, PathBuf};
45use std::process::Command;
46use std::time::{Duration, Instant};
47
48use tempfile::TempDir;
49
50use super::super::context::L2Context;
51use super::super::types::{AnalyzerStatus, L2AnalyzerOutput};
52use super::super::L2Engine;
53use crate::commands::bugbot::dead::is_test_function;
54use crate::commands::bugbot::types::BugbotFinding;
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58enum TldrCategory {
59 Local,
61 Flow,
63}
64
65#[derive(Debug, Clone)]
67struct TldrCommand {
68 name: &'static str,
70 args: &'static [&'static str],
72 category: TldrCategory,
74}
75
76const TLDR_COMMANDS: &[TldrCommand] = &[
78 TldrCommand {
80 name: "complexity",
81 args: &["complexity"],
82 category: TldrCategory::Local,
83 },
84 TldrCommand {
85 name: "cognitive",
86 args: &["cognitive"],
87 category: TldrCategory::Local,
88 },
89 TldrCommand {
90 name: "contracts",
91 args: &["contracts"],
92 category: TldrCategory::Local,
93 },
94 TldrCommand {
95 name: "smells",
96 args: &["smells"],
97 category: TldrCategory::Local,
98 },
99 TldrCommand {
101 name: "calls",
102 args: &["calls"],
103 category: TldrCategory::Flow,
104 },
105 TldrCommand {
106 name: "deps",
107 args: &["deps"],
108 category: TldrCategory::Flow,
109 },
110 TldrCommand {
111 name: "coupling",
112 args: &["coupling"],
113 category: TldrCategory::Flow,
114 },
115 TldrCommand {
116 name: "cohesion",
117 args: &["cohesion"],
118 category: TldrCategory::Flow,
119 },
120 TldrCommand {
121 name: "dead",
122 args: &["dead"],
123 category: TldrCategory::Flow,
124 },
125];
126
127const FINDING_TYPES: &[&str] = &[
129 "complexity-increase",
130 "cognitive-increase",
131 "contract-removed",
132 "smell-introduced",
133 "call-graph-change",
134 "dependency-change",
135 "coupling-increase",
136 "cohesion-decrease",
137 "dead-code-introduced",
138 "downstream-impact",
139 "breaking-change-risk",
140];
141
142const MAX_OUTPUT_BYTES: usize = 10 * 1024 * 1024; pub struct TldrDifferentialEngine {
156 timeout_secs: u64,
158}
159
160impl TldrDifferentialEngine {
161 pub fn new() -> Self {
163 Self { timeout_secs: 30 }
164 }
165
166 pub fn with_timeout(timeout_secs: u64) -> Self {
168 Self { timeout_secs }
169 }
170
171 fn run_tldr_command(&self, args: &[&str], target: &Path) -> Result<serde_json::Value, String> {
180 let target_str = target.to_string_lossy().to_string();
181 let mut full_args: Vec<String> = args.iter().map(|a| a.to_string()).collect();
182 full_args.push(target_str);
183 full_args.push("--format".to_string());
184 full_args.push("json".to_string());
185 self.run_tldr_raw(&full_args)
186 }
187
188 fn run_tldr_per_function(
193 &self,
194 command: &str,
195 file: &Path,
196 function_name: &str,
197 ) -> Result<serde_json::Value, String> {
198 let file_str = file.to_string_lossy().to_string();
199 let args = vec![
200 command.to_string(),
201 file_str,
202 function_name.to_string(),
203 "--format".to_string(),
204 "json".to_string(),
205 ];
206 self.run_tldr_raw(&args)
207 }
208
209 fn run_tldr_flow_command(
217 &self,
218 cmd_name: &str,
219 args: &[&str],
220 target: &Path,
221 language: &str,
222 ) -> Result<serde_json::Value, String> {
223 let target_str = target.to_string_lossy().to_string();
224 let mut full_args: Vec<String> = args.iter().map(|a| a.to_string()).collect();
225 full_args.push(target_str);
226 full_args.push("--lang".to_string());
227 full_args.push(language.to_string());
228 if cmd_name == "calls" {
231 full_args.push("--respect-ignore".to_string());
232 }
233 full_args.push("--format".to_string());
234 full_args.push("json".to_string());
235 self.run_tldr_raw(&full_args)
236 }
237
238 fn run_tldr_raw(&self, args: &[String]) -> Result<serde_json::Value, String> {
240 let child = Command::new("tldr")
241 .args(args)
242 .stdout(std::process::Stdio::piped())
243 .stderr(std::process::Stdio::piped())
244 .spawn();
245
246 let child = match child {
247 Ok(c) => c,
248 Err(e) => return Err(format!("Failed to spawn 'tldr': {}", e)),
249 };
250
251 let timeout = Duration::from_secs(self.timeout_secs);
253 let child_id = child.id();
254 let timed_out = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
255 let timed_out_clone = timed_out.clone();
256
257 let _watchdog = std::thread::spawn(move || {
258 std::thread::sleep(timeout);
259 timed_out_clone.store(true, std::sync::atomic::Ordering::SeqCst);
260 #[cfg(unix)]
261 unsafe {
262 libc::kill(child_id as libc::pid_t, libc::SIGKILL);
263 }
264 #[cfg(windows)]
265 unsafe {
266 let handle = windows_sys::Win32::System::Threading::OpenProcess(
267 windows_sys::Win32::System::Threading::PROCESS_TERMINATE,
268 0,
269 child_id,
270 );
271 if handle != 0 {
272 windows_sys::Win32::System::Threading::TerminateProcess(handle, 1);
273 windows_sys::Win32::Foundation::CloseHandle(handle);
274 }
275 }
276 });
277
278 let output = child
279 .wait_with_output()
280 .map_err(|e| format!("Failed to read tldr output: {}", e))?;
281
282 if timed_out.load(std::sync::atomic::Ordering::SeqCst) {
283 return Err(format!("Timeout after {}s", self.timeout_secs));
284 }
285
286 let raw_stdout = String::from_utf8_lossy(&output.stdout).to_string();
287 let stdout = if raw_stdout.len() > MAX_OUTPUT_BYTES {
288 let mut truncated = raw_stdout;
289 truncated.truncate(MAX_OUTPUT_BYTES);
290 if let Some(last_newline) = truncated.rfind('\n') {
291 truncated.truncate(last_newline + 1);
292 }
293 truncated
294 } else {
295 raw_stdout
296 };
297
298 if stdout.trim().is_empty() {
299 return Err(format!(
300 "tldr {} produced empty output (exit code: {:?}, stderr: {})",
301 args.first().map(|s| s.as_str()).unwrap_or("?"),
302 output.status.code(),
303 String::from_utf8_lossy(&output.stderr),
304 ));
305 }
306
307 serde_json::from_str(&stdout).map_err(|e| {
308 format!(
309 "Failed to parse tldr JSON: {} (first 200 chars: {:?})",
310 e,
311 &stdout[..stdout.len().min(200)]
312 )
313 })
314 }
315
316 fn analyze_local_commands(
323 &self,
324 file_path: &Path,
325 baseline_source: &str,
326 current_source: &str,
327 partial_reasons: &mut Vec<String>,
328 ) -> Vec<BugbotFinding> {
329 let mut findings = Vec::new();
330
331 let ext = file_path
332 .extension()
333 .and_then(|e| e.to_str())
334 .unwrap_or("py");
335
336 let tmp_dir = match TempDir::new() {
338 Ok(d) => d,
339 Err(e) => {
340 partial_reasons.push(format!("tmpdir creation failed: {}", e));
341 return findings;
342 }
343 };
344
345 let baseline_file = tmp_dir.path().join(format!("baseline.{}", ext));
346 let current_file = tmp_dir.path().join(format!("current.{}", ext));
347
348 if std::fs::write(&baseline_file, baseline_source).is_err() {
349 partial_reasons.push(format!(
350 "write baseline tmpfile failed for {}",
351 file_path.display()
352 ));
353 return findings;
354 }
355 if std::fs::write(¤t_file, current_source).is_err() {
356 partial_reasons.push(format!(
357 "write current tmpfile failed for {}",
358 file_path.display()
359 ));
360 return findings;
361 }
362
363 for cmd_name in &["cognitive", "smells"] {
366 let baseline_result = self.run_tldr_command(&[cmd_name], &baseline_file);
367 let current_result = self.run_tldr_command(&[cmd_name], ¤t_file);
368
369 match (baseline_result, current_result) {
370 (Ok(baseline_json), Ok(current_json)) => {
371 let cmd_findings =
372 self.diff_local_metrics(cmd_name, file_path, &baseline_json, ¤t_json);
373 findings.extend(cmd_findings);
374 }
375 (Err(e), _) | (_, Err(e)) => {
376 partial_reasons.push(format!(
377 "tldr {} failed for {}: {}",
378 cmd_name,
379 file_path.display(),
380 e,
381 ));
382 }
383 }
384 }
385
386 let baseline_funcs = Self::discover_function_names_from_cognitive(
389 &self.run_tldr_command(&["cognitive"], &baseline_file),
390 );
391 let current_funcs = Self::discover_function_names_from_cognitive(
392 &self.run_tldr_command(&["cognitive"], ¤t_file),
393 );
394
395 {
397 let mut baseline_entries: Vec<(String, serde_json::Value)> = Vec::new();
398 for func in &baseline_funcs {
399 match self.run_tldr_per_function("complexity", &baseline_file, func) {
400 Ok(json) => baseline_entries.push((func.clone(), json)),
401 Err(e) => {
402 partial_reasons.push(format!("tldr complexity {} baseline: {}", func, e));
403 }
404 }
405 }
406
407 let mut current_entries: Vec<(String, serde_json::Value)> = Vec::new();
408 for func in ¤t_funcs {
409 match self.run_tldr_per_function("complexity", ¤t_file, func) {
410 Ok(json) => current_entries.push((func.clone(), json)),
411 Err(e) => {
412 partial_reasons.push(format!("tldr complexity {} current: {}", func, e));
413 }
414 }
415 }
416
417 let baseline_agg = Self::aggregate_per_function_complexity(&baseline_entries);
420 let current_agg = Self::aggregate_per_function_complexity(¤t_entries);
421
422 let complexity_findings =
423 self.diff_local_metrics("complexity", file_path, &baseline_agg, ¤t_agg);
424 findings.extend(complexity_findings);
425 }
426
427 {
429 let mut baseline_entries: Vec<(String, serde_json::Value)> = Vec::new();
430 for func in &baseline_funcs {
431 match self.run_tldr_per_function("contracts", &baseline_file, func) {
432 Ok(json) => baseline_entries.push((func.clone(), json)),
433 Err(e) => {
434 partial_reasons.push(format!("tldr contracts {} baseline: {}", func, e));
435 }
436 }
437 }
438
439 let current_func_set: std::collections::HashSet<&str> =
445 current_funcs.iter().map(|s| s.as_str()).collect();
446 let all_current_candidates: Vec<String> = current_funcs
447 .iter()
448 .cloned()
449 .chain(
450 baseline_funcs
451 .iter()
452 .filter(|f| !current_func_set.contains(f.as_str()))
453 .cloned(),
454 )
455 .collect();
456
457 let mut current_entries: Vec<(String, serde_json::Value)> = Vec::new();
458 for func in &all_current_candidates {
459 match self.run_tldr_per_function("contracts", ¤t_file, func) {
460 Ok(json) => current_entries.push((func.clone(), json)),
461 Err(e) => {
462 partial_reasons.push(format!("tldr contracts {} current: {}", func, e));
463 }
464 }
465 }
466
467 let baseline_agg = Self::aggregate_per_function_contracts(&baseline_entries);
468 let current_agg = Self::aggregate_per_function_contracts(¤t_entries);
469
470 let contract_findings = self.diff_contracts(
471 file_path,
472 &baseline_agg,
473 ¤t_agg,
474 &all_current_candidates,
475 );
476 findings.extend(contract_findings);
477 }
478
479 findings
480 }
481
482 fn discover_function_names_from_cognitive(
487 result: &Result<serde_json::Value, String>,
488 ) -> Vec<String> {
489 match result {
490 Ok(json) => Self::extract_function_entries(json)
491 .into_iter()
492 .map(|(name, _)| name)
493 .filter(|name| !is_test_function(name))
494 .collect(),
495 Err(_) => Vec::new(),
496 }
497 }
498
499 fn aggregate_per_function_complexity(
504 entries: &[(String, serde_json::Value)],
505 ) -> serde_json::Value {
506 let functions: Vec<serde_json::Value> = entries
507 .iter()
508 .map(|(name, json)| {
509 let cyclomatic = json
510 .get("cyclomatic")
511 .and_then(|v| v.as_f64())
512 .unwrap_or(0.0);
513 let line = json
514 .get("lines_of_code")
515 .and_then(|v| v.as_u64())
516 .unwrap_or(1);
517 serde_json::json!({
518 "name": name,
519 "cyclomatic": cyclomatic,
520 "line": line,
521 })
522 })
523 .collect();
524 serde_json::json!({ "functions": functions })
525 }
526
527 fn aggregate_per_function_contracts(
531 entries: &[(String, serde_json::Value)],
532 ) -> serde_json::Value {
533 let functions: Vec<serde_json::Value> = entries
534 .iter()
535 .map(|(name, json)| {
536 let preconditions = json
537 .get("preconditions")
538 .cloned()
539 .unwrap_or(serde_json::json!([]));
540 let postconditions = json
541 .get("postconditions")
542 .cloned()
543 .unwrap_or(serde_json::json!([]));
544 serde_json::json!({
545 "name": name,
546 "preconditions": preconditions,
547 "postconditions": postconditions,
548 })
549 })
550 .collect();
551 serde_json::json!({ "functions": functions })
552 }
553
554 fn diff_local_metrics(
560 &self,
561 command_name: &str,
562 file_path: &Path,
563 baseline_json: &serde_json::Value,
564 current_json: &serde_json::Value,
565 ) -> Vec<BugbotFinding> {
566 let mut findings = Vec::new();
567
568 match command_name {
569 "complexity" => {
570 findings.extend(self.diff_numeric_metrics(
571 "complexity-increase",
572 "cyclomatic",
573 file_path,
574 baseline_json,
575 current_json,
576 ));
577 }
578 "cognitive" => {
579 findings.extend(self.diff_numeric_metrics(
580 "cognitive-increase",
581 "cognitive",
582 file_path,
583 baseline_json,
584 current_json,
585 ));
586 }
587 "contracts" => {
588 findings.extend(self.diff_contracts(file_path, baseline_json, current_json, &[]));
593 }
594 "smells" => {
595 findings.extend(self.diff_smells(file_path, baseline_json, current_json));
596 }
597 _ => {}
598 }
599
600 findings
601 }
602
603 fn extract_function_entries(json: &serde_json::Value) -> Vec<(String, &serde_json::Value)> {
608 let mut entries = Vec::new();
609
610 for key in &["functions", "results", "items", "entries", "metrics"] {
612 if let Some(arr) = json.get(key).and_then(|v| v.as_array()) {
613 for item in arr {
614 if let Some(name) = item.get("name").and_then(|n| n.as_str()) {
615 entries.push((name.to_string(), item));
616 }
617 }
618 if !entries.is_empty() {
619 return entries;
620 }
621 }
622 }
623
624 if let Some(arr) = json.as_array() {
626 for item in arr {
627 if let Some(name) = item.get("name").and_then(|n| n.as_str()) {
628 entries.push((name.to_string(), item));
629 }
630 }
631 }
632
633 entries
634 }
635
636 fn diff_numeric_metrics(
641 &self,
642 finding_type: &str,
643 metric_field: &str,
644 file_path: &Path,
645 baseline_json: &serde_json::Value,
646 current_json: &serde_json::Value,
647 ) -> Vec<BugbotFinding> {
648 let mut findings = Vec::new();
649
650 let baseline_entries = Self::extract_function_entries(baseline_json);
651 let current_entries = Self::extract_function_entries(current_json);
652
653 let baseline_map: std::collections::HashMap<&str, &serde_json::Value> = baseline_entries
654 .iter()
655 .map(|(name, val)| (name.as_str(), *val))
656 .collect();
657
658 for (func_name, current_entry) in ¤t_entries {
659 let Some(baseline_entry) = baseline_map.get(func_name.as_str()) else {
660 if let Some(current_val) = current_entry.get(metric_field).and_then(|v| v.as_f64())
662 {
663 if current_val > 10.0 {
664 findings.push(BugbotFinding {
665 finding_type: finding_type.to_string(),
666 severity: "info".to_string(),
667 file: file_path.to_path_buf(),
668 function: func_name.clone(),
669 line: current_entry
670 .get("line")
671 .and_then(|l| l.as_u64())
672 .unwrap_or(1) as usize,
673 message: format!(
674 "New function `{}` has {} = {:.1}",
675 func_name, metric_field, current_val,
676 ),
677 evidence: serde_json::json!({
678 "command": finding_type.replace("-increase", ""),
679 "metric": metric_field,
680 "current_value": current_val,
681 "new_function": true,
682 }),
683 confidence: Some("DETERMINISTIC".to_string()),
684 finding_id: Some(compute_finding_id(
685 finding_type,
686 file_path,
687 func_name,
688 0,
689 )),
690 });
691 }
692 }
693 continue;
694 };
695
696 let baseline_val = baseline_entry
697 .get(metric_field)
698 .and_then(|v| v.as_f64())
699 .unwrap_or(0.0);
700 let current_val = current_entry
701 .get(metric_field)
702 .and_then(|v| v.as_f64())
703 .unwrap_or(0.0);
704
705 if current_val > baseline_val {
706 let delta = current_val - baseline_val;
707
708 let min_delta = match finding_type {
713 "cognitive-increase" => 3.0,
714 "complexity-increase" => 2.0,
715 _ => 1.0,
716 };
717 if delta < min_delta {
718 continue;
719 }
720
721 let pct_increase = if baseline_val > 0.0 {
722 (delta / baseline_val) * 100.0
723 } else {
724 100.0
725 };
726
727 let severity = if pct_increase > 50.0 {
728 "high"
729 } else if pct_increase > 20.0 {
730 "medium"
731 } else {
732 "low"
733 };
734
735 let line = current_entry
736 .get("line")
737 .and_then(|l| l.as_u64())
738 .unwrap_or(1) as usize;
739
740 findings.push(BugbotFinding {
741 finding_type: finding_type.to_string(),
742 severity: severity.to_string(),
743 file: file_path.to_path_buf(),
744 function: func_name.clone(),
745 line,
746 message: format!(
747 "`{}` {} increased by {:.1} ({:.1} -> {:.1}, +{:.0}%)",
748 func_name, metric_field, delta, baseline_val, current_val, pct_increase,
749 ),
750 evidence: serde_json::json!({
751 "command": finding_type.replace("-increase", ""),
752 "metric": metric_field,
753 "old_value": baseline_val,
754 "new_value": current_val,
755 "delta": delta,
756 "pct_increase": pct_increase,
757 }),
758 confidence: Some("DETERMINISTIC".to_string()),
759 finding_id: Some(compute_finding_id(finding_type, file_path, func_name, line)),
760 });
761 }
762 }
763
764 findings
765 }
766
767 fn diff_contracts(
777 &self,
778 file_path: &Path,
779 baseline_json: &serde_json::Value,
780 current_json: &serde_json::Value,
781 known_current_funcs: &[String],
782 ) -> Vec<BugbotFinding> {
783 let mut findings = Vec::new();
784
785 let baseline_entries = Self::extract_function_entries(baseline_json);
786 let current_entries = Self::extract_function_entries(current_json);
787
788 let current_names: std::collections::HashSet<String> = current_entries
789 .iter()
790 .map(|(name, _)| name.clone())
791 .collect();
792
793 let baseline_contract_count = |entry: &serde_json::Value| -> usize {
795 let pre = entry
796 .get("preconditions")
797 .and_then(|v| v.as_array())
798 .map(|a| a.len())
799 .unwrap_or(0);
800 let post = entry
801 .get("postconditions")
802 .and_then(|v| v.as_array())
803 .map(|a| a.len())
804 .unwrap_or(0);
805 pre + post
806 };
807
808 let current_map: std::collections::HashMap<&str, &serde_json::Value> = current_entries
809 .iter()
810 .map(|(name, val)| (name.as_str(), *val))
811 .collect();
812
813 for (func_name, baseline_entry) in &baseline_entries {
814 let b_count = baseline_contract_count(baseline_entry);
815 if b_count == 0 {
816 continue;
817 }
818
819 if let Some(current_entry) = current_map.get(func_name.as_str()) {
820 let c_count = baseline_contract_count(current_entry);
821 if c_count < b_count {
822 let removed = b_count - c_count;
823 findings.push(BugbotFinding {
824 finding_type: "contract-removed".to_string(),
825 severity: "medium".to_string(),
826 file: file_path.to_path_buf(),
827 function: func_name.clone(),
828 line: 1,
829 message: format!(
830 "`{}` lost {} contract(s) ({} -> {})",
831 func_name, removed, b_count, c_count,
832 ),
833 evidence: serde_json::json!({
834 "command": "contracts",
835 "baseline_contracts": b_count,
836 "current_contracts": c_count,
837 "removed": removed,
838 }),
839 confidence: Some("DETERMINISTIC".to_string()),
840 finding_id: Some(compute_finding_id(
841 "contract-removed",
842 file_path,
843 func_name,
844 1,
845 )),
846 });
847 }
848 } else if !current_names.contains(func_name.as_str()) {
849 if known_current_funcs.iter().any(|f| f == func_name) {
853 continue;
854 }
855 findings.push(BugbotFinding {
857 finding_type: "contract-removed".to_string(),
858 severity: "high".to_string(),
859 file: file_path.to_path_buf(),
860 function: func_name.clone(),
861 line: 1,
862 message: format!(
863 "`{}` with {} contract(s) was removed entirely",
864 func_name, b_count,
865 ),
866 evidence: serde_json::json!({
867 "command": "contracts",
868 "baseline_contracts": b_count,
869 "current_contracts": 0,
870 "function_deleted": true,
871 }),
872 confidence: Some("DETERMINISTIC".to_string()),
873 finding_id: Some(compute_finding_id(
874 "contract-removed",
875 file_path,
876 func_name,
877 0,
878 )),
879 });
880 }
881 }
882
883 findings
884 }
885
886 fn diff_smells(
891 &self,
892 file_path: &Path,
893 baseline_json: &serde_json::Value,
894 current_json: &serde_json::Value,
895 ) -> Vec<BugbotFinding> {
896 let mut findings = Vec::new();
897
898 let count_smells = |json: &serde_json::Value| -> usize {
899 for key in &["smells", "issues", "findings", "results"] {
901 if let Some(arr) = json.get(key).and_then(|v| v.as_array()) {
902 return arr.len();
903 }
904 }
905 if let Some(arr) = json.as_array() {
906 return arr.len();
907 }
908 0
909 };
910
911 let baseline_count = count_smells(baseline_json);
912 let current_count = count_smells(current_json);
913
914 if baseline_count == 0 {
916 return findings;
917 }
918
919 if current_count > baseline_count {
920 let introduced = current_count - baseline_count;
921
922 let current_smells: Vec<&serde_json::Value> = {
924 let mut result = Vec::new();
925 for key in &["smells", "issues", "findings", "results"] {
926 if let Some(arr) = current_json.get(key).and_then(|v| v.as_array()) {
927 result = arr.iter().collect();
928 break;
929 }
930 }
931 if result.is_empty() {
932 if let Some(arr) = current_json.as_array() {
933 result = arr.iter().collect();
934 }
935 }
936 result
937 };
938
939 const SUPPRESSED_SMELL_TYPES: &[&str] = &["message_chain", "long_parameter_list"];
943
944 for (i, smell) in current_smells.iter().rev().take(introduced).enumerate() {
946 let smell_type = smell
947 .get("smell_type")
948 .or_else(|| smell.get("type"))
949 .or_else(|| smell.get("kind"))
950 .and_then(|v| v.as_str())
951 .unwrap_or("unknown");
952
953 if SUPPRESSED_SMELL_TYPES.contains(&smell_type) {
954 continue;
955 }
956
957 let func_name = smell
958 .get("function")
959 .or_else(|| smell.get("name"))
960 .and_then(|v| v.as_str())
961 .unwrap_or("(file-level)");
962 let line = smell.get("line").and_then(|l| l.as_u64()).unwrap_or(1) as usize;
963
964 let severity = match smell_type {
967 "god_class" | "feature_envy" | "data_clump" => "medium",
968 _ => "low",
969 };
970
971 findings.push(BugbotFinding {
972 finding_type: "smell-introduced".to_string(),
973 severity: severity.to_string(),
974 file: file_path.to_path_buf(),
975 function: func_name.to_string(),
976 line,
977 message: format!(
978 "New code smell `{}` introduced (total smells: {} -> {})",
979 smell_type, baseline_count, current_count,
980 ),
981 evidence: serde_json::json!({
982 "command": "smells",
983 "smell_type": smell_type,
984 "baseline_smell_count": baseline_count,
985 "current_smell_count": current_count,
986 "introduced": introduced,
987 "index": i,
988 }),
989 confidence: Some("DETERMINISTIC".to_string()),
990 finding_id: Some(compute_finding_id(
991 "smell-introduced",
992 file_path,
993 func_name,
994 line,
995 )),
996 });
997 }
998 }
999
1000 findings
1001 }
1002
1003 fn analyze_flow_commands(
1017 &self,
1018 project: &Path,
1019 base_ref: &str,
1020 language: &str,
1021 current_calls_json: Option<&serde_json::Value>,
1022 partial_reasons: &mut Vec<String>,
1023 ) -> Vec<BugbotFinding> {
1024 let mut findings = Vec::new();
1025
1026 let flow_engine = TldrDifferentialEngine::with_timeout(300);
1030
1031 for cmd in TLDR_COMMANDS
1033 .iter()
1034 .filter(|c| c.category == TldrCategory::Flow && c.name == "dead")
1035 {
1036 match flow_engine.run_tldr_flow_command(cmd.name, cmd.args, project, language) {
1037 Ok(json) => {
1038 let dead_count = Self::count_dead_code_entries(&json);
1039 if dead_count > 0 {
1040 findings.push(BugbotFinding {
1041 finding_type: "dead-code-introduced".to_string(),
1042 severity: "info".to_string(),
1043 file: PathBuf::from("(project)"),
1044 function: "(project-level)".to_string(),
1045 line: 0,
1046 message: format!(
1047 "{} dead code entries detected in project",
1048 dead_count,
1049 ),
1050 evidence: serde_json::json!({
1051 "command": cmd.name,
1052 "dead_code_count": dead_count,
1053 }),
1054 confidence: Some("DETERMINISTIC".to_string()),
1055 finding_id: Some(compute_finding_id(
1056 "dead-code-introduced",
1057 Path::new("(project)"),
1058 "(project-level)",
1059 0,
1060 )),
1061 });
1062 }
1063 }
1064 Err(e) => {
1065 partial_reasons.push(format!("tldr {} failed: {}", cmd.name, e));
1066 }
1067 }
1068 }
1069
1070 use crate::commands::bugbot::first_run::{
1076 load_cached_baseline_call_graph, resolve_git_ref, save_baseline_call_graph,
1077 };
1078
1079 let base_commit = resolve_git_ref(project, base_ref).ok();
1080 let cached_baseline = base_commit
1081 .as_deref()
1082 .and_then(|hash| load_cached_baseline_call_graph(project, hash));
1083
1084 let mut calls_deps_done = false;
1086
1087 if let Some(ref cached_cg) = cached_baseline {
1088 let current_calls_result: Result<std::borrow::Cow<'_, serde_json::Value>, String> =
1090 if let Some(cached) = current_calls_json {
1091 Ok(std::borrow::Cow::Borrowed(cached))
1092 } else {
1093 flow_engine
1094 .run_tldr_flow_command("calls", &["calls"], project, language)
1095 .map(std::borrow::Cow::Owned)
1096 };
1097
1098 match ¤t_calls_result {
1099 Ok(current_json) => {
1100 findings.extend(self.diff_calls_json(cached_cg, current_json.as_ref()));
1101
1102 let baseline_deps = Self::derive_deps_from_calls(cached_cg);
1103 let current_deps = Self::derive_deps_from_calls(current_json.as_ref());
1104 findings.extend(self.diff_deps_json(&baseline_deps, ¤t_deps));
1105 calls_deps_done = true;
1106 }
1107 Err(e) => {
1108 partial_reasons.push(format!("tldr calls (current) failed: {}", e));
1109 calls_deps_done = true; }
1111 }
1112 }
1113
1114 let needs_worktree = true; if needs_worktree {
1121 let baseline_dir = match tempfile::tempdir() {
1122 Ok(d) => d,
1123 Err(e) => {
1124 partial_reasons.push(format!("tmpdir for baseline worktree: {}", e));
1125 return findings;
1126 }
1127 };
1128 let worktree_path = baseline_dir.path().join("baseline");
1129
1130 let worktree_ok = match Command::new("git")
1131 .args([
1132 "worktree",
1133 "add",
1134 &worktree_path.to_string_lossy(),
1135 base_ref,
1136 ])
1137 .current_dir(project)
1138 .stdout(std::process::Stdio::null())
1139 .stderr(std::process::Stdio::piped())
1140 .status()
1141 {
1142 Ok(status) if status.success() => true,
1143 Ok(status) => {
1144 partial_reasons.push(format!(
1145 "git worktree add failed (exit {}); skipping baseline flow diff",
1146 status
1147 ));
1148 false
1149 }
1150 Err(e) => {
1151 partial_reasons.push(format!(
1152 "git worktree add: {}; skipping baseline flow diff",
1153 e
1154 ));
1155 false
1156 }
1157 };
1158
1159 if worktree_ok {
1160 let tldrignore_src = project.join(".tldrignore");
1163 if tldrignore_src.exists() {
1164 let _ = std::fs::copy(&tldrignore_src, worktree_path.join(".tldrignore"));
1165 }
1166
1167 if !calls_deps_done {
1169 let baseline_calls = flow_engine.run_tldr_flow_command(
1170 "calls",
1171 &["calls"],
1172 &worktree_path,
1173 language,
1174 );
1175 let current_calls_result: Result<
1176 std::borrow::Cow<'_, serde_json::Value>,
1177 String,
1178 > = if let Some(cached) = current_calls_json {
1179 Ok(std::borrow::Cow::Borrowed(cached))
1180 } else {
1181 flow_engine
1182 .run_tldr_flow_command("calls", &["calls"], project, language)
1183 .map(std::borrow::Cow::Owned)
1184 };
1185
1186 match (&baseline_calls, ¤t_calls_result) {
1187 (Ok(baseline_json), Ok(current_json)) => {
1188 findings
1190 .extend(self.diff_calls_json(baseline_json, current_json.as_ref()));
1191
1192 let baseline_deps = Self::derive_deps_from_calls(baseline_json);
1194 let current_deps = Self::derive_deps_from_calls(current_json.as_ref());
1195 findings.extend(self.diff_deps_json(&baseline_deps, ¤t_deps));
1196
1197 if let Some(ref hash) = base_commit {
1199 let _ = save_baseline_call_graph(
1200 project,
1201 baseline_json,
1202 hash,
1203 language,
1204 );
1205 }
1206 }
1207 (Err(e), _) => {
1208 partial_reasons.push(format!("tldr calls (baseline) failed: {}", e));
1209 }
1210 (_, Err(e)) => {
1211 partial_reasons.push(format!("tldr calls (current) failed: {}", e));
1212 }
1213 }
1214 }
1215
1216 for cmd in TLDR_COMMANDS
1218 .iter()
1219 .filter(|c| c.category == TldrCategory::Flow && c.name == "cohesion")
1220 {
1221 let baseline_result = flow_engine.run_tldr_flow_command(
1222 cmd.name,
1223 cmd.args,
1224 &worktree_path,
1225 language,
1226 );
1227 let current_result =
1228 flow_engine.run_tldr_flow_command(cmd.name, cmd.args, project, language);
1229 match (baseline_result, current_result) {
1230 (Ok(baseline_json), Ok(current_json)) => {
1231 findings.extend(self.diff_cohesion_json(&baseline_json, ¤t_json));
1232 }
1233 (Err(e), _) => {
1234 partial_reasons.push(format!("tldr cohesion (baseline) failed: {}", e));
1235 }
1236 (_, Err(e)) => {
1237 partial_reasons.push(format!("tldr cohesion (current) failed: {}", e));
1238 }
1239 }
1240 }
1241
1242 let _ = Command::new("git")
1244 .args([
1245 "worktree",
1246 "remove",
1247 "--force",
1248 &worktree_path.to_string_lossy(),
1249 ])
1250 .current_dir(project)
1251 .stdout(std::process::Stdio::null())
1252 .stderr(std::process::Stdio::null())
1253 .status();
1254 }
1255 }
1256
1257 findings
1258 }
1259
1260 fn parse_whatbreaks_findings(file_path: &Path, json: &serde_json::Value) -> Vec<BugbotFinding> {
1268 let mut findings = Vec::new();
1269
1270 let summary = json.get("summary").unwrap_or(json);
1271 let importer_count = summary
1272 .get("importer_count")
1273 .and_then(|v| v.as_u64())
1274 .unwrap_or(0);
1275 let caller_count = summary
1276 .get("direct_caller_count")
1277 .and_then(|v| v.as_u64())
1278 .unwrap_or(0);
1279 let test_count = summary
1280 .get("affected_test_count")
1281 .and_then(|v| v.as_u64())
1282 .unwrap_or(0);
1283
1284 if importer_count > 0 || caller_count > 0 {
1285 let severity = if importer_count > 10 {
1286 "high"
1287 } else if importer_count > 3 {
1288 "medium"
1289 } else {
1290 "low"
1291 };
1292
1293 findings.push(BugbotFinding {
1294 finding_type: "downstream-impact".to_string(),
1295 severity: severity.to_string(),
1296 file: file_path.to_path_buf(),
1297 function: "(file-level)".to_string(),
1298 line: 0,
1299 message: format!(
1300 "Changed file has {} importers, {} direct callers, {} affected tests",
1301 importer_count, caller_count, test_count,
1302 ),
1303 evidence: serde_json::json!({
1304 "command": "whatbreaks",
1305 "importer_count": importer_count,
1306 "direct_caller_count": caller_count,
1307 "affected_test_count": test_count,
1308 }),
1309 confidence: Some("DETERMINISTIC".to_string()),
1310 finding_id: Some(compute_finding_id(
1311 "downstream-impact",
1312 file_path,
1313 "(file-level)",
1314 0,
1315 )),
1316 });
1317 }
1318
1319 findings
1320 }
1321
1322 pub fn parse_impact_findings(
1334 function_name: &str,
1335 json: &serde_json::Value,
1336 ) -> Vec<BugbotFinding> {
1337 let mut findings = Vec::new();
1338
1339 let (caller_count, callers_preview) = if let Some(target) =
1341 json.get("targets").and_then(|t| t.get(function_name))
1342 {
1343 let count = target
1344 .get("caller_count")
1345 .and_then(|v| v.as_u64())
1346 .unwrap_or(0);
1347 let callers: Vec<String> = target
1348 .get("callers")
1349 .and_then(|v| v.as_array())
1350 .map(|arr| {
1351 arr.iter()
1352 .take(5)
1353 .map(|c| {
1354 let file = c.get("file").and_then(|v| v.as_str()).unwrap_or("?");
1355 let func = c.get("function").and_then(|v| v.as_str()).unwrap_or("?");
1356 format!("{}::{}", file, func)
1357 })
1358 .collect()
1359 })
1360 .unwrap_or_default();
1361 (count, callers)
1362 } else {
1363 let count = json
1365 .get("caller_count")
1366 .and_then(|v| v.as_u64())
1367 .unwrap_or(0);
1368 let callers: Vec<String> = json
1369 .get("callers")
1370 .and_then(|v| v.as_array())
1371 .map(|arr| {
1372 arr.iter()
1373 .take(5)
1374 .map(|c| {
1375 let file = c.get("file").and_then(|v| v.as_str()).unwrap_or("?");
1376 let func = c.get("function").and_then(|v| v.as_str()).unwrap_or("?");
1377 format!("{}::{}", file, func)
1378 })
1379 .collect()
1380 })
1381 .unwrap_or_default();
1382 (count, callers)
1383 };
1384
1385 if caller_count > 0 {
1386 let severity = if caller_count > 5 {
1387 "high"
1388 } else if caller_count >= 2 {
1389 "medium"
1390 } else {
1391 "info"
1392 };
1393
1394 findings.push(BugbotFinding {
1395 finding_type: "breaking-change-risk".to_string(),
1396 severity: severity.to_string(),
1397 file: PathBuf::from("(project)"),
1398 function: function_name.to_string(),
1399 line: 0,
1400 message: format!(
1401 "Function `{}` has {} callers that may be affected by changes",
1402 function_name, caller_count,
1403 ),
1404 evidence: serde_json::json!({
1405 "command": "impact",
1406 "caller_count": caller_count,
1407 "callers_preview": callers_preview,
1408 }),
1409 confidence: Some("DETERMINISTIC".to_string()),
1410 finding_id: Some(compute_finding_id(
1411 "breaking-change-risk",
1412 Path::new("(project)"),
1413 function_name,
1414 0,
1415 )),
1416 });
1417 }
1418
1419 findings
1420 }
1421
1422 fn build_reverse_caller_map(
1429 calls_json: &serde_json::Value,
1430 ) -> HashMap<String, Vec<(String, String)>> {
1431 let mut map: HashMap<String, Vec<(String, String)>> = HashMap::new();
1432
1433 if let Some(edges) = calls_json.get("edges").and_then(|v| v.as_array()) {
1434 for edge in edges {
1435 let src_file = edge.get("src_file").and_then(|v| v.as_str());
1436 let src_func = edge.get("src_func").and_then(|v| v.as_str());
1437 let dst_func = edge.get("dst_func").and_then(|v| v.as_str());
1438
1439 if let (Some(sf), Some(sfn), Some(df)) = (src_file, src_func, dst_func) {
1440 map.entry(df.to_string())
1441 .or_default()
1442 .push((sf.to_string(), sfn.to_string()));
1443 }
1444 }
1445 }
1446
1447 map
1448 }
1449
1450 fn parse_impact_findings_from_callgraph(
1464 func_name: &str,
1465 callers: &[(String, String)],
1466 ) -> Vec<BugbotFinding> {
1467 let mut findings = Vec::new();
1468 let caller_count = callers.len();
1469
1470 if caller_count == 0 {
1471 return findings;
1472 }
1473
1474 let severity = if caller_count > 5 {
1475 "high"
1476 } else if caller_count >= 2 {
1477 "medium"
1478 } else {
1479 "info"
1480 };
1481
1482 let callers_preview: Vec<String> = callers
1483 .iter()
1484 .take(5)
1485 .map(|(file, func)| format!("{}::{}", file, func))
1486 .collect();
1487
1488 findings.push(BugbotFinding {
1489 finding_type: "breaking-change-risk".to_string(),
1490 severity: severity.to_string(),
1491 file: PathBuf::from("(project)"),
1492 function: func_name.to_string(),
1493 line: 0,
1494 message: format!(
1495 "Function `{}` has {} callers that may be affected by changes",
1496 func_name, caller_count
1497 ),
1498 evidence: serde_json::json!({
1499 "command": "calls",
1500 "caller_count": caller_count,
1501 "callers_preview": callers_preview,
1502 }),
1503 confidence: Some("DETERMINISTIC".to_string()),
1504 finding_id: Some(compute_finding_id(
1505 "breaking-change-risk",
1506 Path::new("(project)"),
1507 func_name,
1508 0,
1509 )),
1510 });
1511
1512 findings
1513 }
1514
1515 fn analyze_downstream_impact(
1525 &self,
1526 project: &Path,
1527 changed_files: &[PathBuf],
1528 language: &str,
1529 current_calls_json: Option<&serde_json::Value>,
1530 partial_reasons: &mut Vec<String>,
1531 ) -> Vec<BugbotFinding> {
1532 let mut findings = Vec::new();
1533
1534 if let Some(calls_json) = current_calls_json {
1535 let changed_file_strs: Vec<&str> = changed_files
1537 .iter()
1538 .map(|p| p.strip_prefix(project).unwrap_or(p))
1539 .filter_map(|p| p.to_str())
1540 .collect();
1541
1542 let downstream_results =
1543 Self::derive_downstream_from_calls(calls_json, &changed_file_strs);
1544 for (file_str, metrics) in &downstream_results {
1545 let file_path = project.join(file_str);
1546 let wb_json = serde_json::json!({ "summary": metrics });
1547 findings.extend(Self::parse_whatbreaks_findings(&file_path, &wb_json));
1548 }
1549 } else {
1550 let flow_engine = TldrDifferentialEngine::with_timeout(300);
1552
1553 for file_path in changed_files {
1554 let relative = file_path.strip_prefix(project).unwrap_or(file_path);
1555 let rel_str = relative.to_string_lossy().to_string();
1556
1557 let args = vec![
1558 "whatbreaks".to_string(),
1559 rel_str.clone(),
1560 "--type".to_string(),
1561 "file".to_string(),
1562 "--quick".to_string(),
1563 project.to_string_lossy().to_string(),
1564 "--lang".to_string(),
1565 language.to_string(),
1566 "--format".to_string(),
1567 "json".to_string(),
1568 ];
1569
1570 match flow_engine.run_tldr_raw(&args) {
1571 Ok(json) => {
1572 findings.extend(Self::parse_whatbreaks_findings(file_path, &json));
1573 }
1574 Err(e) => {
1575 partial_reasons.push(format!("tldr whatbreaks {} failed: {}", rel_str, e));
1576 }
1577 }
1578 }
1579 }
1580
1581 findings
1582 }
1583
1584 fn analyze_function_impact(
1598 &self,
1599 project: &Path,
1600 changed_files: &[PathBuf],
1601 language: &str,
1602 current_calls_json: Option<&serde_json::Value>,
1603 partial_reasons: &mut Vec<String>,
1604 ) -> Vec<BugbotFinding> {
1605 let mut findings = Vec::new();
1606 let impact_engine = TldrDifferentialEngine::with_timeout(60);
1607
1608 let mut all_functions: Vec<String> = Vec::new();
1610 for file_path in changed_files {
1611 let relative = file_path.strip_prefix(project).unwrap_or(file_path);
1612 let full_path = project.join(relative);
1613
1614 let cognitive_result = impact_engine.run_tldr_command(&["cognitive"], &full_path);
1615 let func_names = Self::discover_function_names_from_cognitive(&cognitive_result);
1616 all_functions.extend(func_names);
1617 }
1618
1619 all_functions.truncate(20);
1621
1622 if all_functions.is_empty() {
1623 return findings;
1624 }
1625
1626 let calls_json_owned: Option<serde_json::Value>;
1628 let calls_json_ref: &serde_json::Value = if let Some(cached) = current_calls_json {
1629 cached
1630 } else {
1631 let args = vec![
1632 "calls".to_string(),
1633 project.to_string_lossy().to_string(),
1634 "--lang".to_string(),
1635 language.to_string(),
1636 "--format".to_string(),
1637 "json".to_string(),
1638 ];
1639
1640 match impact_engine.run_tldr_raw(&args) {
1641 Ok(json) => {
1642 calls_json_owned = Some(json);
1643 calls_json_owned.as_ref().unwrap()
1644 }
1645 Err(e) => {
1646 partial_reasons.push(format!("tldr calls failed: {}", e));
1647 return findings;
1648 }
1649 }
1650 };
1651
1652 let reverse_map = Self::build_reverse_caller_map(calls_json_ref);
1654
1655 for func_name in &all_functions {
1657 let callers = reverse_map.get(func_name).cloned().unwrap_or_default();
1658 findings.extend(Self::parse_impact_findings_from_callgraph(
1659 func_name, &callers,
1660 ));
1661 }
1662
1663 findings
1664 }
1665
1666 fn diff_calls_json(
1678 &self,
1679 baseline: &serde_json::Value,
1680 current: &serde_json::Value,
1681 ) -> Vec<BugbotFinding> {
1682 let mut findings = Vec::new();
1683
1684 let extract_edges =
1685 |json: &serde_json::Value| -> std::collections::HashSet<(String, String)> {
1686 let mut set = std::collections::HashSet::new();
1687 if let Some(edges) = json.get("edges").and_then(|v| v.as_array()) {
1688 for edge in edges {
1689 let from = format!(
1690 "{}::{}",
1691 edge.get("src_file").and_then(|v| v.as_str()).unwrap_or("?"),
1692 edge.get("src_func").and_then(|v| v.as_str()).unwrap_or("?"),
1693 );
1694 let to = format!(
1695 "{}::{}",
1696 edge.get("dst_file").and_then(|v| v.as_str()).unwrap_or("?"),
1697 edge.get("dst_func").and_then(|v| v.as_str()).unwrap_or("?"),
1698 );
1699 if from != "?::?" && to != "?::?" {
1700 set.insert((from, to));
1701 }
1702 }
1703 }
1704 set
1705 };
1706
1707 let baseline_edges = extract_edges(baseline);
1708 let current_edges = extract_edges(current);
1709
1710 let new_edges: Vec<&(String, String)> = current_edges.difference(&baseline_edges).collect();
1712 let removed_edges: Vec<&(String, String)> =
1714 baseline_edges.difference(¤t_edges).collect();
1715
1716 if new_edges.is_empty() && removed_edges.is_empty() {
1717 return findings;
1718 }
1719
1720 for (from, to) in &new_edges {
1722 findings.push(BugbotFinding {
1723 finding_type: "call-graph-change".to_string(),
1724 severity: "info".to_string(),
1725 file: PathBuf::from("(project)"),
1726 function: "(project-level)".to_string(),
1727 line: 0,
1728 message: format!("New call edge: {} -> {}", from, to),
1729 evidence: serde_json::json!({
1730 "change": "added",
1731 "from": from,
1732 "to": to,
1733 }),
1734 confidence: Some("DETERMINISTIC".to_string()),
1735 finding_id: Some(compute_finding_id(
1736 "call-graph-change",
1737 Path::new("(project)"),
1738 &format!("{}:{}", from, to),
1739 0,
1740 )),
1741 });
1742 }
1743
1744 for (from, to) in &removed_edges {
1746 findings.push(BugbotFinding {
1747 finding_type: "call-graph-change".to_string(),
1748 severity: "info".to_string(),
1749 file: PathBuf::from("(project)"),
1750 function: "(project-level)".to_string(),
1751 line: 0,
1752 message: format!("Removed call edge: {} -> {}", from, to),
1753 evidence: serde_json::json!({
1754 "change": "removed",
1755 "from": from,
1756 "to": to,
1757 }),
1758 confidence: Some("DETERMINISTIC".to_string()),
1759 finding_id: Some(compute_finding_id(
1760 "call-graph-change",
1761 Path::new("(project)"),
1762 &format!("removed:{}:{}", from, to),
1763 0,
1764 )),
1765 });
1766 }
1767
1768 if new_edges.len() > 5 {
1770 findings.push(BugbotFinding {
1771 finding_type: "call-graph-change".to_string(),
1772 severity: "medium".to_string(),
1773 file: PathBuf::from("(project)"),
1774 function: "(project-level)".to_string(),
1775 line: 0,
1776 message: format!(
1777 "Significant call graph change: {} new edges, {} removed edges",
1778 new_edges.len(),
1779 removed_edges.len(),
1780 ),
1781 evidence: serde_json::json!({
1782 "new_edge_count": new_edges.len(),
1783 "removed_edge_count": removed_edges.len(),
1784 }),
1785 confidence: Some("DETERMINISTIC".to_string()),
1786 finding_id: Some(compute_finding_id(
1787 "call-graph-change",
1788 Path::new("(project)"),
1789 "(summary)",
1790 0,
1791 )),
1792 });
1793 }
1794
1795 findings
1796 }
1797
1798 fn diff_deps_json(
1813 &self,
1814 baseline: &serde_json::Value,
1815 current: &serde_json::Value,
1816 ) -> Vec<BugbotFinding> {
1817 let mut findings = Vec::new();
1818
1819 let extract_circular = |json: &serde_json::Value| -> std::collections::HashSet<String> {
1822 let mut set = std::collections::HashSet::new();
1823 if let Some(circs) = json.get("circular_dependencies").and_then(|v| v.as_array()) {
1824 for circ in circs {
1825 if let Some(path) = circ.get("path").and_then(|v| v.as_array()) {
1827 let mut names: Vec<String> = path
1828 .iter()
1829 .filter_map(|m| m.as_str().map(|s| s.to_string()))
1830 .collect();
1831 names.sort();
1832 set.insert(names.join(","));
1833 }
1834 }
1835 }
1836 set
1837 };
1838
1839 let baseline_circular = extract_circular(baseline);
1840 let current_circular = extract_circular(current);
1841
1842 let new_circular: Vec<&String> = current_circular.difference(&baseline_circular).collect();
1844 for circ in &new_circular {
1845 findings.push(BugbotFinding {
1846 finding_type: "dependency-change".to_string(),
1847 severity: "high".to_string(),
1848 file: PathBuf::from("(project)"),
1849 function: "(project-level)".to_string(),
1850 line: 0,
1851 message: format!("New circular dependency detected: {}", circ),
1852 evidence: serde_json::json!({
1853 "change": "new_circular",
1854 "modules": circ,
1855 }),
1856 confidence: Some("DETERMINISTIC".to_string()),
1857 finding_id: Some(compute_finding_id(
1858 "dependency-change",
1859 Path::new("(project)"),
1860 &format!("circular:{}", circ),
1861 0,
1862 )),
1863 });
1864 }
1865
1866 let count_internal_deps = |json: &serde_json::Value| -> usize {
1870 if let Some(total) = json
1872 .get("stats")
1873 .and_then(|s| s.get("total_internal_deps"))
1874 .and_then(|v| v.as_u64())
1875 {
1876 return total as usize;
1877 }
1878 json.get("internal_dependencies")
1880 .and_then(|v| v.as_object())
1881 .map(|obj| {
1882 obj.values()
1883 .filter_map(|v| v.as_array())
1884 .map(|a| a.len())
1885 .sum()
1886 })
1887 .unwrap_or(0)
1888 };
1889
1890 let baseline_dep_count = count_internal_deps(baseline);
1891 let current_dep_count = count_internal_deps(current);
1892
1893 if current_dep_count > baseline_dep_count {
1894 let increase = current_dep_count - baseline_dep_count;
1895 if increase > 5 || (baseline_dep_count > 0 && increase * 100 / baseline_dep_count > 20)
1897 {
1898 findings.push(BugbotFinding {
1899 finding_type: "dependency-change".to_string(),
1900 severity: "medium".to_string(),
1901 file: PathBuf::from("(project)"),
1902 function: "(project-level)".to_string(),
1903 line: 0,
1904 message: format!(
1905 "Internal dependency count increased: {} -> {} (+{})",
1906 baseline_dep_count, current_dep_count, increase,
1907 ),
1908 evidence: serde_json::json!({
1909 "change": "dependency_count_increase",
1910 "baseline_count": baseline_dep_count,
1911 "current_count": current_dep_count,
1912 "increase": increase,
1913 }),
1914 confidence: Some("DETERMINISTIC".to_string()),
1915 finding_id: Some(compute_finding_id(
1916 "dependency-change",
1917 Path::new("(project)"),
1918 "(dep-count)",
1919 0,
1920 )),
1921 });
1922 }
1923 }
1924
1925 findings
1926 }
1927
1928 pub fn diff_coupling_json(
1939 &self,
1940 baseline: &serde_json::Value,
1941 current: &serde_json::Value,
1942 ) -> Vec<BugbotFinding> {
1943 let mut findings = Vec::new();
1944
1945 let extract_metrics =
1946 |json: &serde_json::Value| -> std::collections::HashMap<String, (f64, f64, f64)> {
1947 let mut map = std::collections::HashMap::new();
1948 if let Some(metrics) = json.get("martin_metrics").and_then(|v| v.as_array()) {
1949 for entry in metrics {
1950 let module = entry.get("module").and_then(|v| v.as_str()).unwrap_or("");
1951 if module.is_empty() {
1952 continue;
1953 }
1954 let ca = entry.get("ca").and_then(|v| v.as_f64()).unwrap_or(0.0);
1955 let ce = entry.get("ce").and_then(|v| v.as_f64()).unwrap_or(0.0);
1956 let instability = entry
1957 .get("instability")
1958 .and_then(|v| v.as_f64())
1959 .unwrap_or(0.0);
1960 map.insert(module.to_string(), (ca, ce, instability));
1961 }
1962 }
1963 map
1964 };
1965
1966 let baseline_metrics = extract_metrics(baseline);
1967 let current_metrics = extract_metrics(current);
1968
1969 for (module, (_, curr_ce, curr_instability)) in ¤t_metrics {
1970 if let Some((_, base_ce, base_instability)) = baseline_metrics.get(module) {
1971 let instability_delta = curr_instability - base_instability;
1973 let ce_delta = curr_ce - base_ce;
1974
1975 if instability_delta > 0.05 || ce_delta > 2.0 {
1976 let severity = if instability_delta > 0.3 || ce_delta > 5.0 {
1977 "high"
1978 } else if instability_delta > 0.1 || ce_delta > 3.0 {
1979 "medium"
1980 } else {
1981 "low"
1982 };
1983
1984 findings.push(BugbotFinding {
1985 finding_type: "coupling-increase".to_string(),
1986 severity: severity.to_string(),
1987 file: PathBuf::from("(project)"),
1988 function: "(project-level)".to_string(),
1989 line: 0,
1990 message: format!(
1991 "Module '{}': instability {:.2} -> {:.2} (delta {:.2}), ce {} -> {}",
1992 module,
1993 base_instability,
1994 curr_instability,
1995 instability_delta,
1996 base_ce,
1997 curr_ce,
1998 ),
1999 evidence: serde_json::json!({
2000 "module": module,
2001 "baseline_instability": base_instability,
2002 "current_instability": curr_instability,
2003 "instability_delta": instability_delta,
2004 "baseline_ce": base_ce,
2005 "current_ce": curr_ce,
2006 "ce_delta": ce_delta,
2007 }),
2008 confidence: Some("DETERMINISTIC".to_string()),
2009 finding_id: Some(compute_finding_id(
2010 "coupling-increase",
2011 Path::new("(project)"),
2012 module,
2013 0,
2014 )),
2015 });
2016 }
2017 }
2018 }
2019
2020 findings
2021 }
2022
2023 fn diff_cohesion_json(
2034 &self,
2035 baseline: &serde_json::Value,
2036 current: &serde_json::Value,
2037 ) -> Vec<BugbotFinding> {
2038 let mut findings = Vec::new();
2039
2040 let extract_lcom4 = |json: &serde_json::Value| -> std::collections::HashMap<String, f64> {
2041 let mut map = std::collections::HashMap::new();
2042 if let Some(classes) = json.get("classes").and_then(|v| v.as_array()) {
2043 for cls in classes {
2044 let name = cls
2046 .get("class_name")
2047 .or_else(|| cls.get("name"))
2048 .and_then(|v| v.as_str())
2049 .unwrap_or("");
2050 if name.is_empty() {
2051 continue;
2052 }
2053 let lcom4 = cls.get("lcom4").and_then(|v| v.as_f64()).unwrap_or(0.0);
2054 map.insert(name.to_string(), lcom4);
2055 }
2056 }
2057 map
2058 };
2059
2060 let baseline_lcom = extract_lcom4(baseline);
2061 let current_lcom = extract_lcom4(current);
2062
2063 for (class_name, curr_lcom4) in ¤t_lcom {
2064 if let Some(base_lcom4) = baseline_lcom.get(class_name) {
2065 let delta = curr_lcom4 - base_lcom4;
2067 if delta > 0.5 {
2068 let severity = if delta > 3.0 {
2069 "high"
2070 } else if delta > 1.0 {
2071 "medium"
2072 } else {
2073 "low"
2074 };
2075
2076 findings.push(BugbotFinding {
2077 finding_type: "cohesion-decrease".to_string(),
2078 severity: severity.to_string(),
2079 file: PathBuf::from("(project)"),
2080 function: "(project-level)".to_string(),
2081 line: 0,
2082 message: format!(
2083 "Class '{}': LCOM4 increased {} -> {} (less cohesive)",
2084 class_name, base_lcom4, curr_lcom4,
2085 ),
2086 evidence: serde_json::json!({
2087 "class": class_name,
2088 "baseline_lcom4": base_lcom4,
2089 "current_lcom4": curr_lcom4,
2090 "delta": delta,
2091 }),
2092 confidence: Some("DETERMINISTIC".to_string()),
2093 finding_id: Some(compute_finding_id(
2094 "cohesion-decrease",
2095 Path::new("(project)"),
2096 class_name,
2097 0,
2098 )),
2099 });
2100 }
2101 } else {
2102 if *curr_lcom4 > 3.0 {
2104 findings.push(BugbotFinding {
2105 finding_type: "cohesion-decrease".to_string(),
2106 severity: "info".to_string(),
2107 file: PathBuf::from("(project)"),
2108 function: "(project-level)".to_string(),
2109 line: 0,
2110 message: format!(
2111 "New class '{}' has high LCOM4 ({}): consider splitting",
2112 class_name, curr_lcom4,
2113 ),
2114 evidence: serde_json::json!({
2115 "class": class_name,
2116 "lcom4": curr_lcom4,
2117 "new_class": true,
2118 }),
2119 confidence: Some("DETERMINISTIC".to_string()),
2120 finding_id: Some(compute_finding_id(
2121 "cohesion-decrease",
2122 Path::new("(project)"),
2123 class_name,
2124 0,
2125 )),
2126 });
2127 }
2128 }
2129 }
2130
2131 findings
2132 }
2133
2134 fn count_dead_code_entries(json: &serde_json::Value) -> usize {
2139 if let Some(total) = json.get("total_count").and_then(|v| v.as_u64()) {
2141 return total as usize;
2142 }
2143 for key in &[
2145 "dead_functions",
2146 "possibly_dead",
2147 "dead_code",
2148 "unreachable",
2149 "functions",
2150 "results",
2151 ] {
2152 if let Some(arr) = json.get(key).and_then(|v| v.as_array()) {
2153 return arr.len();
2154 }
2155 }
2156 if let Some(arr) = json.as_array() {
2157 return arr.len();
2158 }
2159 0
2160 }
2161
2162 pub fn derive_deps_from_calls(calls_json: &serde_json::Value) -> serde_json::Value {
2171 let empty_edges: Vec<serde_json::Value> = Vec::new();
2172 let edges = calls_json
2173 .get("edges")
2174 .and_then(|v| v.as_array())
2175 .unwrap_or(&empty_edges);
2176
2177 let mut dep_map: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
2179 for edge in edges {
2180 let src_file = edge.get("src_file").and_then(|v| v.as_str()).unwrap_or("");
2181 let dst_file = edge.get("dst_file").and_then(|v| v.as_str()).unwrap_or("");
2182 if src_file.is_empty() || dst_file.is_empty() || src_file == dst_file {
2184 continue;
2185 }
2186 dep_map
2187 .entry(src_file.to_string())
2188 .or_default()
2189 .insert(dst_file.to_string());
2190 }
2191
2192 let total_internal_deps: usize = dep_map.values().map(|s| s.len()).sum();
2194
2195 let mut circular: Vec<serde_json::Value> = Vec::new();
2197 let mut seen_cycles: BTreeSet<(String, String)> = BTreeSet::new();
2198 for (src, destinations) in &dep_map {
2199 for dst in destinations {
2200 if let Some(reverse_deps) = dep_map.get(dst) {
2201 if reverse_deps.contains(src) {
2202 let (a, b) = if src < dst {
2203 (src.clone(), dst.clone())
2204 } else {
2205 (dst.clone(), src.clone())
2206 };
2207 if seen_cycles.insert((a.clone(), b.clone())) {
2208 circular.push(serde_json::json!({
2209 "path": [a, b]
2210 }));
2211 }
2212 }
2213 }
2214 }
2215 }
2216
2217 let internal_deps: serde_json::Map<String, serde_json::Value> = dep_map
2219 .into_iter()
2220 .map(|(k, v)| {
2221 let arr: Vec<serde_json::Value> =
2222 v.into_iter().map(serde_json::Value::String).collect();
2223 (k, serde_json::Value::Array(arr))
2224 })
2225 .collect();
2226
2227 serde_json::json!({
2228 "internal_dependencies": internal_deps,
2229 "circular_dependencies": circular,
2230 "stats": {
2231 "total_internal_deps": total_internal_deps
2232 }
2233 })
2234 }
2235
2236 pub fn derive_coupling_from_calls(calls_json: &serde_json::Value) -> serde_json::Value {
2243 let empty_edges: Vec<serde_json::Value> = Vec::new();
2244 let edges = calls_json
2245 .get("edges")
2246 .and_then(|v| v.as_array())
2247 .unwrap_or(&empty_edges);
2248
2249 let mut ce_map: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
2251 let mut ca_map: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
2253
2254 for edge in edges {
2255 let src_file = edge.get("src_file").and_then(|v| v.as_str()).unwrap_or("");
2256 let dst_file = edge.get("dst_file").and_then(|v| v.as_str()).unwrap_or("");
2257 if src_file.is_empty() || dst_file.is_empty() || src_file == dst_file {
2259 continue;
2260 }
2261 ce_map
2262 .entry(src_file.to_string())
2263 .or_default()
2264 .insert(dst_file.to_string());
2265 ca_map
2266 .entry(dst_file.to_string())
2267 .or_default()
2268 .insert(src_file.to_string());
2269 }
2270
2271 let mut all_modules: BTreeSet<String> = BTreeSet::new();
2273 for k in ce_map.keys() {
2274 all_modules.insert(k.clone());
2275 }
2276 for k in ca_map.keys() {
2277 all_modules.insert(k.clone());
2278 }
2279
2280 let mut metrics: Vec<serde_json::Value> = Vec::new();
2281 for module in &all_modules {
2282 let ca = ca_map.get(module).map_or(0, |s| s.len());
2283 let ce = ce_map.get(module).map_or(0, |s| s.len());
2284 let instability = if ca + ce == 0 {
2285 0.0
2286 } else {
2287 ce as f64 / (ca + ce) as f64
2288 };
2289 metrics.push(serde_json::json!({
2290 "module": module,
2291 "ca": ca,
2292 "ce": ce,
2293 "instability": instability
2294 }));
2295 }
2296
2297 serde_json::json!({
2298 "martin_metrics": metrics
2299 })
2300 }
2301
2302 pub fn derive_downstream_from_calls(
2309 calls_json: &serde_json::Value,
2310 changed_files: &[&str],
2311 ) -> Vec<(String, serde_json::Value)> {
2312 let empty_edges: Vec<serde_json::Value> = Vec::new();
2313 let edges = calls_json
2314 .get("edges")
2315 .and_then(|v| v.as_array())
2316 .unwrap_or(&empty_edges);
2317
2318 let mut results: Vec<(String, serde_json::Value)> = Vec::new();
2319
2320 for &changed_file in changed_files {
2321 let mut importers: BTreeSet<String> = BTreeSet::new();
2322 let mut test_importers: BTreeSet<String> = BTreeSet::new();
2323
2324 for edge in edges {
2325 let src_file = edge.get("src_file").and_then(|v| v.as_str()).unwrap_or("");
2326 let dst_file = edge.get("dst_file").and_then(|v| v.as_str()).unwrap_or("");
2327
2328 if dst_file == changed_file && src_file != changed_file && !src_file.is_empty() {
2330 importers.insert(src_file.to_string());
2331 if src_file.contains("test") {
2332 test_importers.insert(src_file.to_string());
2333 }
2334 }
2335 }
2336
2337 let importer_count = importers.len() as u64;
2338 let affected_test_count = test_importers.len() as u64;
2339
2340 results.push((
2341 changed_file.to_string(),
2342 serde_json::json!({
2343 "importer_count": importer_count,
2344 "direct_caller_count": importer_count,
2345 "affected_test_count": affected_test_count
2346 }),
2347 ));
2348 }
2349
2350 results
2351 }
2352}
2353
2354fn compute_finding_id(finding_type: &str, file: &Path, function: &str, line: usize) -> String {
2359 let mut hasher = DefaultHasher::new();
2360 finding_type.hash(&mut hasher);
2361 file.to_string_lossy().as_ref().hash(&mut hasher);
2362 function.hash(&mut hasher);
2363 line.hash(&mut hasher);
2364 format!("{:x}", hasher.finish())
2365}
2366
2367impl Default for TldrDifferentialEngine {
2368 fn default() -> Self {
2369 Self::new()
2370 }
2371}
2372
2373impl L2Engine for TldrDifferentialEngine {
2374 fn name(&self) -> &'static str {
2375 "TldrDifferentialEngine"
2376 }
2377
2378 fn finding_types(&self) -> &[&'static str] {
2379 FINDING_TYPES
2380 }
2381
2382 fn analyze(&self, ctx: &L2Context) -> L2AnalyzerOutput {
2383 let start = Instant::now();
2384 let mut all_findings = Vec::new();
2385 let mut partial_reasons = Vec::new();
2386
2387 let work_items: Vec<_> = ctx
2389 .changed_files
2390 .iter()
2391 .filter_map(|file_path| {
2392 let baseline = ctx.baseline_contents.get(file_path)?;
2393 let current = ctx.current_contents.get(file_path)?;
2394 Some((file_path, baseline.as_str(), current.as_str()))
2395 })
2396 .collect();
2397
2398 let functions_skipped = ctx.changed_files.len() - work_items.len();
2399 let functions_analyzed = work_items.len();
2400
2401 let num_threads = std::thread::available_parallelism()
2402 .map(|n| n.get())
2403 .unwrap_or(1)
2404 .min(work_items.len().max(1));
2405
2406 if num_threads <= 1 || work_items.len() <= 1 {
2407 for (file_path, baseline_src, current_src) in &work_items {
2408 let mut file_reasons = Vec::new();
2409 let file_findings = self.analyze_local_commands(
2410 file_path,
2411 baseline_src,
2412 current_src,
2413 &mut file_reasons,
2414 );
2415 all_findings.extend(file_findings);
2416 partial_reasons.extend(file_reasons);
2417 }
2418 } else {
2419 let chunk_size = work_items.len().div_ceil(num_threads);
2420 std::thread::scope(|s| {
2421 let handles: Vec<_> = work_items
2422 .chunks(chunk_size)
2423 .map(|chunk| {
2424 s.spawn(move || {
2425 let mut findings = Vec::new();
2426 let mut reasons = Vec::new();
2427 for (file_path, baseline_src, current_src) in chunk {
2428 let file_findings = self.analyze_local_commands(
2429 file_path,
2430 baseline_src,
2431 current_src,
2432 &mut reasons,
2433 );
2434 findings.extend(file_findings);
2435 }
2436 (findings, reasons)
2437 })
2438 })
2439 .collect();
2440
2441 for handle in handles {
2442 if let Ok((findings, reasons)) = handle.join() {
2443 all_findings.extend(findings);
2444 partial_reasons.extend(reasons);
2445 }
2446 }
2447 });
2448 }
2449
2450 let language_str = ctx.language.as_str();
2452 let calls_engine = TldrDifferentialEngine::with_timeout(300);
2453 let current_calls_json = calls_engine
2454 .run_tldr_flow_command("calls", &["calls"], &ctx.project, language_str)
2455 .ok();
2456
2457 let flow_findings = self.analyze_flow_commands(
2459 &ctx.project,
2460 &ctx.base_ref,
2461 language_str,
2462 current_calls_json.as_ref(),
2463 &mut partial_reasons,
2464 );
2465 all_findings.extend(flow_findings);
2466
2467 let impact_findings = self.analyze_downstream_impact(
2469 &ctx.project,
2470 &ctx.changed_files,
2471 language_str,
2472 current_calls_json.as_ref(),
2473 &mut partial_reasons,
2474 );
2475 all_findings.extend(impact_findings);
2476
2477 let func_impact_findings = self.analyze_function_impact(
2478 &ctx.project,
2479 &ctx.changed_files,
2480 language_str,
2481 current_calls_json.as_ref(),
2482 &mut partial_reasons,
2483 );
2484 all_findings.extend(func_impact_findings);
2485
2486 let duration_ms = start.elapsed().as_millis() as u64;
2487
2488 let status = if partial_reasons.is_empty() {
2489 AnalyzerStatus::Complete
2490 } else {
2491 AnalyzerStatus::Partial {
2492 reason: partial_reasons.join("; "),
2493 }
2494 };
2495
2496 L2AnalyzerOutput {
2497 findings: all_findings,
2498 status,
2499 duration_ms,
2500 functions_analyzed,
2501 functions_skipped,
2502 }
2503 }
2504}
2505
2506#[cfg(test)]
2507mod tests {
2508 use super::*;
2509 use crate::commands::bugbot::l2::context::{FunctionDiff, L2Context};
2510 use std::collections::HashMap;
2511 use std::path::PathBuf;
2512 use tldr_core::Language;
2513
2514 fn empty_context() -> L2Context {
2515 L2Context::new(
2516 PathBuf::from("/tmp/test-project"),
2517 Language::Rust,
2518 vec![],
2519 FunctionDiff {
2520 changed: vec![],
2521 inserted: vec![],
2522 deleted: vec![],
2523 },
2524 HashMap::new(),
2525 HashMap::new(),
2526 HashMap::new(),
2527 )
2528 }
2529
2530 #[test]
2535 fn test_engine_name() {
2536 let engine = TldrDifferentialEngine::new();
2537 assert_eq!(engine.name(), "TldrDifferentialEngine");
2538 }
2539
2540 #[test]
2541 fn test_finding_types() {
2542 let engine = TldrDifferentialEngine::new();
2543 let types = engine.finding_types();
2544 assert_eq!(types.len(), 11);
2545 assert!(types.contains(&"complexity-increase"));
2546 assert!(types.contains(&"cognitive-increase"));
2547 assert!(types.contains(&"contract-removed"));
2548 assert!(types.contains(&"smell-introduced"));
2549 assert!(types.contains(&"call-graph-change"));
2550 assert!(types.contains(&"dependency-change"));
2551 assert!(types.contains(&"coupling-increase"));
2552 assert!(types.contains(&"cohesion-decrease"));
2553 assert!(types.contains(&"dead-code-introduced"));
2554 assert!(types.contains(&"downstream-impact"));
2555 assert!(types.contains(&"breaking-change-risk"));
2556 }
2557
2558 #[test]
2559 fn test_default() {
2560 let engine = TldrDifferentialEngine::default();
2561 assert_eq!(engine.name(), "TldrDifferentialEngine");
2562 assert_eq!(engine.timeout_secs, 30);
2563 }
2564
2565 #[test]
2566 fn test_with_timeout() {
2567 let engine = TldrDifferentialEngine::with_timeout(60);
2568 assert_eq!(engine.timeout_secs, 60);
2569 }
2570
2571 #[test]
2572 fn test_languages_empty() {
2573 let engine = TldrDifferentialEngine::new();
2574 assert!(
2575 engine.languages().is_empty(),
2576 "TldrDifferentialEngine is language-agnostic"
2577 );
2578 }
2579
2580 #[test]
2585 fn test_empty_context() {
2586 let engine = TldrDifferentialEngine::new();
2587 let ctx = empty_context();
2588 let output = engine.analyze(&ctx);
2589
2590 assert!(
2591 output.findings.is_empty(),
2592 "Empty context should produce no findings"
2593 );
2594 assert_eq!(output.functions_analyzed, 0);
2595 assert_eq!(output.functions_skipped, 0);
2596 assert!(output.duration_ms < 5000, "Should complete quickly");
2597 }
2598
2599 #[test]
2600 fn test_empty_context_status() {
2601 let engine = TldrDifferentialEngine::new();
2602 let ctx = empty_context();
2603 let output = engine.analyze(&ctx);
2604
2605 match &output.status {
2611 AnalyzerStatus::Complete => {} AnalyzerStatus::Partial { .. } => {} other => panic!("Unexpected status: {:?}", other),
2614 }
2615 }
2616
2617 #[test]
2622 fn test_run_tldr_command_not_found() {
2623 let engine = TldrDifferentialEngine::new();
2626 let result = engine.run_tldr_command(&["complexity"], Path::new("/dev/null"));
2627
2628 match result {
2632 Ok(_) => {} Err(e) => {
2634 assert!(!e.is_empty(), "Error message should not be empty");
2635 }
2636 }
2637 }
2638
2639 #[test]
2644 fn test_as_trait_object() {
2645 let engine: Box<dyn L2Engine> = Box::new(TldrDifferentialEngine::new());
2646 assert_eq!(engine.name(), "TldrDifferentialEngine");
2647 assert_eq!(engine.finding_types().len(), 11);
2648 assert!(engine.languages().is_empty());
2649 }
2650
2651 #[test]
2656 fn test_finding_id_deterministic() {
2657 let id1 = compute_finding_id("complexity-increase", Path::new("a.py"), "foo", 10);
2658 let id2 = compute_finding_id("complexity-increase", Path::new("a.py"), "foo", 10);
2659 assert_eq!(id1, id2);
2660 }
2661
2662 #[test]
2663 fn test_finding_id_differs_for_different_inputs() {
2664 let id1 = compute_finding_id("complexity-increase", Path::new("a.py"), "foo", 10);
2665 let id2 = compute_finding_id("complexity-increase", Path::new("a.py"), "bar", 10);
2666 assert_ne!(id1, id2);
2667 }
2668
2669 #[test]
2674 fn test_diff_numeric_metrics_increase_detected() {
2675 let engine = TldrDifferentialEngine::new();
2676
2677 let baseline = serde_json::json!({
2678 "functions": [
2679 { "name": "process", "cyclomatic": 2, "line": 1 }
2680 ]
2681 });
2682 let current = serde_json::json!({
2683 "functions": [
2684 { "name": "process", "cyclomatic": 10, "line": 1 }
2685 ]
2686 });
2687
2688 let findings = engine.diff_numeric_metrics(
2689 "complexity-increase",
2690 "cyclomatic",
2691 Path::new("src/lib.py"),
2692 &baseline,
2693 ¤t,
2694 );
2695
2696 assert!(!findings.is_empty(), "Should detect cyclomatic increase");
2697 assert_eq!(findings[0].finding_type, "complexity-increase");
2698 assert_eq!(findings[0].confidence, Some("DETERMINISTIC".to_string()));
2699 assert!(findings[0].finding_id.is_some());
2700
2701 assert_eq!(findings[0].severity, "high");
2703
2704 let evidence = &findings[0].evidence;
2705 assert_eq!(evidence["old_value"], 2.0);
2706 assert_eq!(evidence["new_value"], 10.0);
2707 assert_eq!(evidence["delta"], 8.0);
2708 }
2709
2710 #[test]
2711 fn test_diff_numeric_metrics_decrease_not_flagged() {
2712 let engine = TldrDifferentialEngine::new();
2713
2714 let baseline = serde_json::json!({
2715 "functions": [
2716 { "name": "process", "cyclomatic": 10, "line": 1 }
2717 ]
2718 });
2719 let current = serde_json::json!({
2720 "functions": [
2721 { "name": "process", "cyclomatic": 2, "line": 1 }
2722 ]
2723 });
2724
2725 let findings = engine.diff_numeric_metrics(
2726 "complexity-increase",
2727 "cyclomatic",
2728 Path::new("src/lib.py"),
2729 &baseline,
2730 ¤t,
2731 );
2732
2733 assert!(findings.is_empty(), "Decrease should not produce a finding");
2734 }
2735
2736 #[test]
2737 fn test_diff_numeric_metrics_new_function_info() {
2738 let engine = TldrDifferentialEngine::new();
2739
2740 let baseline = serde_json::json!({
2741 "functions": []
2742 });
2743 let current = serde_json::json!({
2744 "functions": [
2745 { "name": "new_func", "cyclomatic": 15, "line": 5 }
2746 ]
2747 });
2748
2749 let findings = engine.diff_numeric_metrics(
2750 "complexity-increase",
2751 "cyclomatic",
2752 Path::new("src/lib.py"),
2753 &baseline,
2754 ¤t,
2755 );
2756
2757 assert!(
2758 !findings.is_empty(),
2759 "New function with high metric should be reported"
2760 );
2761 assert_eq!(findings[0].severity, "info");
2762 assert!(findings[0].evidence["new_function"]
2763 .as_bool()
2764 .unwrap_or(false));
2765 }
2766
2767 #[test]
2768 fn test_diff_numeric_metrics_no_change() {
2769 let engine = TldrDifferentialEngine::new();
2770
2771 let baseline = serde_json::json!({
2772 "functions": [
2773 { "name": "process", "cyclomatic": 5, "line": 1 }
2774 ]
2775 });
2776 let current = serde_json::json!({
2777 "functions": [
2778 { "name": "process", "cyclomatic": 5, "line": 1 }
2779 ]
2780 });
2781
2782 let findings = engine.diff_numeric_metrics(
2783 "complexity-increase",
2784 "cyclomatic",
2785 Path::new("src/lib.py"),
2786 &baseline,
2787 ¤t,
2788 );
2789
2790 assert!(findings.is_empty(), "No change should produce no findings");
2791 }
2792
2793 #[test]
2794 fn test_diff_contracts_removed() {
2795 let engine = TldrDifferentialEngine::new();
2796
2797 let baseline = serde_json::json!({
2798 "functions": [
2799 {
2800 "name": "validate",
2801 "preconditions": [{"expr": "x > 0"}],
2802 "postconditions": [{"expr": "result >= 0"}]
2803 }
2804 ]
2805 });
2806 let current = serde_json::json!({
2807 "functions": [
2808 {
2809 "name": "validate",
2810 "preconditions": [],
2811 "postconditions": []
2812 }
2813 ]
2814 });
2815
2816 let findings = engine.diff_contracts(
2817 Path::new("src/lib.py"),
2818 &baseline,
2819 ¤t,
2820 &["validate".to_string()],
2821 );
2822
2823 assert!(!findings.is_empty(), "Should detect removed contracts");
2824 assert_eq!(findings[0].finding_type, "contract-removed");
2825 assert_eq!(findings[0].severity, "medium");
2826 assert_eq!(findings[0].evidence["removed"], 2);
2827 }
2828
2829 #[test]
2830 fn test_diff_contracts_function_deleted() {
2831 let engine = TldrDifferentialEngine::new();
2832
2833 let baseline = serde_json::json!({
2834 "functions": [
2835 {
2836 "name": "validate",
2837 "preconditions": [{"expr": "x > 0"}],
2838 "postconditions": []
2839 }
2840 ]
2841 });
2842 let current = serde_json::json!({
2843 "functions": []
2844 });
2845
2846 let findings = engine.diff_contracts(Path::new("src/lib.py"), &baseline, ¤t, &[]);
2848
2849 assert!(
2850 !findings.is_empty(),
2851 "Should detect deleted function with contracts"
2852 );
2853 assert_eq!(findings[0].severity, "high");
2854 assert!(findings[0].evidence["function_deleted"]
2855 .as_bool()
2856 .unwrap_or(false));
2857 }
2858
2859 #[test]
2860 fn test_diff_contracts_extraction_failure_not_treated_as_deletion() {
2861 let engine = TldrDifferentialEngine::new();
2862
2863 let baseline = serde_json::json!({
2864 "functions": [
2865 {
2866 "name": "validate",
2867 "preconditions": [{"expr": "x > 0"}],
2868 "postconditions": []
2869 }
2870 ]
2871 });
2872 let current = serde_json::json!({
2875 "functions": []
2876 });
2877
2878 let findings = engine.diff_contracts(
2880 Path::new("src/lib.rs"),
2881 &baseline,
2882 ¤t,
2883 &["validate".to_string()],
2884 );
2885
2886 assert!(
2887 findings.is_empty(),
2888 "Should NOT emit contract-removed when function exists but extraction failed"
2889 );
2890 }
2891
2892 #[test]
2893 fn test_diff_smells_introduced() {
2894 let engine = TldrDifferentialEngine::new();
2895
2896 let baseline = serde_json::json!({
2897 "smells": [
2898 { "smell_type": "long_method", "name": "process", "line": 1, "reason": "too long", "severity": 1 }
2899 ]
2900 });
2901 let current = serde_json::json!({
2902 "smells": [
2903 { "smell_type": "long_method", "name": "process", "line": 1, "reason": "too long", "severity": 1 },
2904 { "smell_type": "god_class", "name": "Handler", "line": 20, "reason": "too many methods", "severity": 2 }
2905 ]
2906 });
2907
2908 let findings = engine.diff_smells(Path::new("src/lib.py"), &baseline, ¤t);
2909
2910 assert!(!findings.is_empty(), "Should detect introduced smell");
2911 assert_eq!(findings[0].finding_type, "smell-introduced");
2912 assert_eq!(findings[0].severity, "medium"); assert_eq!(findings[0].evidence["introduced"], 1);
2914 assert_eq!(findings[0].evidence["smell_type"], "god_class");
2916 assert!(findings[0].message.contains("god_class"));
2917 }
2918
2919 #[test]
2920 fn test_diff_smells_no_regression() {
2921 let engine = TldrDifferentialEngine::new();
2922
2923 let baseline = serde_json::json!({
2924 "smells": [
2925 { "smell_type": "long_method", "name": "process", "line": 1, "reason": "too long", "severity": 1 }
2926 ]
2927 });
2928 let current = serde_json::json!({
2929 "smells": [
2930 { "smell_type": "long_method", "name": "process", "line": 1, "reason": "too long", "severity": 1 }
2931 ]
2932 });
2933
2934 let findings = engine.diff_smells(Path::new("src/lib.py"), &baseline, ¤t);
2935
2936 assert!(
2937 findings.is_empty(),
2938 "Same smells should produce no findings"
2939 );
2940 }
2941
2942 #[test]
2943 fn test_diff_smells_new_file_baseline_empty() {
2944 let engine = TldrDifferentialEngine::new();
2945
2946 let baseline = serde_json::json!({ "smells": [] });
2949 let current = serde_json::json!({
2950 "smells": [
2951 { "smell_type": "god_class", "name": "BigEngine", "line": 10, "reason": "too big", "severity": 2 },
2952 { "smell_type": "long_method", "name": "run", "line": 50, "reason": "too long", "severity": 1 },
2953 { "smell_type": "long_method", "name": "analyze", "line": 200, "reason": "too long", "severity": 1 }
2954 ]
2955 });
2956
2957 let findings = engine.diff_smells(Path::new("src/new_module.rs"), &baseline, ¤t);
2958
2959 assert!(
2960 findings.is_empty(),
2961 "New file (empty baseline) should not trigger smell-introduced"
2962 );
2963 }
2964
2965 #[test]
2966 fn test_diff_smells_real_tldr_schema() {
2967 let engine = TldrDifferentialEngine::new();
2969
2970 let baseline = serde_json::json!({
2971 "smells": [
2972 {
2973 "smell_type": "long_method",
2974 "file": "src/engine.rs",
2975 "name": "analyze",
2976 "line": 100,
2977 "reason": "Method has 52 lines of code (threshold: 50)",
2978 "severity": 1
2979 }
2980 ],
2981 "files_scanned": 1,
2982 "by_file": {},
2983 "summary": { "total": 1 }
2984 });
2985 let current = serde_json::json!({
2986 "smells": [
2987 {
2988 "smell_type": "long_method",
2989 "file": "src/engine.rs",
2990 "name": "analyze",
2991 "line": 100,
2992 "reason": "Method has 80 lines of code (threshold: 50)",
2993 "severity": 2
2994 },
2995 {
2996 "smell_type": "feature_envy",
2997 "file": "src/engine.rs",
2998 "name": "diff_metrics",
2999 "line": 200,
3000 "reason": "Method accesses 5 foreign fields",
3001 "severity": 1
3002 },
3003 {
3004 "smell_type": "data_clump",
3005 "file": "src/engine.rs",
3006 "name": "analyze_batch",
3007 "line": 300,
3008 "reason": "3 parameters always appear together",
3009 "severity": 1
3010 }
3011 ],
3012 "files_scanned": 1,
3013 "by_file": {},
3014 "summary": { "total": 3 }
3015 });
3016
3017 let findings = engine.diff_smells(Path::new("src/engine.rs"), &baseline, ¤t);
3018
3019 assert_eq!(findings.len(), 2, "Should detect 2 introduced smells");
3020 let types: Vec<&str> = findings
3022 .iter()
3023 .map(|f| f.evidence["smell_type"].as_str().unwrap())
3024 .collect();
3025 assert!(
3026 types.contains(&"feature_envy"),
3027 "Should extract feature_envy type"
3028 );
3029 assert!(
3030 types.contains(&"data_clump"),
3031 "Should extract data_clump type"
3032 );
3033 assert!(
3035 findings.iter().all(|f| f.severity == "medium"),
3036 "Structural smells should be medium severity"
3037 );
3038 assert!(
3040 !types.contains(&"unknown"),
3041 "No smell should have type 'unknown'"
3042 );
3043 }
3044
3045 #[test]
3046 fn test_diff_smells_suppressed_types_filtered() {
3047 let engine = TldrDifferentialEngine::new();
3048
3049 let baseline = serde_json::json!({
3050 "smells": [
3051 { "smell_type": "long_method", "name": "process", "line": 1, "reason": "too long", "severity": 1 }
3052 ]
3053 });
3054 let current = serde_json::json!({
3056 "smells": [
3057 { "smell_type": "long_method", "name": "process", "line": 1, "reason": "too long", "severity": 1 },
3058 { "smell_type": "message_chain", "name": "chain", "line": 50, "reason": "chain length 4", "severity": 1 },
3059 { "smell_type": "long_parameter_list", "name": "many_params", "line": 80, "reason": "6 params", "severity": 1 }
3060 ]
3061 });
3062
3063 let findings = engine.diff_smells(Path::new("src/lib.rs"), &baseline, ¤t);
3064
3065 assert!(
3066 findings.is_empty(),
3067 "Suppressed smell types should produce no findings"
3068 );
3069 }
3070
3071 #[test]
3072 fn test_extract_function_entries_from_functions_key() {
3073 let json = serde_json::json!({
3074 "functions": [
3075 { "name": "foo", "value": 1 },
3076 { "name": "bar", "value": 2 }
3077 ]
3078 });
3079
3080 let entries = TldrDifferentialEngine::extract_function_entries(&json);
3081 assert_eq!(entries.len(), 2);
3082 assert_eq!(entries[0].0, "foo");
3083 assert_eq!(entries[1].0, "bar");
3084 }
3085
3086 #[test]
3087 fn test_extract_function_entries_from_root_array() {
3088 let json = serde_json::json!([
3089 { "name": "foo", "value": 1 },
3090 { "name": "bar", "value": 2 }
3091 ]);
3092
3093 let entries = TldrDifferentialEngine::extract_function_entries(&json);
3094 assert_eq!(entries.len(), 2);
3095 }
3096
3097 #[test]
3098 fn test_extract_function_entries_empty() {
3099 let json = serde_json::json!({ "other": 42 });
3100 let entries = TldrDifferentialEngine::extract_function_entries(&json);
3101 assert!(entries.is_empty());
3102 }
3103
3104 #[test]
3105 fn test_count_dead_code_entries() {
3106 let json = serde_json::json!({
3107 "dead_code": [
3108 { "name": "unused_fn", "file": "src/lib.rs" },
3109 { "name": "old_helper", "file": "src/utils.rs" }
3110 ]
3111 });
3112 assert_eq!(TldrDifferentialEngine::count_dead_code_entries(&json), 2);
3113 }
3114
3115 #[test]
3116 fn test_count_dead_code_entries_empty() {
3117 let json = serde_json::json!({ "dead_code": [] });
3118 assert_eq!(TldrDifferentialEngine::count_dead_code_entries(&json), 0);
3119 }
3120
3121 #[test]
3122 fn test_severity_thresholds() {
3123 let engine = TldrDifferentialEngine::new();
3124
3125 let high = serde_json::json!({ "functions": [{ "name": "f", "metric": 2.0, "line": 1 }] });
3127 let high_curr =
3128 serde_json::json!({ "functions": [{ "name": "f", "metric": 10.0, "line": 1 }] });
3129 let findings = engine.diff_numeric_metrics(
3130 "test-increase",
3131 "metric",
3132 Path::new("a.py"),
3133 &high,
3134 &high_curr,
3135 );
3136 assert_eq!(findings[0].severity, "high");
3137
3138 let med = serde_json::json!({ "functions": [{ "name": "f", "metric": 10.0, "line": 1 }] });
3140 let med_curr =
3141 serde_json::json!({ "functions": [{ "name": "f", "metric": 14.0, "line": 1 }] });
3142 let findings = engine.diff_numeric_metrics(
3143 "test-increase",
3144 "metric",
3145 Path::new("a.py"),
3146 &med,
3147 &med_curr,
3148 );
3149 assert_eq!(findings[0].severity, "medium");
3150
3151 let low = serde_json::json!({ "functions": [{ "name": "f", "metric": 10.0, "line": 1 }] });
3153 let low_curr =
3154 serde_json::json!({ "functions": [{ "name": "f", "metric": 11.0, "line": 1 }] });
3155 let findings = engine.diff_numeric_metrics(
3156 "test-increase",
3157 "metric",
3158 Path::new("a.py"),
3159 &low,
3160 &low_curr,
3161 );
3162 assert_eq!(findings[0].severity, "low");
3163 }
3164
3165 #[test]
3166 fn test_cognitive_delta_threshold_filters_trivial() {
3167 let engine = TldrDifferentialEngine::new();
3168
3169 let baseline =
3171 serde_json::json!({ "functions": [{ "name": "f", "cognitive": 2.0, "line": 1 }] });
3172 let current =
3173 serde_json::json!({ "functions": [{ "name": "f", "cognitive": 4.0, "line": 1 }] });
3174 let findings = engine.diff_numeric_metrics(
3175 "cognitive-increase",
3176 "cognitive",
3177 Path::new("a.rs"),
3178 &baseline,
3179 ¤t,
3180 );
3181 assert!(
3182 findings.is_empty(),
3183 "Cognitive delta of 2 should be suppressed (threshold 3)"
3184 );
3185
3186 let baseline =
3188 serde_json::json!({ "functions": [{ "name": "g", "cognitive": 5.0, "line": 1 }] });
3189 let current =
3190 serde_json::json!({ "functions": [{ "name": "g", "cognitive": 8.0, "line": 1 }] });
3191 let findings = engine.diff_numeric_metrics(
3192 "cognitive-increase",
3193 "cognitive",
3194 Path::new("a.rs"),
3195 &baseline,
3196 ¤t,
3197 );
3198 assert_eq!(findings.len(), 1, "Cognitive delta of 3 should be reported");
3199
3200 let baseline =
3202 serde_json::json!({ "functions": [{ "name": "h", "cyclomatic": 3.0, "line": 1 }] });
3203 let current =
3204 serde_json::json!({ "functions": [{ "name": "h", "cyclomatic": 4.0, "line": 1 }] });
3205 let findings = engine.diff_numeric_metrics(
3206 "complexity-increase",
3207 "cyclomatic",
3208 Path::new("a.rs"),
3209 &baseline,
3210 ¤t,
3211 );
3212 assert!(
3213 findings.is_empty(),
3214 "Complexity delta of 1 should be suppressed (threshold 2)"
3215 );
3216
3217 let baseline =
3219 serde_json::json!({ "functions": [{ "name": "j", "cyclomatic": 3.0, "line": 1 }] });
3220 let current =
3221 serde_json::json!({ "functions": [{ "name": "j", "cyclomatic": 5.0, "line": 1 }] });
3222 let findings = engine.diff_numeric_metrics(
3223 "complexity-increase",
3224 "cyclomatic",
3225 Path::new("a.rs"),
3226 &baseline,
3227 ¤t,
3228 );
3229 assert_eq!(
3230 findings.len(),
3231 1,
3232 "Complexity delta of 2 should be reported"
3233 );
3234 }
3235
3236 #[test]
3241 fn test_complexity_diff_real_tldr() {
3242 if Command::new("tldr").arg("--version").output().is_err() {
3244 eprintln!("Skipping test_complexity_diff_real_tldr: tldr not on PATH");
3245 return;
3246 }
3247
3248 let engine = TldrDifferentialEngine::with_timeout(10);
3249
3250 let tmp_dir = TempDir::new().expect("create tmpdir");
3252 let baseline_file = tmp_dir.path().join("baseline.py");
3253 let current_file = tmp_dir.path().join("current.py");
3254
3255 std::fs::write(&baseline_file, "def process(x):\n return x + 1\n")
3256 .expect("write baseline");
3257
3258 std::fs::write(
3259 ¤t_file,
3260 "def process(x):\n if x > 10:\n if x > 20:\n return x * 3\n return x * 2\n return x\n",
3261 ).expect("write current");
3262
3263 let baseline_result = engine.run_tldr_command(&["complexity"], &baseline_file);
3265 let current_result = engine.run_tldr_command(&["complexity"], ¤t_file);
3266
3267 match (baseline_result, current_result) {
3269 (Ok(baseline_json), Ok(current_json)) => {
3270 assert!(baseline_json.is_object() || baseline_json.is_array());
3272 assert!(current_json.is_object() || current_json.is_array());
3273 }
3274 (Err(e), _) => {
3275 eprintln!("Baseline complexity failed (acceptable): {}", e);
3277 }
3278 (_, Err(e)) => {
3279 eprintln!("Current complexity failed (acceptable): {}", e);
3280 }
3281 }
3282 }
3283
3284 #[test]
3289 fn test_tldr_commands_count() {
3290 assert_eq!(TLDR_COMMANDS.len(), 9);
3291 }
3292
3293 #[test]
3294 fn test_tldr_commands_local_count() {
3295 let local_count = TLDR_COMMANDS
3296 .iter()
3297 .filter(|c| c.category == TldrCategory::Local)
3298 .count();
3299 assert_eq!(local_count, 4);
3300 }
3301
3302 #[test]
3303 fn test_tldr_commands_flow_count() {
3304 let flow_count = TLDR_COMMANDS
3305 .iter()
3306 .filter(|c| c.category == TldrCategory::Flow)
3307 .count();
3308 assert_eq!(flow_count, 5);
3309 }
3310
3311 #[test]
3312 fn test_finding_types_match_commands() {
3313 assert_eq!(FINDING_TYPES.len(), TLDR_COMMANDS.len() + 2);
3317 assert!(FINDING_TYPES.contains(&"downstream-impact"));
3319 assert!(FINDING_TYPES.contains(&"breaking-change-risk"));
3320 }
3321
3322 #[test]
3327 fn test_diff_calls_new_edges_detected() {
3328 let engine = TldrDifferentialEngine::new();
3329 let baseline = serde_json::json!({
3330 "edges": [{"src_file": "a.rs", "src_func": "foo", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct"}],
3331 "edge_count": 1
3332 });
3333 let current = serde_json::json!({
3334 "edges": [
3335 {"src_file": "a.rs", "src_func": "foo", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct"},
3336 {"src_file": "a.rs", "src_func": "foo", "dst_file": "c.rs", "dst_func": "baz", "call_type": "direct"}
3337 ],
3338 "edge_count": 2
3339 });
3340 let findings = engine.diff_calls_json(&baseline, ¤t);
3341 assert!(!findings.is_empty(), "Should detect new call graph edge");
3342 assert_eq!(findings[0].finding_type, "call-graph-change");
3343 assert_eq!(findings[0].confidence, Some("DETERMINISTIC".to_string()));
3344 assert!(findings[0].finding_id.is_some());
3345 }
3346
3347 #[test]
3348 fn test_diff_calls_no_change() {
3349 let engine = TldrDifferentialEngine::new();
3350 let json = serde_json::json!({
3351 "edges": [{"src_file": "a.rs", "src_func": "foo", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct"}],
3352 "edge_count": 1
3353 });
3354 let findings = engine.diff_calls_json(&json, &json);
3355 assert!(findings.is_empty(), "No change should produce no findings");
3356 }
3357
3358 #[test]
3359 fn test_diff_calls_removed_edge_reported() {
3360 let engine = TldrDifferentialEngine::new();
3361 let baseline = serde_json::json!({
3362 "edges": [
3363 {"src_file": "a.rs", "src_func": "foo", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct"},
3364 {"src_file": "a.rs", "src_func": "foo", "dst_file": "c.rs", "dst_func": "baz", "call_type": "direct"}
3365 ],
3366 "edge_count": 2
3367 });
3368 let current = serde_json::json!({
3369 "edges": [{"src_file": "a.rs", "src_func": "foo", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct"}],
3370 "edge_count": 1
3371 });
3372 let findings = engine.diff_calls_json(&baseline, ¤t);
3373 assert!(
3374 !findings.is_empty(),
3375 "Should detect removed call graph edge"
3376 );
3377 assert_eq!(findings[0].finding_type, "call-graph-change");
3378 }
3379
3380 #[test]
3381 fn test_diff_calls_many_new_edges_medium_severity() {
3382 let engine = TldrDifferentialEngine::new();
3383 let baseline = serde_json::json!({
3384 "edges": [{"src_file": "a.rs", "src_func": "foo", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct"}],
3385 "edge_count": 1
3386 });
3387 let current = serde_json::json!({
3389 "edges": [
3390 {"src_file": "a.rs", "src_func": "foo", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct"},
3391 {"src_file": "a.rs", "src_func": "foo", "dst_file": "c.rs", "dst_func": "baz", "call_type": "direct"},
3392 {"src_file": "a.rs", "src_func": "foo", "dst_file": "d.rs", "dst_func": "qux", "call_type": "direct"},
3393 {"src_file": "a.rs", "src_func": "foo", "dst_file": "e.rs", "dst_func": "quux", "call_type": "direct"},
3394 {"src_file": "b.rs", "src_func": "bar", "dst_file": "c.rs", "dst_func": "baz", "call_type": "direct"},
3395 {"src_file": "b.rs", "src_func": "bar", "dst_file": "d.rs", "dst_func": "qux", "call_type": "direct"},
3396 {"src_file": "b.rs", "src_func": "bar", "dst_file": "e.rs", "dst_func": "quux", "call_type": "direct"}
3397 ],
3398 "edge_count": 7
3399 });
3400 let findings = engine.diff_calls_json(&baseline, ¤t);
3401 assert!(!findings.is_empty());
3402 let has_medium = findings.iter().any(|f| f.severity == "medium");
3404 assert!(
3405 has_medium,
3406 "Should produce a medium-severity summary finding for >5 new edges"
3407 );
3408 }
3409
3410 #[test]
3415 fn test_diff_deps_new_circular_dep_high_severity() {
3416 let engine = TldrDifferentialEngine::new();
3417 let baseline = serde_json::json!({
3418 "internal_dependencies": {"a.rs": ["b.rs"]},
3419 "circular_dependencies": [],
3420 "stats": {"total_internal_deps": 1}
3421 });
3422 let current = serde_json::json!({
3423 "internal_dependencies": {"a.rs": ["b.rs"], "b.rs": ["a.rs"]},
3424 "circular_dependencies": [{"path": ["a.rs", "b.rs", "a.rs"], "len": 3}],
3425 "stats": {"total_internal_deps": 2}
3426 });
3427 let findings = engine.diff_deps_json(&baseline, ¤t);
3428 assert!(
3429 !findings.is_empty(),
3430 "Should detect new circular dependency"
3431 );
3432 assert_eq!(findings[0].finding_type, "dependency-change");
3433 assert_eq!(findings[0].severity, "high");
3434 }
3435
3436 #[test]
3437 fn test_diff_deps_no_change() {
3438 let engine = TldrDifferentialEngine::new();
3439 let json = serde_json::json!({
3440 "internal_dependencies": {"a.rs": ["b.rs"]},
3441 "circular_dependencies": [],
3442 "stats": {"total_internal_deps": 1}
3443 });
3444 let findings = engine.diff_deps_json(&json, &json);
3445 assert!(findings.is_empty(), "No change should produce no findings");
3446 }
3447
3448 #[test]
3449 fn test_diff_deps_removed_circular_not_flagged() {
3450 let engine = TldrDifferentialEngine::new();
3451 let baseline = serde_json::json!({
3452 "internal_dependencies": {"a.rs": ["b.rs"], "b.rs": ["a.rs"]},
3453 "circular_dependencies": [{"path": ["a.rs", "b.rs", "a.rs"], "len": 3}],
3454 "stats": {"total_internal_deps": 2}
3455 });
3456 let current = serde_json::json!({
3457 "internal_dependencies": {"a.rs": ["b.rs"]},
3458 "circular_dependencies": [],
3459 "stats": {"total_internal_deps": 1}
3460 });
3461 let findings = engine.diff_deps_json(&baseline, ¤t);
3462 let has_high = findings.iter().any(|f| f.severity == "high");
3464 assert!(
3465 !has_high,
3466 "Removing circular dependency should not produce high severity finding"
3467 );
3468 }
3469
3470 #[test]
3471 fn test_diff_deps_internal_deps_dict_count() {
3472 let engine = TldrDifferentialEngine::new();
3474 let baseline = serde_json::json!({
3475 "internal_dependencies": {"a.rs": ["b.rs"]},
3476 "circular_dependencies": [],
3477 "stats": {"total_internal_deps": 1}
3478 });
3479 let current = serde_json::json!({
3480 "internal_dependencies": {"a.rs": ["b.rs", "c.rs", "d.rs", "e.rs", "f.rs", "g.rs", "h.rs"]},
3481 "circular_dependencies": [],
3482 "stats": {"total_internal_deps": 7}
3483 });
3484 let findings = engine.diff_deps_json(&baseline, ¤t);
3485 assert!(
3486 !findings.is_empty(),
3487 "Should detect dependency count increase of 6 (>5 threshold)"
3488 );
3489 assert_eq!(findings[0].finding_type, "dependency-change");
3490 assert_eq!(findings[0].severity, "medium");
3491 }
3492
3493 #[test]
3494 fn test_diff_deps_fallback_to_dict_counting_without_stats() {
3495 let engine = TldrDifferentialEngine::new();
3497 let baseline = serde_json::json!({
3498 "internal_dependencies": {"a.rs": ["b.rs"]},
3499 "circular_dependencies": []
3500 });
3501 let current = serde_json::json!({
3502 "internal_dependencies": {"a.rs": ["b.rs", "c.rs", "d.rs", "e.rs", "f.rs", "g.rs", "h.rs"]},
3503 "circular_dependencies": []
3504 });
3505 let findings = engine.diff_deps_json(&baseline, ¤t);
3506 assert!(
3507 !findings.is_empty(),
3508 "Should detect dependency count increase even without stats field"
3509 );
3510 }
3511
3512 #[test]
3517 fn test_diff_coupling_instability_increase_detected() {
3518 let engine = TldrDifferentialEngine::new();
3519 let baseline = serde_json::json!({
3520 "martin_metrics": [
3521 {"module": "core", "ca": 5, "ce": 2, "instability": 0.29, "abstractness": 0.1}
3522 ],
3523 "pairwise_coupling": []
3524 });
3525 let current = serde_json::json!({
3526 "martin_metrics": [
3527 {"module": "core", "ca": 5, "ce": 8, "instability": 0.62, "abstractness": 0.1}
3528 ],
3529 "pairwise_coupling": []
3530 });
3531 let findings = engine.diff_coupling_json(&baseline, ¤t);
3532 assert!(!findings.is_empty(), "Should detect instability increase");
3533 assert_eq!(findings[0].finding_type, "coupling-increase");
3534 }
3535
3536 #[test]
3537 fn test_diff_coupling_no_change() {
3538 let engine = TldrDifferentialEngine::new();
3539 let json = serde_json::json!({
3540 "martin_metrics": [
3541 {"module": "core", "ca": 5, "ce": 2, "instability": 0.29, "abstractness": 0.1}
3542 ],
3543 "pairwise_coupling": []
3544 });
3545 let findings = engine.diff_coupling_json(&json, &json);
3546 assert!(findings.is_empty(), "No change should produce no findings");
3547 }
3548
3549 #[test]
3550 fn test_diff_coupling_improvement_not_flagged() {
3551 let engine = TldrDifferentialEngine::new();
3552 let baseline = serde_json::json!({
3553 "martin_metrics": [
3554 {"module": "core", "ca": 5, "ce": 8, "instability": 0.62, "abstractness": 0.1}
3555 ],
3556 "pairwise_coupling": []
3557 });
3558 let current = serde_json::json!({
3559 "martin_metrics": [
3560 {"module": "core", "ca": 5, "ce": 2, "instability": 0.29, "abstractness": 0.1}
3561 ],
3562 "pairwise_coupling": []
3563 });
3564 let findings = engine.diff_coupling_json(&baseline, ¤t);
3565 assert!(
3566 findings.is_empty(),
3567 "Coupling decrease should not produce findings"
3568 );
3569 }
3570
3571 #[test]
3576 fn test_diff_cohesion_lcom4_increase_detected() {
3577 let engine = TldrDifferentialEngine::new();
3578 let baseline = serde_json::json!({
3579 "classes": [
3580 {"class_name": "Engine", "lcom4": 1, "method_count": 5, "field_count": 3}
3581 ],
3582 "summary": {"total_classes": 1}
3583 });
3584 let current = serde_json::json!({
3585 "classes": [
3586 {"class_name": "Engine", "lcom4": 4, "method_count": 8, "field_count": 3}
3587 ],
3588 "summary": {"total_classes": 1}
3589 });
3590 let findings = engine.diff_cohesion_json(&baseline, ¤t);
3591 assert!(!findings.is_empty(), "Should detect LCOM4 increase");
3592 assert_eq!(findings[0].finding_type, "cohesion-decrease");
3593 }
3594
3595 #[test]
3596 fn test_diff_cohesion_no_change() {
3597 let engine = TldrDifferentialEngine::new();
3598 let json = serde_json::json!({
3599 "classes": [
3600 {"class_name": "Engine", "lcom4": 2, "method_count": 5, "field_count": 3}
3601 ],
3602 "summary": {"total_classes": 1}
3603 });
3604 let findings = engine.diff_cohesion_json(&json, &json);
3605 assert!(findings.is_empty(), "No change should produce no findings");
3606 }
3607
3608 #[test]
3609 fn test_diff_cohesion_improvement_not_flagged() {
3610 let engine = TldrDifferentialEngine::new();
3611 let baseline = serde_json::json!({
3612 "classes": [
3613 {"class_name": "Engine", "lcom4": 5, "method_count": 10, "field_count": 3}
3614 ],
3615 "summary": {"total_classes": 1}
3616 });
3617 let current = serde_json::json!({
3618 "classes": [
3619 {"class_name": "Engine", "lcom4": 1, "method_count": 4, "field_count": 3}
3620 ],
3621 "summary": {"total_classes": 1}
3622 });
3623 let findings = engine.diff_cohesion_json(&baseline, ¤t);
3624 assert!(
3625 findings.is_empty(),
3626 "LCOM4 decrease is an improvement, should not produce findings"
3627 );
3628 }
3629
3630 #[test]
3631 fn test_diff_cohesion_new_class_high_lcom4_info() {
3632 let engine = TldrDifferentialEngine::new();
3633 let baseline = serde_json::json!({
3634 "classes": [],
3635 "summary": {"total_classes": 0}
3636 });
3637 let current = serde_json::json!({
3638 "classes": [
3639 {"class_name": "GodObject", "lcom4": 5, "method_count": 12, "field_count": 0, "verdict": "split_candidate"}
3640 ],
3641 "summary": {"total_classes": 1}
3642 });
3643 let findings = engine.diff_cohesion_json(&baseline, ¤t);
3644 assert!(
3645 !findings.is_empty(),
3646 "New class with high LCOM4 should be flagged"
3647 );
3648 assert_eq!(findings[0].severity, "info");
3649 }
3650
3651 #[test]
3652 fn test_diff_cohesion_backward_compat_name_field() {
3653 let engine = TldrDifferentialEngine::new();
3655 let baseline = serde_json::json!({
3656 "classes": [{"name": "Legacy", "lcom4": 1}],
3657 "summary": {"total_classes": 1}
3658 });
3659 let current = serde_json::json!({
3660 "classes": [{"name": "Legacy", "lcom4": 4}],
3661 "summary": {"total_classes": 1}
3662 });
3663 let findings = engine.diff_cohesion_json(&baseline, ¤t);
3664 assert!(
3665 !findings.is_empty(),
3666 "Should still work with 'name' field as fallback"
3667 );
3668 }
3669
3670 #[test]
3675 fn test_l2context_default_base_ref() {
3676 let ctx = empty_context();
3677 assert_eq!(ctx.base_ref, "HEAD", "Default base_ref should be HEAD");
3678 }
3679
3680 #[test]
3681 fn test_l2context_with_base_ref() {
3682 let ctx = empty_context().with_base_ref(String::from("main"));
3683 assert_eq!(ctx.base_ref, "main");
3684 }
3685
3686 #[test]
3691 fn test_analyze_flow_commands_accepts_base_ref_and_language() {
3692 let engine = TldrDifferentialEngine::new();
3693 let mut partial_reasons = Vec::new();
3694 let _findings = engine.analyze_flow_commands(
3696 Path::new("/tmp/nonexistent-project-for-test"),
3697 "HEAD",
3698 "rust",
3699 None,
3700 &mut partial_reasons,
3701 );
3702 }
3705
3706 #[test]
3711 fn test_run_tldr_flow_command_exists() {
3712 let engine = TldrDifferentialEngine::new();
3714 let result = engine.run_tldr_flow_command(
3716 "calls",
3717 &["calls"],
3718 Path::new("/tmp/nonexistent-project"),
3719 "rust",
3720 );
3721 let _ = result;
3723 }
3724
3725 #[test]
3726 fn test_run_tldr_flow_command_builds_args_with_lang() {
3727 let engine = TldrDifferentialEngine::with_timeout(1);
3733
3734 for lang in &["python", "rust", "typescript", "go", "java"] {
3735 let result = engine.run_tldr_flow_command(
3736 "dead",
3737 &["dead"],
3738 Path::new("/tmp/nonexistent"),
3739 lang,
3740 );
3741 let _ = result;
3743 }
3744 }
3745
3746 #[test]
3747 fn test_run_tldr_flow_command_calls_gets_respect_ignore() {
3748 let engine = TldrDifferentialEngine::with_timeout(1);
3751
3752 let _calls_result = engine.run_tldr_flow_command(
3754 "calls",
3755 &["calls"],
3756 Path::new("/tmp/nonexistent"),
3757 "rust",
3758 );
3759 let _deps_result =
3760 engine.run_tldr_flow_command("deps", &["deps"], Path::new("/tmp/nonexistent"), "rust");
3761 }
3762
3763 #[test]
3768 fn test_flow_engine_timeout_is_300s() {
3769 let engine = TldrDifferentialEngine::with_timeout(10);
3776 let mut partial_reasons = Vec::new();
3777 let _findings = engine.analyze_flow_commands(
3778 Path::new("/tmp/nonexistent-project"),
3779 "HEAD",
3780 "python",
3781 None,
3782 &mut partial_reasons,
3783 );
3784 }
3787
3788 #[test]
3793 fn test_analyze_passes_language_to_flow_commands() {
3794 let engine = TldrDifferentialEngine::new();
3797 let ctx = L2Context::new(
3798 PathBuf::from("/tmp/test-project-lang"),
3799 Language::Python,
3800 vec![],
3801 FunctionDiff {
3802 changed: vec![],
3803 inserted: vec![],
3804 deleted: vec![],
3805 },
3806 HashMap::new(),
3807 HashMap::new(),
3808 HashMap::new(),
3809 );
3810 let output = engine.analyze(&ctx);
3811 match &output.status {
3814 AnalyzerStatus::Complete => {}
3815 AnalyzerStatus::Partial { .. } => {}
3816 other => panic!("Unexpected status: {:?}", other),
3817 }
3818 }
3819
3820 #[test]
3825 fn test_finding_types_includes_impact() {
3826 let engine = TldrDifferentialEngine::new();
3827 let types = engine.finding_types();
3828 assert!(
3829 types.contains(&"downstream-impact"),
3830 "FINDING_TYPES must include downstream-impact"
3831 );
3832 assert!(
3833 types.contains(&"breaking-change-risk"),
3834 "FINDING_TYPES must include breaking-change-risk"
3835 );
3836 }
3837
3838 #[test]
3839 fn test_downstream_impact_severity_high() {
3840 let json = serde_json::json!({
3841 "summary": {
3842 "importer_count": 15,
3843 "direct_caller_count": 3,
3844 "affected_test_count": 2
3845 }
3846 });
3847 let file = PathBuf::from("src/lib.rs");
3848 let findings = TldrDifferentialEngine::parse_whatbreaks_findings(&file, &json);
3849 assert_eq!(findings.len(), 1);
3850 assert_eq!(findings[0].finding_type, "downstream-impact");
3851 assert_eq!(findings[0].severity, "high");
3852 assert_eq!(findings[0].function, "(file-level)");
3853 assert_eq!(findings[0].file, file);
3854 assert_eq!(findings[0].confidence.as_deref(), Some("DETERMINISTIC"));
3855 assert!(findings[0].finding_id.is_some());
3856
3857 let ev = &findings[0].evidence;
3859 assert_eq!(ev["command"], "whatbreaks");
3860 assert_eq!(ev["importer_count"], 15);
3861 assert_eq!(ev["direct_caller_count"], 3);
3862 assert_eq!(ev["affected_test_count"], 2);
3863 }
3864
3865 #[test]
3866 fn test_downstream_impact_severity_medium() {
3867 let json = serde_json::json!({
3868 "summary": {
3869 "importer_count": 7,
3870 "direct_caller_count": 1,
3871 "affected_test_count": 0
3872 }
3873 });
3874 let file = PathBuf::from("src/core.rs");
3875 let findings = TldrDifferentialEngine::parse_whatbreaks_findings(&file, &json);
3876 assert_eq!(findings.len(), 1);
3877 assert_eq!(findings[0].severity, "medium");
3878 }
3879
3880 #[test]
3881 fn test_downstream_impact_severity_low() {
3882 let json = serde_json::json!({
3883 "summary": {
3884 "importer_count": 2,
3885 "direct_caller_count": 0,
3886 "affected_test_count": 1
3887 }
3888 });
3889 let file = PathBuf::from("src/utils.rs");
3890 let findings = TldrDifferentialEngine::parse_whatbreaks_findings(&file, &json);
3891 assert_eq!(findings.len(), 1);
3892 assert_eq!(findings[0].severity, "low");
3893 }
3894
3895 #[test]
3896 fn test_downstream_impact_no_findings_when_no_importers() {
3897 let json = serde_json::json!({
3898 "summary": {
3899 "importer_count": 0,
3900 "direct_caller_count": 0,
3901 "affected_test_count": 0
3902 }
3903 });
3904 let file = PathBuf::from("src/leaf.rs");
3905 let findings = TldrDifferentialEngine::parse_whatbreaks_findings(&file, &json);
3906 assert!(
3907 findings.is_empty(),
3908 "Zero importers and zero callers should produce no findings"
3909 );
3910 }
3911
3912 #[test]
3913 fn test_downstream_impact_boundary_importer_3() {
3914 let json = serde_json::json!({
3916 "summary": {
3917 "importer_count": 3,
3918 "direct_caller_count": 0,
3919 "affected_test_count": 0
3920 }
3921 });
3922 let file = PathBuf::from("src/boundary.rs");
3923 let findings = TldrDifferentialEngine::parse_whatbreaks_findings(&file, &json);
3924 assert_eq!(findings.len(), 1);
3925 assert_eq!(findings[0].severity, "low");
3926 }
3927
3928 #[test]
3929 fn test_downstream_impact_boundary_importer_4() {
3930 let json = serde_json::json!({
3932 "summary": {
3933 "importer_count": 4,
3934 "direct_caller_count": 0,
3935 "affected_test_count": 0
3936 }
3937 });
3938 let file = PathBuf::from("src/boundary4.rs");
3939 let findings = TldrDifferentialEngine::parse_whatbreaks_findings(&file, &json);
3940 assert_eq!(findings.len(), 1);
3941 assert_eq!(findings[0].severity, "medium");
3942 }
3943
3944 #[test]
3945 fn test_downstream_impact_boundary_importer_10() {
3946 let json = serde_json::json!({
3948 "summary": {
3949 "importer_count": 10,
3950 "direct_caller_count": 0,
3951 "affected_test_count": 0
3952 }
3953 });
3954 let file = PathBuf::from("src/boundary10.rs");
3955 let findings = TldrDifferentialEngine::parse_whatbreaks_findings(&file, &json);
3956 assert_eq!(findings.len(), 1);
3957 assert_eq!(findings[0].severity, "medium");
3958 }
3959
3960 #[test]
3961 fn test_downstream_impact_boundary_importer_11() {
3962 let json = serde_json::json!({
3964 "summary": {
3965 "importer_count": 11,
3966 "direct_caller_count": 0,
3967 "affected_test_count": 0
3968 }
3969 });
3970 let file = PathBuf::from("src/boundary11.rs");
3971 let findings = TldrDifferentialEngine::parse_whatbreaks_findings(&file, &json);
3972 assert_eq!(findings.len(), 1);
3973 assert_eq!(findings[0].severity, "high");
3974 }
3975
3976 #[test]
3977 fn test_downstream_impact_callers_only() {
3978 let json = serde_json::json!({
3980 "summary": {
3981 "importer_count": 0,
3982 "direct_caller_count": 5,
3983 "affected_test_count": 0
3984 }
3985 });
3986 let file = PathBuf::from("src/callers.rs");
3987 let findings = TldrDifferentialEngine::parse_whatbreaks_findings(&file, &json);
3988 assert_eq!(findings.len(), 1);
3989 assert_eq!(findings[0].severity, "low");
3990 assert!(findings[0].message.contains("5 direct callers"));
3991 }
3992
3993 #[test]
3994 fn test_downstream_impact_summary_at_top_level() {
3995 let json = serde_json::json!({
3997 "importer_count": 6,
3998 "direct_caller_count": 2,
3999 "affected_test_count": 1
4000 });
4001 let file = PathBuf::from("src/flat.rs");
4002 let findings = TldrDifferentialEngine::parse_whatbreaks_findings(&file, &json);
4003 assert_eq!(findings.len(), 1);
4004 assert_eq!(findings[0].severity, "medium");
4005 }
4006
4007 #[test]
4012 fn test_function_impact_high_severity() {
4013 let json = serde_json::json!({
4014 "targets": {
4015 "process_data": {
4016 "caller_count": 8,
4017 "callers": [
4018 { "file": "main.rs", "function": "run" },
4019 { "file": "handler.rs", "function": "handle" },
4020 { "file": "api.rs", "function": "endpoint" },
4021 { "file": "worker.rs", "function": "execute" },
4022 { "file": "batch.rs", "function": "process_all" },
4023 { "file": "test.rs", "function": "test_it" },
4024 ]
4025 }
4026 }
4027 });
4028 let findings = TldrDifferentialEngine::parse_impact_findings("process_data", &json);
4029 assert_eq!(findings.len(), 1);
4030 assert_eq!(findings[0].finding_type, "breaking-change-risk");
4031 assert_eq!(findings[0].severity, "high");
4032 assert_eq!(findings[0].function, "process_data");
4033 assert_eq!(findings[0].file, PathBuf::from("(project)"));
4034 assert_eq!(findings[0].confidence.as_deref(), Some("DETERMINISTIC"));
4035 assert!(findings[0].finding_id.is_some());
4036
4037 let ev = &findings[0].evidence;
4039 assert_eq!(ev["command"], "impact");
4040 assert_eq!(ev["caller_count"], 8);
4041 let preview = ev["callers_preview"].as_array().unwrap();
4043 assert_eq!(preview.len(), 5);
4044 }
4045
4046 #[test]
4047 fn test_function_impact_medium_severity() {
4048 let json = serde_json::json!({
4049 "targets": {
4050 "helper_fn": {
4051 "caller_count": 3,
4052 "callers": [
4053 { "file": "a.rs", "function": "foo" },
4054 { "file": "b.rs", "function": "bar" },
4055 { "file": "c.rs", "function": "baz" },
4056 ]
4057 }
4058 }
4059 });
4060 let findings = TldrDifferentialEngine::parse_impact_findings("helper_fn", &json);
4061 assert_eq!(findings.len(), 1);
4062 assert_eq!(findings[0].severity, "medium");
4063 }
4064
4065 #[test]
4066 fn test_function_impact_info_severity() {
4067 let json = serde_json::json!({
4068 "targets": {
4069 "rare_fn": {
4070 "caller_count": 1,
4071 "callers": [
4072 { "file": "only.rs", "function": "sole_caller" }
4073 ]
4074 }
4075 }
4076 });
4077 let findings = TldrDifferentialEngine::parse_impact_findings("rare_fn", &json);
4078 assert_eq!(findings.len(), 1);
4079 assert_eq!(findings[0].severity, "info");
4080 }
4081
4082 #[test]
4083 fn test_function_impact_no_callers() {
4084 let json = serde_json::json!({
4085 "targets": {
4086 "leaf_fn": {
4087 "caller_count": 0,
4088 "callers": []
4089 }
4090 }
4091 });
4092 let findings = TldrDifferentialEngine::parse_impact_findings("leaf_fn", &json);
4093 assert!(
4094 findings.is_empty(),
4095 "Function with zero callers should produce no findings"
4096 );
4097 }
4098
4099 #[test]
4100 fn test_function_impact_missing_target() {
4101 let json = serde_json::json!({
4103 "targets": {
4104 "other_fn": {
4105 "caller_count": 5,
4106 "callers": []
4107 }
4108 }
4109 });
4110 let findings = TldrDifferentialEngine::parse_impact_findings("missing_fn", &json);
4111 assert!(
4112 findings.is_empty(),
4113 "Missing target key should produce no findings"
4114 );
4115 }
4116
4117 #[test]
4118 fn test_function_impact_fallback_top_level() {
4119 let json = serde_json::json!({
4121 "caller_count": 4,
4122 "callers": [
4123 { "file": "x.rs", "function": "a" },
4124 { "file": "y.rs", "function": "b" },
4125 { "file": "z.rs", "function": "c" },
4126 { "file": "w.rs", "function": "d" },
4127 ]
4128 });
4129 let findings = TldrDifferentialEngine::parse_impact_findings("any_fn", &json);
4130 assert_eq!(findings.len(), 1);
4131 assert_eq!(findings[0].severity, "medium");
4132 assert_eq!(findings[0].evidence["caller_count"], 4);
4133 }
4134
4135 #[test]
4136 fn test_function_impact_boundary_caller_2() {
4137 let json = serde_json::json!({
4139 "targets": {
4140 "boundary_fn": {
4141 "caller_count": 2,
4142 "callers": [
4143 { "file": "a.rs", "function": "x" },
4144 { "file": "b.rs", "function": "y" },
4145 ]
4146 }
4147 }
4148 });
4149 let findings = TldrDifferentialEngine::parse_impact_findings("boundary_fn", &json);
4150 assert_eq!(findings.len(), 1);
4151 assert_eq!(findings[0].severity, "medium");
4152 }
4153
4154 #[test]
4155 fn test_function_impact_boundary_caller_5() {
4156 let json = serde_json::json!({
4158 "targets": {
4159 "five_fn": {
4160 "caller_count": 5,
4161 "callers": []
4162 }
4163 }
4164 });
4165 let findings = TldrDifferentialEngine::parse_impact_findings("five_fn", &json);
4166 assert_eq!(findings.len(), 1);
4167 assert_eq!(findings[0].severity, "medium");
4168 }
4169
4170 #[test]
4171 fn test_function_impact_boundary_caller_6() {
4172 let json = serde_json::json!({
4174 "targets": {
4175 "six_fn": {
4176 "caller_count": 6,
4177 "callers": []
4178 }
4179 }
4180 });
4181 let findings = TldrDifferentialEngine::parse_impact_findings("six_fn", &json);
4182 assert_eq!(findings.len(), 1);
4183 assert_eq!(findings[0].severity, "high");
4184 }
4185
4186 #[test]
4187 fn test_downstream_impact_finding_id_deterministic() {
4188 let json = serde_json::json!({
4190 "summary": {
4191 "importer_count": 5,
4192 "direct_caller_count": 2,
4193 "affected_test_count": 1
4194 }
4195 });
4196 let file = PathBuf::from("src/stable.rs");
4197 let findings1 = TldrDifferentialEngine::parse_whatbreaks_findings(&file, &json);
4198 let findings2 = TldrDifferentialEngine::parse_whatbreaks_findings(&file, &json);
4199 assert_eq!(findings1[0].finding_id, findings2[0].finding_id);
4200 }
4201
4202 #[test]
4203 fn test_function_impact_finding_id_deterministic() {
4204 let json = serde_json::json!({
4205 "targets": {
4206 "stable_fn": {
4207 "caller_count": 3,
4208 "callers": []
4209 }
4210 }
4211 });
4212 let findings1 = TldrDifferentialEngine::parse_impact_findings("stable_fn", &json);
4213 let findings2 = TldrDifferentialEngine::parse_impact_findings("stable_fn", &json);
4214 assert_eq!(findings1[0].finding_id, findings2[0].finding_id);
4215 }
4216
4217 #[test]
4222 fn test_build_reverse_caller_map_basic() {
4223 let json = serde_json::json!({
4226 "edges": [
4227 { "src_file": "a.rs", "src_func": "foo", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct" },
4228 { "src_file": "c.rs", "src_func": "baz", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct" }
4229 ]
4230 });
4231 let map = TldrDifferentialEngine::build_reverse_caller_map(&json);
4232 assert_eq!(map.len(), 1);
4233 assert_eq!(map["bar"].len(), 2);
4234 assert!(map["bar"].contains(&("a.rs".to_string(), "foo".to_string())));
4235 assert!(map["bar"].contains(&("c.rs".to_string(), "baz".to_string())));
4236 }
4237
4238 #[test]
4239 fn test_build_reverse_caller_map_multiple_targets() {
4240 let json = serde_json::json!({
4242 "edges": [
4243 { "src_file": "a.rs", "src_func": "foo", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct" },
4244 { "src_file": "c.rs", "src_func": "baz", "dst_file": "d.rs", "dst_func": "qux", "call_type": "direct" }
4245 ]
4246 });
4247 let map = TldrDifferentialEngine::build_reverse_caller_map(&json);
4248 assert_eq!(map.len(), 2);
4249 assert_eq!(map["bar"].len(), 1);
4250 assert_eq!(map["qux"].len(), 1);
4251 }
4252
4253 #[test]
4254 fn test_build_reverse_caller_map_empty_edges() {
4255 let json = serde_json::json!({ "edges": [] });
4256 let map = TldrDifferentialEngine::build_reverse_caller_map(&json);
4257 assert!(map.is_empty());
4258 }
4259
4260 #[test]
4261 fn test_build_reverse_caller_map_no_edges_key() {
4262 let json = serde_json::json!({ "nodes": [] });
4263 let map = TldrDifferentialEngine::build_reverse_caller_map(&json);
4264 assert!(map.is_empty());
4265 }
4266
4267 #[test]
4268 fn test_build_reverse_caller_map_malformed_edges_skipped() {
4269 let json = serde_json::json!({
4271 "edges": [
4272 { "src_file": "a.rs", "src_func": "foo" },
4273 { "src_func": "bar", "dst_func": "baz" },
4274 { "src_file": "valid.rs", "src_func": "caller", "dst_file": "t.rs", "dst_func": "target", "call_type": "direct" }
4275 ]
4276 });
4277 let map = TldrDifferentialEngine::build_reverse_caller_map(&json);
4278 assert_eq!(map.len(), 1);
4280 assert_eq!(map["target"].len(), 1);
4281 }
4282
4283 #[test]
4288 fn test_parse_impact_from_callgraph_high_severity() {
4289 let callers = vec![
4291 ("main.rs".to_string(), "run".to_string()),
4292 ("handler.rs".to_string(), "handle".to_string()),
4293 ("api.rs".to_string(), "endpoint".to_string()),
4294 ("worker.rs".to_string(), "execute".to_string()),
4295 ("batch.rs".to_string(), "process_all".to_string()),
4296 ("scheduler.rs".to_string(), "schedule".to_string()),
4297 ];
4298 let findings =
4299 TldrDifferentialEngine::parse_impact_findings_from_callgraph("process_data", &callers);
4300
4301 assert_eq!(findings.len(), 1);
4302 assert_eq!(findings[0].finding_type, "breaking-change-risk");
4303 assert_eq!(findings[0].severity, "high");
4304 assert_eq!(findings[0].evidence["caller_count"], 6);
4305 assert_eq!(findings[0].evidence["command"], "calls");
4306 assert!(findings[0].message.contains("process_data"));
4307 assert!(findings[0].message.contains("6 callers"));
4308 let preview = findings[0].evidence["callers_preview"].as_array().unwrap();
4310 assert_eq!(preview.len(), 5);
4311 }
4312
4313 #[test]
4314 fn test_parse_impact_from_callgraph_medium_severity() {
4315 let callers = vec![
4317 ("a.rs".to_string(), "foo".to_string()),
4318 ("b.rs".to_string(), "bar".to_string()),
4319 ("c.rs".to_string(), "baz".to_string()),
4320 ];
4321 let findings =
4322 TldrDifferentialEngine::parse_impact_findings_from_callgraph("helper", &callers);
4323
4324 assert_eq!(findings.len(), 1);
4325 assert_eq!(findings[0].severity, "medium");
4326 assert_eq!(findings[0].evidence["caller_count"], 3);
4327 }
4328
4329 #[test]
4330 fn test_parse_impact_from_callgraph_info_severity() {
4331 let callers = vec![("main.rs".to_string(), "run".to_string())];
4333 let findings =
4334 TldrDifferentialEngine::parse_impact_findings_from_callgraph("private_fn", &callers);
4335
4336 assert_eq!(findings.len(), 1);
4337 assert_eq!(findings[0].severity, "info");
4338 assert_eq!(findings[0].evidence["caller_count"], 1);
4339 }
4340
4341 #[test]
4342 fn test_parse_impact_from_callgraph_no_callers() {
4343 let callers: Vec<(String, String)> = vec![];
4345 let findings =
4346 TldrDifferentialEngine::parse_impact_findings_from_callgraph("unused_fn", &callers);
4347 assert!(findings.is_empty());
4348 }
4349
4350 #[test]
4351 fn test_parse_impact_from_callgraph_callers_preview_format() {
4352 let callers = vec![
4354 ("main.rs".to_string(), "run".to_string()),
4355 ("handler.rs".to_string(), "handle".to_string()),
4356 ];
4357 let findings =
4358 TldrDifferentialEngine::parse_impact_findings_from_callgraph("target", &callers);
4359
4360 let preview = findings[0].evidence["callers_preview"].as_array().unwrap();
4361 assert_eq!(preview[0], "main.rs::run");
4362 assert_eq!(preview[1], "handler.rs::handle");
4363 }
4364
4365 #[test]
4366 fn test_parse_impact_from_callgraph_finding_fields() {
4367 let callers = vec![
4369 ("src.rs".to_string(), "caller".to_string()),
4370 ("other.rs".to_string(), "other_caller".to_string()),
4371 ];
4372 let findings =
4373 TldrDifferentialEngine::parse_impact_findings_from_callgraph("my_func", &callers);
4374
4375 assert_eq!(findings[0].finding_type, "breaking-change-risk");
4376 assert_eq!(findings[0].file, PathBuf::from("(project)"));
4377 assert_eq!(findings[0].function, "my_func");
4378 assert_eq!(findings[0].line, 0);
4379 assert_eq!(findings[0].confidence, Some("DETERMINISTIC".to_string()));
4380 assert!(findings[0].finding_id.is_some());
4381 }
4382
4383 #[test]
4384 fn test_parse_impact_from_callgraph_boundary_5_callers() {
4385 let callers: Vec<(String, String)> = (0..5)
4387 .map(|i| (format!("f{}.rs", i), format!("fn{}", i)))
4388 .collect();
4389 let findings =
4390 TldrDifferentialEngine::parse_impact_findings_from_callgraph("boundary_fn", &callers);
4391
4392 assert_eq!(findings[0].severity, "medium");
4393 let preview = findings[0].evidence["callers_preview"].as_array().unwrap();
4395 assert_eq!(preview.len(), 5);
4396 }
4397
4398 #[test]
4399 fn test_parse_impact_from_callgraph_boundary_2_callers() {
4400 let callers = vec![
4402 ("a.rs".to_string(), "fa".to_string()),
4403 ("b.rs".to_string(), "fb".to_string()),
4404 ];
4405 let findings =
4406 TldrDifferentialEngine::parse_impact_findings_from_callgraph("edge_fn", &callers);
4407 assert_eq!(findings[0].severity, "medium");
4408 }
4409
4410 #[test]
4417 fn test_bugbot_derive_deps_basic() {
4418 let calls = serde_json::json!({
4420 "edges": [
4421 {"src_file": "a.rs", "src_func": "foo", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct"}
4422 ]
4423 });
4424 let deps = TldrDifferentialEngine::derive_deps_from_calls(&calls);
4425 let internal = deps["internal_dependencies"].as_object().unwrap();
4426 assert!(internal.contains_key("a.rs"));
4427 let a_deps = internal["a.rs"].as_array().unwrap();
4428 assert_eq!(a_deps.len(), 1);
4429 assert!(a_deps.iter().any(|v| v.as_str() == Some("b.rs")));
4430 assert_eq!(deps["stats"]["total_internal_deps"].as_u64().unwrap(), 1);
4431 }
4432
4433 #[test]
4434 fn test_bugbot_derive_deps_intra_file_excluded() {
4435 let calls = serde_json::json!({
4437 "edges": [
4438 {"src_file": "a.rs", "src_func": "foo", "dst_file": "a.rs", "dst_func": "bar", "call_type": "direct"}
4439 ]
4440 });
4441 let deps = TldrDifferentialEngine::derive_deps_from_calls(&calls);
4442 let internal = deps["internal_dependencies"].as_object().unwrap();
4443 assert!(internal.is_empty() || internal.values().all(|v| v.as_array().unwrap().is_empty()));
4444 assert_eq!(deps["stats"]["total_internal_deps"].as_u64().unwrap(), 0);
4445 }
4446
4447 #[test]
4448 fn test_bugbot_derive_deps_deduplication() {
4449 let calls = serde_json::json!({
4451 "edges": [
4452 {"src_file": "a.rs", "src_func": "foo", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct"},
4453 {"src_file": "a.rs", "src_func": "baz", "dst_file": "b.rs", "dst_func": "qux", "call_type": "direct"}
4454 ]
4455 });
4456 let deps = TldrDifferentialEngine::derive_deps_from_calls(&calls);
4457 let a_deps = deps["internal_dependencies"]["a.rs"].as_array().unwrap();
4458 assert_eq!(a_deps.len(), 1);
4459 assert_eq!(deps["stats"]["total_internal_deps"].as_u64().unwrap(), 1);
4460 }
4461
4462 #[test]
4463 fn test_bugbot_derive_deps_circular_detection() {
4464 let calls = serde_json::json!({
4466 "edges": [
4467 {"src_file": "a.rs", "src_func": "f1", "dst_file": "b.rs", "dst_func": "f2", "call_type": "direct"},
4468 {"src_file": "b.rs", "src_func": "f2", "dst_file": "a.rs", "dst_func": "f3", "call_type": "direct"}
4469 ]
4470 });
4471 let deps = TldrDifferentialEngine::derive_deps_from_calls(&calls);
4472 let circular = deps["circular_dependencies"].as_array().unwrap();
4473 assert!(
4474 !circular.is_empty(),
4475 "should detect circular dependency between a.rs and b.rs"
4476 );
4477 let path = circular[0]["path"].as_array().unwrap();
4479 let path_strs: Vec<&str> = path.iter().map(|v| v.as_str().unwrap()).collect();
4480 assert!(path_strs.contains(&"a.rs"));
4481 assert!(path_strs.contains(&"b.rs"));
4482 }
4483
4484 #[test]
4485 fn test_bugbot_derive_deps_empty_edges() {
4486 let calls = serde_json::json!({ "edges": [] });
4487 let deps = TldrDifferentialEngine::derive_deps_from_calls(&calls);
4488 let internal = deps["internal_dependencies"].as_object().unwrap();
4489 assert!(internal.is_empty());
4490 let circular = deps["circular_dependencies"].as_array().unwrap();
4491 assert!(circular.is_empty());
4492 assert_eq!(deps["stats"]["total_internal_deps"].as_u64().unwrap(), 0);
4493 }
4494
4495 #[test]
4496 fn test_bugbot_derive_deps_no_edges_key() {
4497 let calls = serde_json::json!({ "nodes": ["a.rs:foo"] });
4499 let deps = TldrDifferentialEngine::derive_deps_from_calls(&calls);
4500 assert_eq!(deps["stats"]["total_internal_deps"].as_u64().unwrap(), 0);
4501 }
4502
4503 #[test]
4506 fn test_bugbot_derive_coupling_basic() {
4507 let calls = serde_json::json!({
4509 "edges": [
4510 {"src_file": "a.rs", "src_func": "f1", "dst_file": "b.rs", "dst_func": "g1", "call_type": "direct"},
4511 {"src_file": "c.rs", "src_func": "f2", "dst_file": "b.rs", "dst_func": "g2", "call_type": "direct"}
4512 ]
4513 });
4514 let coupling = TldrDifferentialEngine::derive_coupling_from_calls(&calls);
4515 let metrics = coupling["martin_metrics"].as_array().unwrap();
4516
4517 let b_metric = metrics
4519 .iter()
4520 .find(|m| m["module"].as_str() == Some("b.rs"))
4521 .unwrap();
4522 assert_eq!(b_metric["ca"].as_u64().unwrap(), 2);
4523 assert_eq!(b_metric["ce"].as_u64().unwrap(), 0);
4524 assert!((b_metric["instability"].as_f64().unwrap() - 0.0).abs() < 0.01);
4525
4526 let a_metric = metrics
4528 .iter()
4529 .find(|m| m["module"].as_str() == Some("a.rs"))
4530 .unwrap();
4531 assert_eq!(a_metric["ca"].as_u64().unwrap(), 0);
4532 assert_eq!(a_metric["ce"].as_u64().unwrap(), 1);
4533 assert!((a_metric["instability"].as_f64().unwrap() - 1.0).abs() < 0.01);
4534 }
4535
4536 #[test]
4537 fn test_bugbot_derive_coupling_bidirectional() {
4538 let calls = serde_json::json!({
4540 "edges": [
4541 {"src_file": "a.rs", "src_func": "f1", "dst_file": "b.rs", "dst_func": "g1", "call_type": "direct"},
4542 {"src_file": "b.rs", "src_func": "g2", "dst_file": "a.rs", "dst_func": "f2", "call_type": "direct"}
4543 ]
4544 });
4545 let coupling = TldrDifferentialEngine::derive_coupling_from_calls(&calls);
4546 let metrics = coupling["martin_metrics"].as_array().unwrap();
4547
4548 for module_name in &["a.rs", "b.rs"] {
4549 let m = metrics
4550 .iter()
4551 .find(|m| m["module"].as_str() == Some(*module_name))
4552 .unwrap_or_else(|| panic!("missing metric for {}", module_name));
4553 assert_eq!(
4554 m["ca"].as_u64().unwrap(),
4555 1,
4556 "{} Ca should be 1",
4557 module_name
4558 );
4559 assert_eq!(
4560 m["ce"].as_u64().unwrap(),
4561 1,
4562 "{} Ce should be 1",
4563 module_name
4564 );
4565 assert!(
4566 (m["instability"].as_f64().unwrap() - 0.5).abs() < 0.01,
4567 "{} instability should be 0.5",
4568 module_name
4569 );
4570 }
4571 }
4572
4573 #[test]
4574 fn test_bugbot_derive_coupling_self_calls_excluded() {
4575 let calls = serde_json::json!({
4577 "edges": [
4578 {"src_file": "a.rs", "src_func": "f1", "dst_file": "a.rs", "dst_func": "f2", "call_type": "direct"}
4579 ]
4580 });
4581 let coupling = TldrDifferentialEngine::derive_coupling_from_calls(&calls);
4582 let metrics = coupling["martin_metrics"].as_array().unwrap();
4583 if !metrics.is_empty() {
4585 let a = metrics
4586 .iter()
4587 .find(|m| m["module"].as_str() == Some("a.rs"));
4588 if let Some(a_metric) = a {
4589 assert_eq!(a_metric["ca"].as_u64().unwrap(), 0);
4590 assert_eq!(a_metric["ce"].as_u64().unwrap(), 0);
4591 }
4592 }
4593 }
4594
4595 #[test]
4596 fn test_bugbot_derive_coupling_empty() {
4597 let calls = serde_json::json!({ "edges": [] });
4598 let coupling = TldrDifferentialEngine::derive_coupling_from_calls(&calls);
4599 let metrics = coupling["martin_metrics"].as_array().unwrap();
4600 assert!(metrics.is_empty());
4601 }
4602
4603 #[test]
4606 fn test_bugbot_derive_downstream_basic() {
4607 let calls = serde_json::json!({
4608 "edges": [
4609 {"src_file": "main.rs", "src_func": "run", "dst_file": "lib.rs", "dst_func": "process", "call_type": "direct"}
4610 ]
4611 });
4612 let results = TldrDifferentialEngine::derive_downstream_from_calls(&calls, &["lib.rs"]);
4613 assert_eq!(results.len(), 1);
4614 let (file, metrics) = &results[0];
4615 assert_eq!(file, "lib.rs");
4616 assert_eq!(metrics["importer_count"].as_u64().unwrap(), 1);
4617 assert_eq!(metrics["direct_caller_count"].as_u64().unwrap(), 1);
4618 }
4619
4620 #[test]
4621 fn test_bugbot_derive_downstream_multiple_importers() {
4622 let calls = serde_json::json!({
4623 "edges": [
4624 {"src_file": "a.rs", "src_func": "f1", "dst_file": "lib.rs", "dst_func": "process", "call_type": "direct"},
4625 {"src_file": "b.rs", "src_func": "f2", "dst_file": "lib.rs", "dst_func": "process", "call_type": "direct"},
4626 {"src_file": "c.rs", "src_func": "f3", "dst_file": "lib.rs", "dst_func": "init", "call_type": "direct"}
4627 ]
4628 });
4629 let results = TldrDifferentialEngine::derive_downstream_from_calls(&calls, &["lib.rs"]);
4630 let (_, metrics) = &results[0];
4631 assert_eq!(metrics["importer_count"].as_u64().unwrap(), 3);
4632 assert_eq!(metrics["direct_caller_count"].as_u64().unwrap(), 3);
4633 }
4634
4635 #[test]
4636 fn test_bugbot_derive_downstream_no_callers() {
4637 let calls = serde_json::json!({
4639 "edges": [
4640 {"src_file": "a.rs", "src_func": "f1", "dst_file": "b.rs", "dst_func": "g1", "call_type": "direct"}
4641 ]
4642 });
4643 let results = TldrDifferentialEngine::derive_downstream_from_calls(&calls, &["lib.rs"]);
4644 assert_eq!(results.len(), 1);
4645 let (_, metrics) = &results[0];
4646 assert_eq!(metrics["importer_count"].as_u64().unwrap(), 0);
4647 assert_eq!(metrics["direct_caller_count"].as_u64().unwrap(), 0);
4648 }
4649
4650 #[test]
4651 fn test_bugbot_derive_downstream_test_heuristic() {
4652 let calls = serde_json::json!({
4654 "edges": [
4655 {"src_file": "tests/test_lib.rs", "src_func": "test_process", "dst_file": "lib.rs", "dst_func": "process", "call_type": "direct"},
4656 {"src_file": "main.rs", "src_func": "run", "dst_file": "lib.rs", "dst_func": "process", "call_type": "direct"}
4657 ]
4658 });
4659 let results = TldrDifferentialEngine::derive_downstream_from_calls(&calls, &["lib.rs"]);
4660 let (_, metrics) = &results[0];
4661 assert!(
4662 metrics["affected_test_count"].as_u64().unwrap() >= 1,
4663 "test callers should be detected via path/name heuristic"
4664 );
4665 assert_eq!(metrics["importer_count"].as_u64().unwrap(), 2);
4666 }
4667
4668 #[test]
4669 fn test_bugbot_derive_downstream_self_calls_excluded() {
4670 let calls = serde_json::json!({
4672 "edges": [
4673 {"src_file": "lib.rs", "src_func": "helper", "dst_file": "lib.rs", "dst_func": "process", "call_type": "direct"},
4674 {"src_file": "main.rs", "src_func": "run", "dst_file": "lib.rs", "dst_func": "process", "call_type": "direct"}
4675 ]
4676 });
4677 let results = TldrDifferentialEngine::derive_downstream_from_calls(&calls, &["lib.rs"]);
4678 let (_, metrics) = &results[0];
4679 assert_eq!(
4680 metrics["importer_count"].as_u64().unwrap(),
4681 1,
4682 "self-calls should be excluded"
4683 );
4684 }
4685
4686 #[test]
4687 fn test_bugbot_derive_downstream_same_importer_multiple_calls() {
4688 let calls = serde_json::json!({
4690 "edges": [
4691 {"src_file": "main.rs", "src_func": "run", "dst_file": "lib.rs", "dst_func": "init", "call_type": "direct"},
4692 {"src_file": "main.rs", "src_func": "run", "dst_file": "lib.rs", "dst_func": "process", "call_type": "direct"},
4693 {"src_file": "main.rs", "src_func": "shutdown", "dst_file": "lib.rs", "dst_func": "cleanup", "call_type": "direct"}
4694 ]
4695 });
4696 let results = TldrDifferentialEngine::derive_downstream_from_calls(&calls, &["lib.rs"]);
4697 let (_, metrics) = &results[0];
4698 assert_eq!(
4699 metrics["importer_count"].as_u64().unwrap(),
4700 1,
4701 "3 edges from same file = 1 importer"
4702 );
4703 assert_eq!(metrics["direct_caller_count"].as_u64().unwrap(), 1);
4704 }
4705
4706 #[test]
4711 fn test_analyze_flow_commands_accepts_cached_calls_json() {
4712 let engine = TldrDifferentialEngine::new();
4715 let mut partial_reasons = Vec::new();
4716 let _findings = engine.analyze_flow_commands(
4717 Path::new("/tmp/nonexistent-project-for-cache-test"),
4718 "HEAD",
4719 "rust",
4720 None, &mut partial_reasons,
4722 );
4723 }
4725
4726 #[test]
4727 fn test_analyze_flow_commands_uses_cached_calls_for_deps() {
4728 let engine = TldrDifferentialEngine::new();
4731 let mut partial_reasons = Vec::new();
4732 let calls_json = serde_json::json!({
4733 "edges": [
4734 {"src_file": "a.rs", "src_func": "foo", "dst_file": "b.rs", "dst_func": "bar", "call_type": "direct"}
4735 ]
4736 });
4737 let _findings = engine.analyze_flow_commands(
4741 Path::new("/tmp/nonexistent-project-for-cache-test"),
4742 "HEAD",
4743 "rust",
4744 Some(&calls_json),
4745 &mut partial_reasons,
4746 );
4747 }
4748
4749 #[test]
4750 fn test_analyze_downstream_impact_accepts_cached_calls_json() {
4751 let engine = TldrDifferentialEngine::new();
4755 let mut partial_reasons = Vec::new();
4756 let calls_json = serde_json::json!({
4757 "edges": [
4758 {"src_file": "main.rs", "src_func": "run", "dst_file": "lib.rs", "dst_func": "process", "call_type": "direct"},
4759 {"src_file": "tests/test_lib.rs", "src_func": "test_it", "dst_file": "lib.rs", "dst_func": "process", "call_type": "direct"}
4760 ]
4761 });
4762
4763 let project = Path::new("/tmp/nonexistent-downstream-test");
4764 let changed_files = vec![project.join("lib.rs")];
4765 let findings = engine.analyze_downstream_impact(
4766 project,
4767 &changed_files,
4768 "rust",
4769 Some(&calls_json),
4770 &mut partial_reasons,
4771 );
4772
4773 assert!(
4775 !findings.is_empty(),
4776 "cached calls should produce downstream findings"
4777 );
4778 assert_eq!(findings[0].finding_type, "downstream-impact");
4779 }
4780
4781 #[test]
4782 fn test_analyze_downstream_impact_none_falls_back() {
4783 let engine = TldrDifferentialEngine::new();
4787 let mut partial_reasons = Vec::new();
4788 let project = Path::new("/tmp/nonexistent-downstream-fallback");
4789 let changed_files = vec![project.join("lib.rs")];
4790 let _findings = engine.analyze_downstream_impact(
4791 project,
4792 &changed_files,
4793 "rust",
4794 None,
4795 &mut partial_reasons,
4796 );
4797 }
4799
4800 #[test]
4801 fn test_analyze_function_impact_accepts_cached_calls_json() {
4802 let engine = TldrDifferentialEngine::new();
4805 let mut partial_reasons = Vec::new();
4806 let calls_json = serde_json::json!({
4807 "edges": [
4808 {"src_file": "caller.rs", "src_func": "caller_fn", "dst_file": "lib.rs", "dst_func": "target_fn", "call_type": "direct"}
4809 ]
4810 });
4811 let project = Path::new("/tmp/nonexistent-function-impact-test");
4812 let changed_files = vec![project.join("lib.rs")];
4813 let _findings = engine.analyze_function_impact(
4814 project,
4815 &changed_files,
4816 "rust",
4817 Some(&calls_json),
4818 &mut partial_reasons,
4819 );
4820 }
4822
4823 #[test]
4824 fn test_analyze_function_impact_none_falls_back() {
4825 let engine = TldrDifferentialEngine::new();
4827 let mut partial_reasons = Vec::new();
4828 let project = Path::new("/tmp/nonexistent-function-impact-fallback");
4829 let changed_files = vec![project.join("lib.rs")];
4830 let _findings = engine.analyze_function_impact(
4831 project,
4832 &changed_files,
4833 "rust",
4834 None,
4835 &mut partial_reasons,
4836 );
4837 }
4839
4840 #[test]
4841 fn test_analyze_downstream_with_cached_calls_produces_correct_findings() {
4842 let engine = TldrDifferentialEngine::new();
4845 let mut partial_reasons = Vec::new();
4846 let calls_json = serde_json::json!({
4847 "edges": [
4848 {"src_file": "a.rs", "src_func": "f1", "dst_file": "target.rs", "dst_func": "process", "call_type": "direct"},
4849 {"src_file": "b.rs", "src_func": "f2", "dst_file": "target.rs", "dst_func": "init", "call_type": "direct"},
4850 {"src_file": "c.rs", "src_func": "f3", "dst_file": "target.rs", "dst_func": "run", "call_type": "direct"},
4851 {"src_file": "d.rs", "src_func": "f4", "dst_file": "target.rs", "dst_func": "cleanup", "call_type": "direct"},
4852 ]
4853 });
4854
4855 let project = Path::new("/tmp/nonexistent-downstream-correct");
4856 let changed_files = vec![project.join("target.rs")];
4857 let findings = engine.analyze_downstream_impact(
4858 project,
4859 &changed_files,
4860 "rust",
4861 Some(&calls_json),
4862 &mut partial_reasons,
4863 );
4864
4865 assert_eq!(findings.len(), 1);
4867 assert_eq!(findings[0].severity, "medium");
4868 assert_eq!(findings[0].finding_type, "downstream-impact");
4869 assert_eq!(findings[0].evidence["importer_count"], 4);
4871 }
4872}