1use crate::facts::{EntryPointKind, FileFact, ImportResolution, ScanReport};
25use serde::{Deserialize, Serialize};
26use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
27use std::fs;
28use std::path::{Path, PathBuf};
29use std::process::Command;
30use thiserror::Error;
31
32#[derive(Debug, Clone, Default, Serialize, Deserialize)]
33#[serde(default)]
34pub struct RaysenseConfig {
35 pub scan: ScanConfig,
36 pub rules: RuleConfig,
37 pub boundaries: BoundaryConfig,
38 pub score: ScoreConfig,
39 pub grades: GradeThresholds,
40}
41
42impl RaysenseConfig {
43 pub fn from_path(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
44 let path = path.as_ref();
45 let content = fs::read_to_string(path).map_err(|source| ConfigError::Read {
46 path: path.to_path_buf(),
47 source,
48 })?;
49 toml::from_str(&content).map_err(|source| ConfigError::Parse {
50 path: path.to_path_buf(),
51 source,
52 })
53 }
54}
55
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
57#[serde(default)]
58pub struct ScanConfig {
59 pub ignored_paths: Vec<String>,
60 pub generated_paths: Vec<String>,
61 pub enabled_languages: Vec<String>,
62 pub disabled_languages: Vec<String>,
63 pub module_roots: Vec<String>,
64 pub test_roots: Vec<String>,
65 pub public_api_paths: Vec<String>,
66 pub plugins: Vec<LanguagePluginConfig>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(default)]
71pub struct GradeThresholds {
72 pub a: f64,
73 pub b: f64,
74 pub c: f64,
75 pub d: f64,
76}
77
78impl Default for GradeThresholds {
79 fn default() -> Self {
80 Self {
81 a: 0.9,
82 b: 0.8,
83 c: 0.7,
84 d: 0.5,
85 }
86 }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90#[serde(default)]
91pub struct ScoreConfig {
92 pub modularity_weight: f64,
93 pub acyclicity_weight: f64,
94 pub depth_weight: f64,
95 pub equality_weight: f64,
96 pub redundancy_weight: f64,
97 pub structural_uniformity_weight: f64,
98}
99
100impl Default for ScoreConfig {
101 fn default() -> Self {
102 Self {
103 modularity_weight: 1.0,
104 acyclicity_weight: 1.0,
105 depth_weight: 1.0,
106 equality_weight: 1.0,
107 redundancy_weight: 1.0,
108 structural_uniformity_weight: 0.0,
111 }
112 }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116#[serde(default)]
117pub struct LanguagePluginConfig {
118 pub name: String,
119 pub grammar: Option<String>,
120 pub grammar_path: Option<String>,
121 pub grammar_symbol: Option<String>,
122 pub extensions: Vec<String>,
123 pub file_names: Vec<String>,
124 pub function_prefixes: Vec<String>,
125 pub import_prefixes: Vec<String>,
126 pub call_suffixes: Vec<String>,
127 pub abstract_type_prefixes: Vec<String>,
128 pub concrete_type_prefixes: Vec<String>,
129 pub tags_query: Option<String>,
130 pub package_index_files: Vec<String>,
131 pub test_path_patterns: Vec<String>,
132 pub source_roots: Vec<String>,
133 pub ignored_paths: Vec<String>,
134 pub local_import_prefixes: Vec<String>,
135 pub max_function_complexity: Option<usize>,
136 pub max_cognitive_complexity: Option<usize>,
137 pub max_file_lines: Option<usize>,
138 pub max_function_lines: Option<usize>,
139 pub resolver_alias_files: Vec<String>,
142 pub namespace_separator: Option<String>,
145 pub module_prefix_files: Vec<String>,
148 pub module_prefix_directives: Vec<String>,
151 pub entry_point_patterns: Vec<String>,
154 pub test_module_patterns: Vec<String>,
156 pub test_attribute_patterns: Vec<String>,
159 pub parameter_node_kinds: Vec<String>,
161 pub complexity_node_kinds: Vec<String>,
164 pub logical_operator_kinds: Vec<String>,
167 pub abstract_base_classes: Vec<String>,
169}
170
171impl Default for LanguagePluginConfig {
172 fn default() -> Self {
173 Self {
174 name: String::new(),
175 grammar: None,
176 grammar_path: None,
177 grammar_symbol: None,
178 extensions: Vec::new(),
179 file_names: Vec::new(),
180 function_prefixes: vec![
181 "function ".to_string(),
182 "def ".to_string(),
183 "fn ".to_string(),
184 ],
185 import_prefixes: vec![
186 "import ".to_string(),
187 "use ".to_string(),
188 "require ".to_string(),
189 ],
190 call_suffixes: vec!["(".to_string()],
191 abstract_type_prefixes: Vec::new(),
192 concrete_type_prefixes: Vec::new(),
193 tags_query: None,
194 package_index_files: Vec::new(),
195 test_path_patterns: Vec::new(),
196 source_roots: Vec::new(),
197 ignored_paths: Vec::new(),
198 local_import_prefixes: vec![".".to_string()],
199 max_function_complexity: None,
200 max_cognitive_complexity: None,
201 max_file_lines: None,
202 max_function_lines: None,
203 resolver_alias_files: Vec::new(),
204 namespace_separator: None,
205 module_prefix_files: Vec::new(),
206 module_prefix_directives: Vec::new(),
207 entry_point_patterns: Vec::new(),
208 test_module_patterns: Vec::new(),
209 test_attribute_patterns: Vec::new(),
210 parameter_node_kinds: Vec::new(),
211 complexity_node_kinds: Vec::new(),
212 logical_operator_kinds: Vec::new(),
213 abstract_base_classes: Vec::new(),
214 }
215 }
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
219#[serde(default)]
220pub struct RuleConfig {
221 pub min_quality_signal: u32,
222 pub min_modularity: f64,
223 pub min_acyclicity: f64,
224 pub min_depth: f64,
225 pub min_equality: f64,
226 pub min_redundancy: f64,
227 pub max_cycles: usize,
228 pub max_coupling_ratio: f64,
229 pub max_function_complexity: usize,
230 pub max_cognitive_complexity: usize,
231 pub max_file_lines: usize,
232 pub max_function_lines: usize,
233 pub no_god_files: bool,
234 pub high_file_fan_in: usize,
235 pub high_file_fan_out: usize,
236 pub large_file_lines: usize,
237 pub max_large_file_findings: usize,
238 pub low_call_resolution_min_calls: usize,
239 pub low_call_resolution_ratio: f64,
240 pub high_function_fan_in: usize,
241 pub high_function_fan_out: usize,
242 pub max_call_hotspot_findings: usize,
243 pub max_upward_layer_violations: usize,
244 pub no_tests_detected: bool,
245 pub language_overrides: BTreeMap<String, LanguageRuleOverride>,
251}
252
253#[derive(Debug, Clone, Default, Serialize, Deserialize)]
254#[serde(default)]
255pub struct LanguageRuleOverride {
256 pub max_function_complexity: Option<usize>,
257 pub max_cognitive_complexity: Option<usize>,
258 pub max_file_lines: Option<usize>,
259 pub max_function_lines: Option<usize>,
260 pub high_file_fan_in: Option<usize>,
261 pub high_file_fan_out: Option<usize>,
262 pub large_file_lines: Option<usize>,
263 pub high_function_fan_in: Option<usize>,
264 pub high_function_fan_out: Option<usize>,
265}
266
267impl Default for RuleConfig {
268 fn default() -> Self {
269 Self {
270 min_quality_signal: 0,
271 min_modularity: 0.0,
272 min_acyclicity: 0.0,
273 min_depth: 0.0,
274 min_equality: 0.0,
275 min_redundancy: 0.0,
276 high_file_fan_in: 50,
277 high_file_fan_out: 15,
278 max_cycles: 0,
279 max_coupling_ratio: 1.0,
280 max_function_complexity: 15,
281 max_cognitive_complexity: 0,
282 max_file_lines: 0,
283 max_function_lines: 0,
284 no_god_files: true,
285 large_file_lines: 500,
286 max_large_file_findings: 20,
287 low_call_resolution_min_calls: 100,
288 low_call_resolution_ratio: 0.5,
289 high_function_fan_in: 200,
290 high_function_fan_out: 100,
291 max_call_hotspot_findings: 5,
292 max_upward_layer_violations: 0,
293 no_tests_detected: true,
294 language_overrides: BTreeMap::new(),
295 }
296 }
297}
298
299#[derive(Debug, Clone, Default, Serialize, Deserialize)]
300#[serde(default)]
301pub struct BoundaryConfig {
302 pub forbidden_edges: Vec<ForbiddenEdgeConfig>,
303 pub layers: Vec<LayerConfig>,
304}
305
306#[derive(Debug, Clone, Default, Serialize, Deserialize)]
307#[serde(default)]
308pub struct ForbiddenEdgeConfig {
309 pub from: String,
310 pub to: String,
311 pub reason: String,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct LayerConfig {
316 pub name: String,
317 pub path: String,
318 pub order: i32,
319}
320
321#[derive(Debug, Error)]
322pub enum ConfigError {
323 #[error("failed to read config {path}: {source}")]
324 Read {
325 path: PathBuf,
326 #[source]
327 source: std::io::Error,
328 },
329 #[error("failed to parse config {path}: {source}")]
330 Parse {
331 path: PathBuf,
332 #[source]
333 source: toml::de::Error,
334 },
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize)]
338pub struct HealthSummary {
339 pub score: u8,
340 pub quality_signal: u32,
341 pub coverage_score: u8,
342 pub structural_score: u8,
343 pub root_causes: RootCauseScores,
344 #[serde(default)]
345 pub grades: GradeSummary,
346 pub metrics: MetricsSummary,
347 pub resolution: ResolutionBreakdown,
348 pub hotspots: Vec<FileHotspot>,
349 pub rules: Vec<RuleFinding>,
350 pub remediations: Vec<Remediation>,
351}
352
353#[derive(Debug, Clone, Default, Serialize, Deserialize)]
354pub struct GradeSummary {
355 pub overall: String,
356 pub modularity: String,
357 pub acyclicity: String,
358 pub depth: String,
359 pub equality: String,
360 pub redundancy: String,
361 pub structural_uniformity: String,
362}
363
364#[derive(Debug, Clone, Default, Serialize, Deserialize)]
365pub struct MetricsSummary {
366 pub coupling: CouplingMetrics,
367 pub calls: CallMetrics,
368 pub architecture: ArchitectureMetrics,
369 pub complexity: ComplexityMetrics,
370 pub size: SizeMetrics,
371 pub entry_points: EntryPointMetrics,
372 pub test_gap: TestGapMetrics,
373 pub dsm: DsmMetrics,
374 pub evolution: EvolutionMetrics,
375 pub trend: TrendMetrics,
376}
377
378#[derive(Debug, Clone, Default, Serialize, Deserialize)]
379pub struct RootCauseScores {
380 pub modularity: f64,
381 pub acyclicity: f64,
382 pub depth: f64,
383 pub equality: f64,
384 pub redundancy: f64,
385 pub structural_uniformity: f64,
386}
387
388#[derive(Debug, Clone, Default, Serialize, Deserialize)]
389pub struct ArchitectureMetrics {
390 pub module_depth: usize,
391 pub max_blast_radius: usize,
392 pub max_blast_radius_file: String,
393 pub max_non_foundation_blast_radius: usize,
394 pub max_non_foundation_blast_radius_file: String,
395 pub attack_surface_files: usize,
396 pub attack_surface_ratio: f64,
397 pub total_graph_files: usize,
398 pub average_distance_from_main_sequence: f64,
399 pub levels: BTreeMap<String, usize>,
400 pub upward_violations: Vec<DependencyViolationMetric>,
401 pub upward_violation_ratio: f64,
402 pub unstable_modules: Vec<ModuleStabilityMetric>,
403 pub stable_foundations: Vec<ModuleStabilityMetric>,
404 pub distance_metrics: Vec<ModuleDistanceMetric>,
405 pub cycles: Vec<Vec<String>>,
406}
407
408#[derive(Debug, Clone, Default, Serialize, Deserialize)]
409pub struct ModuleStabilityMetric {
410 pub module: String,
411 pub fan_in: usize,
412 pub fan_out: usize,
413 pub instability: f64,
414}
415
416#[derive(Debug, Clone, Default, Serialize, Deserialize)]
417pub struct ModuleDistanceMetric {
418 pub module: String,
419 pub abstractness: f64,
420 pub instability: f64,
421 pub distance: f64,
422 pub abstract_count: usize,
423 pub total_types: usize,
424 pub fan_in: usize,
425 pub fan_out: usize,
426 pub is_foundation: bool,
427}
428
429#[derive(Debug, Clone, Default, Serialize, Deserialize)]
430pub struct DependencyViolationMetric {
431 pub from_file_id: usize,
432 pub from_path: String,
433 pub from_level: usize,
434 pub to_file_id: usize,
435 pub to_path: String,
436 pub to_level: usize,
437 pub reason: String,
438}
439
440#[derive(Debug, Clone, Default, Serialize, Deserialize)]
441pub struct ComplexityMetrics {
442 pub max_function_complexity: usize,
443 pub max_cognitive_complexity: usize,
444 pub average_function_complexity: f64,
445 pub average_cognitive_complexity: f64,
446 pub complexity_gini: f64,
447 pub complexity_entropy: f64,
448 pub complexity_entropy_bits: f64,
449 pub all_functions: Vec<FunctionComplexityMetric>,
450 pub complex_functions: Vec<FunctionComplexityMetric>,
451 pub dead_functions: Vec<FunctionComplexityMetric>,
452 pub duplicate_groups: Vec<DuplicateFunctionGroup>,
453 pub semantic_duplicate_groups: Vec<DuplicateFunctionGroup>,
454 pub redundancy_ratio: f64,
455 pub public_api_functions: usize,
456}
457
458#[derive(Debug, Clone, Serialize, Deserialize)]
459pub struct FunctionComplexityMetric {
460 pub function_id: usize,
461 pub file_id: usize,
462 pub path: String,
463 pub name: String,
464 pub value: usize,
465 pub cognitive_value: usize,
466}
467
468#[derive(Debug, Clone, Serialize, Deserialize)]
469pub struct DuplicateFunctionGroup {
470 pub fingerprint: String,
471 pub name: String,
472 pub functions: Vec<FunctionComplexityMetric>,
473}
474
475#[derive(Debug, Clone, Default, Serialize, Deserialize)]
476pub struct CouplingMetrics {
477 pub local_edges: usize,
478 pub cross_module_edges: usize,
479 pub cross_unstable_edges: usize,
480 pub cross_module_ratio: f64,
481 pub cross_unstable_ratio: f64,
482 pub entropy: f64,
483 pub entropy_bits: f64,
484 pub entropy_pairs: usize,
485 pub average_module_cohesion: Option<f64>,
486 pub cohesive_module_count: usize,
487 pub god_files: Vec<FileCouplingMetric>,
488 pub unstable_hotspots: Vec<FileCouplingMetric>,
489 pub most_unstable_files: Vec<FileInstabilityMetric>,
490 pub max_fan_in: usize,
491 pub max_fan_out: usize,
492}
493
494#[derive(Debug, Clone, Default, Serialize, Deserialize)]
495pub struct FileCouplingMetric {
496 pub file_id: usize,
497 pub path: String,
498 pub fan_in: usize,
499 pub fan_out: usize,
500}
501
502#[derive(Debug, Clone, Default, Serialize, Deserialize)]
503pub struct FileInstabilityMetric {
504 pub file_id: usize,
505 pub path: String,
506 pub fan_in: usize,
507 pub fan_out: usize,
508 pub instability: f64,
509}
510
511#[derive(Debug, Clone, Default, Serialize, Deserialize)]
512pub struct CallMetrics {
513 pub total_calls: usize,
514 pub resolved_edges: usize,
515 pub resolution_ratio: f64,
516 pub max_function_fan_in: usize,
517 pub max_function_fan_out: usize,
518 pub top_called_functions: Vec<FunctionCallMetric>,
519 pub top_calling_functions: Vec<FunctionCallMetric>,
520}
521
522#[derive(Debug, Clone, Serialize, Deserialize)]
523pub struct FunctionCallMetric {
524 pub function_id: usize,
525 pub file_id: usize,
526 pub path: String,
527 pub name: String,
528 pub calls: usize,
529}
530
531#[derive(Debug, Clone, Default, Serialize, Deserialize)]
532pub struct SizeMetrics {
533 pub max_file_lines: usize,
534 pub max_function_lines: usize,
535 pub large_files: usize,
536 pub long_functions: usize,
537 pub file_size_entropy: f64,
538 pub file_size_entropy_bits: f64,
539 #[serde(default)]
540 pub total_lines: usize,
541 #[serde(default)]
542 pub total_comment_lines: usize,
543 #[serde(default)]
544 pub comment_ratio: f64,
545}
546
547#[derive(Debug, Clone, Default, Serialize, Deserialize)]
548pub struct EntryPointMetrics {
549 pub binaries: usize,
550 pub examples: usize,
551 pub tests: usize,
552}
553
554#[derive(Debug, Clone, Default, Serialize, Deserialize)]
555pub struct TestGapMetrics {
556 pub production_files: usize,
557 pub test_files: usize,
558 pub files_without_nearby_tests: usize,
559 pub candidates: Vec<TestGapCandidate>,
560}
561
562#[derive(Debug, Clone, Serialize, Deserialize)]
563pub struct TestGapCandidate {
564 pub file_id: usize,
565 pub path: String,
566 pub framework: String,
567 pub expected_tests: Vec<String>,
568 pub matched_tests: Vec<String>,
569}
570
571#[derive(Debug, Clone, Default, Serialize, Deserialize)]
572pub struct TrendMetrics {
573 pub available: bool,
574 pub samples: usize,
575 pub score_delta: i16,
576 pub quality_signal_delta: i32,
577 pub rule_delta: isize,
578 #[serde(default)]
584 pub dimension_deltas: BTreeMap<String, f64>,
585 #[serde(default)]
590 pub series: Vec<TrendSample>,
591}
592
593#[derive(Debug, Clone, Default, Serialize, Deserialize)]
594pub struct TrendSample {
595 #[serde(default)]
596 pub timestamp: i64,
597 pub score: u8,
598 pub quality_signal: u32,
599 pub rules: usize,
600 #[serde(default)]
601 pub snapshot_id: String,
602 #[serde(default)]
603 pub root_causes: RootCauseScores,
604 #[serde(default)]
605 pub overall_grade: String,
606}
607
608#[derive(Debug, Clone, Serialize, Deserialize)]
609pub struct Remediation {
610 pub code: String,
611 pub path: String,
612 pub action: String,
613 pub command: String,
614}
615
616#[derive(Debug, Clone, Default, Serialize, Deserialize)]
617pub struct DsmMetrics {
618 pub module_count: usize,
619 pub module_edges: usize,
620 pub top_module_edges: Vec<ModuleEdgeMetric>,
621}
622
623#[derive(Debug, Clone, Serialize, Deserialize)]
624pub struct ModuleEdgeMetric {
625 pub from_module: String,
626 pub to_module: String,
627 pub edges: usize,
628}
629
630#[derive(Debug, Clone, Default, Serialize, Deserialize)]
631pub struct EvolutionMetrics {
632 pub available: bool,
633 pub reason: String,
634 pub commits_sampled: usize,
635 pub changed_files: usize,
636 pub top_changed_files: Vec<EvolutionFileMetric>,
637 #[serde(default)]
638 pub author_count: usize,
639 #[serde(default)]
640 pub top_authors: Vec<EvolutionAuthorMetric>,
641 #[serde(default)]
642 pub file_ownership: Vec<EvolutionFileOwnership>,
643 #[serde(default)]
644 pub temporal_hotspots: Vec<EvolutionTemporalHotspot>,
645 #[serde(default)]
646 pub file_ages: Vec<EvolutionFileAge>,
647 #[serde(default)]
648 pub change_coupling: Vec<EvolutionChangeCoupling>,
649 #[serde(default)]
652 pub bug_fix_commits: usize,
653 #[serde(default)]
655 pub bug_prone_files: Vec<EvolutionBugProneFile>,
656 #[serde(default)]
660 pub edit_risk_files: Vec<EvolutionEditRiskFile>,
661}
662
663#[derive(Debug, Clone, Serialize, Deserialize)]
664pub struct EvolutionFileMetric {
665 pub path: String,
666 pub commits: usize,
667}
668
669#[derive(Debug, Clone, Serialize, Deserialize)]
670pub struct EvolutionAuthorMetric {
671 pub author: String,
672 pub commits: usize,
673}
674
675#[derive(Debug, Clone, Serialize, Deserialize)]
678pub struct EvolutionTemporalHotspot {
679 pub path: String,
680 pub commits: usize,
681 pub max_complexity: usize,
682 pub risk_score: usize,
683}
684
685#[derive(Debug, Clone, Serialize, Deserialize)]
689pub struct EvolutionFileAge {
690 pub path: String,
691 pub first_commit_unix: i64,
692 pub last_commit_unix: i64,
693 pub age_days: u64,
694 pub last_changed_days: u64,
695}
696
697#[derive(Debug, Clone, Serialize, Deserialize)]
700pub struct EvolutionChangeCoupling {
701 pub left: String,
702 pub right: String,
703 pub co_commits: usize,
704 pub coupling_strength: f64,
705}
706
707#[derive(Debug, Clone, Serialize, Deserialize)]
711pub struct EvolutionBugProneFile {
712 pub path: String,
713 pub bug_fix_commits: usize,
714 pub total_commits: usize,
715 pub bug_fix_ratio: f64,
716}
717
718#[derive(Debug, Clone, Serialize, Deserialize)]
730pub struct EvolutionEditRiskFile {
731 pub path: String,
732 pub commits: usize,
733 pub max_complexity: usize,
734 pub bus_factor: usize,
735 pub has_nearby_tests: bool,
736 pub risk_score: f64,
737}
738
739#[derive(Debug, Clone, Serialize, Deserialize)]
740pub struct EvolutionFileOwnership {
741 pub path: String,
742 pub top_author: String,
743 pub top_author_commits: usize,
744 pub total_commits: usize,
745 pub author_count: usize,
746 pub bus_factor: usize,
749}
750
751#[derive(Debug, Clone, Default, Serialize, Deserialize)]
752pub struct ResolutionBreakdown {
753 pub local: usize,
754 pub external: usize,
755 pub system: usize,
756 pub unresolved: usize,
757}
758
759#[derive(Debug, Clone, Serialize, Deserialize)]
760pub struct FileHotspot {
761 pub file_id: usize,
762 pub path: String,
763 pub module: String,
764 pub fan_in: usize,
765 pub fan_out: usize,
766}
767
768#[derive(Debug, Clone, Serialize, Deserialize)]
769pub struct RuleFinding {
770 pub severity: RuleSeverity,
771 pub code: String,
772 pub path: String,
773 pub message: String,
774}
775
776#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
777pub enum RuleSeverity {
778 Info,
779 Warning,
780 Error,
781}
782
783pub fn compute_health(report: &ScanReport) -> HealthSummary {
784 compute_health_with_config(report, &RaysenseConfig::default())
785}
786
787pub fn compute_health_with_config(report: &ScanReport, config: &RaysenseConfig) -> HealthSummary {
788 let resolution = resolution_breakdown(report);
789 let hotspots = hotspots(report);
790 let metrics = metrics(report, &hotspots, config);
791 let rules = rules(report, &hotspots, &metrics, config);
792 let remediations = remediations(&rules, &metrics);
793 let root_causes = root_causes(report, &metrics);
794 let quality_signal = quality_signal(&root_causes, &config.score);
795 let score = ((quality_signal as f64 / 10000.0) * 100.0).round() as u8;
796 let grades = compute_grades(score, &root_causes, &config.grades);
797
798 HealthSummary {
799 score,
800 quality_signal,
801 coverage_score: coverage_score(report, &resolution),
802 structural_score: structural_score(report, &rules),
803 root_causes,
804 grades,
805 metrics,
806 resolution,
807 hotspots,
808 rules,
809 remediations,
810 }
811}
812
813fn compute_grades(
814 score: u8,
815 root_causes: &RootCauseScores,
816 thresholds: &GradeThresholds,
817) -> GradeSummary {
818 let overall = grade_for(score as f64 / 100.0, thresholds).to_string();
819 GradeSummary {
820 overall,
821 modularity: grade_for(root_causes.modularity, thresholds).to_string(),
822 acyclicity: grade_for(root_causes.acyclicity, thresholds).to_string(),
823 depth: grade_for(root_causes.depth, thresholds).to_string(),
824 equality: grade_for(root_causes.equality, thresholds).to_string(),
825 redundancy: grade_for(root_causes.redundancy, thresholds).to_string(),
826 structural_uniformity: grade_for(root_causes.structural_uniformity, thresholds).to_string(),
827 }
828}
829
830fn grade_for(value: f64, thresholds: &GradeThresholds) -> &'static str {
831 if value >= thresholds.a {
832 "A"
833 } else if value >= thresholds.b {
834 "B"
835 } else if value >= thresholds.c {
836 "C"
837 } else if value >= thresholds.d {
838 "D"
839 } else {
840 "F"
841 }
842}
843
844fn resolution_breakdown(report: &ScanReport) -> ResolutionBreakdown {
845 let mut breakdown = ResolutionBreakdown::default();
846 for import in &report.imports {
847 match import.resolution {
848 ImportResolution::External => breakdown.external += 1,
849 ImportResolution::Local => breakdown.local += 1,
850 ImportResolution::System => breakdown.system += 1,
851 ImportResolution::Unresolved => breakdown.unresolved += 1,
852 }
853 }
854 breakdown
855}
856
857fn coverage_score(report: &ScanReport, resolution: &ResolutionBreakdown) -> u8 {
858 let mut score = 100i32;
859 if report.snapshot.import_count > 0 {
860 let unresolved_pct = (resolution.unresolved as f64 / report.snapshot.import_count as f64
861 * 100.0)
862 .round() as i32;
863 score -= unresolved_pct.min(70);
864 }
865 score.clamp(0, 100) as u8
866}
867
868fn structural_score(report: &ScanReport, rules: &[RuleFinding]) -> u8 {
869 let mut score = 100i32;
870 score -= (report.graph.cycle_count as i32 * 20).min(80);
871 score -= rule_penalty(rules);
872 score.clamp(0, 100) as u8
873}
874
875fn rule_penalty(rules: &[RuleFinding]) -> i32 {
876 rules
877 .iter()
878 .map(|rule| match rule.severity {
879 RuleSeverity::Info => 0,
880 RuleSeverity::Warning => 4,
881 RuleSeverity::Error => 10,
882 })
883 .sum::<i32>()
884 .min(40)
885}
886
887fn hotspots(report: &ScanReport) -> Vec<FileHotspot> {
888 let mut fan_in: HashMap<usize, usize> = HashMap::new();
889 let mut fan_out: HashMap<usize, usize> = HashMap::new();
890
891 for import in &report.imports {
892 if let Some(to_file) = import.resolved_file {
893 if to_file == import.from_file {
894 continue;
895 }
896 *fan_in.entry(to_file).or_default() += 1;
897 *fan_out.entry(import.from_file).or_default() += 1;
898 }
899 }
900
901 let mut hotspots: Vec<FileHotspot> = report
902 .files
903 .iter()
904 .map(|file| FileHotspot {
905 file_id: file.file_id,
906 path: file.path.to_string_lossy().into_owned(),
907 module: file.module.clone(),
908 fan_in: fan_in.get(&file.file_id).copied().unwrap_or(0),
909 fan_out: fan_out.get(&file.file_id).copied().unwrap_or(0),
910 })
911 .filter(|hotspot| hotspot.fan_in > 0 || hotspot.fan_out > 0)
912 .collect();
913
914 hotspots.sort_by(|a, b| {
915 let a_total = a.fan_in + a.fan_out;
916 let b_total = b.fan_in + b.fan_out;
917 b_total
918 .cmp(&a_total)
919 .then_with(|| b.fan_in.cmp(&a.fan_in))
920 .then_with(|| a.path.cmp(&b.path))
921 });
922 hotspots.truncate(10);
923 hotspots
924}
925
926fn metrics(
927 report: &ScanReport,
928 hotspots: &[FileHotspot],
929 config: &RaysenseConfig,
930) -> MetricsSummary {
931 let complexity = complexity_metrics(report, config);
932 let test_gap = test_gap_metrics(report, config);
933 let evolution = evolution_metrics(report, &complexity, &test_gap);
934 MetricsSummary {
935 coupling: coupling_metrics(report, hotspots, config),
936 calls: call_metrics(report),
937 architecture: architecture_metrics(report, config),
938 complexity,
939 size: size_metrics(report),
940 entry_points: entry_point_metrics(report),
941 test_gap,
942 dsm: dsm_metrics(report, config),
943 evolution,
944 trend: trend_metrics(report),
945 }
946}
947
948fn coupling_metrics(
949 report: &ScanReport,
950 hotspots: &[FileHotspot],
951 config: &RaysenseConfig,
952) -> CouplingMetrics {
953 let local_edges = report
954 .imports
955 .iter()
956 .filter(|import| import.resolution == ImportResolution::Local)
957 .count();
958 let cross_module_edges = report
959 .imports
960 .iter()
961 .filter(|import| {
962 let Some(to_file_id) = import.resolved_file else {
963 return false;
964 };
965 if to_file_id == import.from_file {
966 return false;
967 }
968 let Some(from_file) = report.files.get(import.from_file) else {
969 return false;
970 };
971 let Some(to_file) = report.files.get(to_file_id) else {
972 return false;
973 };
974 module_group(from_file, config) != module_group(to_file, config)
975 })
976 .count();
977 let stable_foundations = stable_foundation_modules(report, config);
978 let (entropy, entropy_bits, entropy_pairs) =
979 coupling_entropy(report, config, &stable_foundations);
980 let (average_module_cohesion, cohesive_module_count) = module_cohesion(report, config);
981 let (god_files, unstable_hotspots, most_unstable_files) = file_coupling_metrics(report, config);
982 let cross_unstable_edges = report
983 .imports
984 .iter()
985 .filter(|import| {
986 let Some(to_file_id) = import.resolved_file else {
987 return false;
988 };
989 if to_file_id == import.from_file {
990 return false;
991 }
992 let Some(from_file) = report.files.get(import.from_file) else {
993 return false;
994 };
995 let Some(to_file) = report.files.get(to_file_id) else {
996 return false;
997 };
998 let from = module_group(from_file, config);
999 let to = module_group(to_file, config);
1000 from != to && !stable_foundations.contains(&to)
1001 })
1002 .count();
1003
1004 CouplingMetrics {
1005 local_edges,
1006 cross_module_edges,
1007 cross_unstable_edges,
1008 cross_module_ratio: ratio(cross_module_edges, local_edges),
1009 cross_unstable_ratio: ratio(cross_unstable_edges, local_edges),
1010 entropy,
1011 entropy_bits,
1012 entropy_pairs,
1013 average_module_cohesion,
1014 cohesive_module_count,
1015 god_files,
1016 unstable_hotspots,
1017 most_unstable_files,
1018 max_fan_in: hotspots
1019 .iter()
1020 .map(|hotspot| hotspot.fan_in)
1021 .max()
1022 .unwrap_or(0),
1023 max_fan_out: hotspots
1024 .iter()
1025 .map(|hotspot| hotspot.fan_out)
1026 .max()
1027 .unwrap_or(0),
1028 }
1029}
1030
1031fn coupling_entropy(
1032 report: &ScanReport,
1033 config: &RaysenseConfig,
1034 stable_foundations: &HashSet<String>,
1035) -> (f64, f64, usize) {
1036 let mut pair_counts: BTreeMap<(String, String), usize> = BTreeMap::new();
1037 let mut cross_count = 0usize;
1038
1039 for import in &report.imports {
1040 if import.resolution != ImportResolution::Local {
1041 continue;
1042 }
1043 let Some(to_file_id) = import.resolved_file else {
1044 continue;
1045 };
1046 if to_file_id == import.from_file {
1047 continue;
1048 }
1049 let Some(from_file) = report.files.get(import.from_file) else {
1050 continue;
1051 };
1052 let Some(to_file) = report.files.get(to_file_id) else {
1053 continue;
1054 };
1055 let from = module_group(from_file, config);
1056 let to = module_group(to_file, config);
1057 if from == to || stable_foundations.contains(&to) {
1058 continue;
1059 }
1060 *pair_counts.entry((from, to)).or_default() += 1;
1061 cross_count += 1;
1062 }
1063
1064 let entropy_pairs = pair_counts.len();
1065 if cross_count == 0 || entropy_pairs <= 1 {
1066 return (0.0, 0.0, entropy_pairs);
1067 }
1068
1069 let total = cross_count as f64;
1070 let entropy_bits = pair_counts
1071 .values()
1072 .map(|count| {
1073 let p = *count as f64 / total;
1074 -p * p.log2()
1075 })
1076 .sum::<f64>();
1077 let max_entropy = (entropy_pairs as f64).log2();
1078 let entropy = if max_entropy > 0.0 {
1079 entropy_bits / max_entropy
1080 } else {
1081 0.0
1082 };
1083
1084 (round3(entropy), round3(entropy_bits), entropy_pairs)
1085}
1086
1087fn module_cohesion(report: &ScanReport, config: &RaysenseConfig) -> (Option<f64>, usize) {
1088 let mut files_by_module: HashMap<String, Vec<usize>> = HashMap::new();
1089 for file in &report.files {
1090 let path = normalize_rule_path(&file.path);
1091 if is_test_path_configured(&path, config) {
1092 continue;
1093 }
1094 files_by_module
1095 .entry(module_group(file, config))
1096 .or_default()
1097 .push(file.file_id);
1098 }
1099
1100 let mut module_edges: HashMap<String, HashSet<(usize, usize)>> = HashMap::new();
1101 for import in &report.imports {
1102 let Some(to_file_id) = import.resolved_file else {
1103 continue;
1104 };
1105 if to_file_id == import.from_file || import.resolution != ImportResolution::Local {
1106 continue;
1107 }
1108 let Some(from_file) = report.files.get(import.from_file) else {
1109 continue;
1110 };
1111 let Some(to_file) = report.files.get(to_file_id) else {
1112 continue;
1113 };
1114 let module = module_group(from_file, config);
1115 if module == module_group(to_file, config) {
1116 module_edges
1117 .entry(module)
1118 .or_default()
1119 .insert((import.from_file, to_file_id));
1120 }
1121 }
1122
1123 for edge in &report.call_edges {
1124 let Some(caller) = report.functions.get(edge.caller_function) else {
1125 continue;
1126 };
1127 let Some(callee) = report.functions.get(edge.callee_function) else {
1128 continue;
1129 };
1130 if caller.file_id == callee.file_id {
1131 continue;
1132 }
1133 let Some(from_file) = report.files.get(caller.file_id) else {
1134 continue;
1135 };
1136 let Some(to_file) = report.files.get(callee.file_id) else {
1137 continue;
1138 };
1139 let module = module_group(from_file, config);
1140 if module == module_group(to_file, config) {
1141 module_edges
1142 .entry(module)
1143 .or_default()
1144 .insert((caller.file_id, callee.file_id));
1145 }
1146 }
1147
1148 let mut total = 0.0;
1149 let mut count = 0usize;
1150 for (module, files) in files_by_module {
1151 if files.len() < 2 {
1152 continue;
1153 }
1154 let expected = files.len() - 1;
1155 let actual = module_edges.get(&module).map(HashSet::len).unwrap_or(0);
1156 total += (actual as f64 / expected as f64).min(1.0);
1157 count += 1;
1158 }
1159
1160 if count == 0 {
1161 (None, 0)
1162 } else {
1163 (Some(round3(total / count as f64)), count)
1164 }
1165}
1166
1167fn file_coupling_metrics(
1168 report: &ScanReport,
1169 config: &RaysenseConfig,
1170) -> (
1171 Vec<FileCouplingMetric>,
1172 Vec<FileCouplingMetric>,
1173 Vec<FileInstabilityMetric>,
1174) {
1175 let mut fan_in: HashMap<usize, usize> = HashMap::new();
1176 let mut fan_out: HashMap<usize, usize> = HashMap::new();
1177 for import in &report.imports {
1178 let Some(to_file_id) = import.resolved_file else {
1179 continue;
1180 };
1181 if to_file_id == import.from_file || import.resolution != ImportResolution::Local {
1182 continue;
1183 }
1184 *fan_out.entry(import.from_file).or_default() += 1;
1185 *fan_in.entry(to_file_id).or_default() += 1;
1186 }
1187
1188 let mut coupling = Vec::new();
1189 let mut instability = Vec::new();
1190 for file in &report.files {
1191 let path = normalize_rule_path(&file.path);
1192 if is_test_path_configured(&path, config) {
1193 continue;
1194 }
1195 let incoming = fan_in.get(&file.file_id).copied().unwrap_or(0);
1196 let outgoing = fan_out.get(&file.file_id).copied().unwrap_or(0);
1197 if incoming > 0 || outgoing > 0 {
1198 coupling.push(FileCouplingMetric {
1199 file_id: file.file_id,
1200 path: path.clone(),
1201 fan_in: incoming,
1202 fan_out: outgoing,
1203 });
1204 instability.push(FileInstabilityMetric {
1205 file_id: file.file_id,
1206 path,
1207 fan_in: incoming,
1208 fan_out: outgoing,
1209 instability: if incoming + outgoing == 0 {
1210 0.5
1211 } else {
1212 ratio(outgoing, incoming + outgoing)
1213 },
1214 });
1215 }
1216 }
1217
1218 let fan_out_limit = |metric: &FileCouplingMetric| -> usize {
1219 report
1220 .files
1221 .get(metric.file_id)
1222 .map(|file| high_file_fan_out_limit(file, config))
1223 .unwrap_or(config.rules.high_file_fan_out)
1224 };
1225 let fan_in_limit = |metric: &FileCouplingMetric| -> usize {
1226 report
1227 .files
1228 .get(metric.file_id)
1229 .map(|file| high_file_fan_in_limit(file, config))
1230 .unwrap_or(config.rules.high_file_fan_in)
1231 };
1232 let mut god_files: Vec<FileCouplingMetric> = coupling
1233 .iter()
1234 .filter(|metric| {
1235 metric.fan_out >= fan_out_limit(metric) && !is_package_index_path(&metric.path)
1236 })
1237 .cloned()
1238 .collect();
1239 god_files.sort_by(|a, b| b.fan_out.cmp(&a.fan_out).then_with(|| a.path.cmp(&b.path)));
1240 god_files.truncate(10);
1241
1242 let mut unstable_hotspots: Vec<FileCouplingMetric> = coupling
1243 .iter()
1244 .filter(|metric| {
1245 metric.fan_in >= fan_in_limit(metric)
1246 && !is_package_index_path(&metric.path)
1247 && ratio(metric.fan_out, metric.fan_in + metric.fan_out) >= 0.15
1248 })
1249 .cloned()
1250 .collect();
1251 unstable_hotspots.sort_by(|a, b| {
1252 b.fan_in
1253 .cmp(&a.fan_in)
1254 .then_with(|| b.fan_out.cmp(&a.fan_out))
1255 .then_with(|| a.path.cmp(&b.path))
1256 });
1257 unstable_hotspots.truncate(10);
1258
1259 instability.sort_by(|a, b| {
1260 b.instability
1261 .partial_cmp(&a.instability)
1262 .unwrap_or(std::cmp::Ordering::Equal)
1263 .then_with(|| b.fan_out.cmp(&a.fan_out))
1264 .then_with(|| a.path.cmp(&b.path))
1265 });
1266 instability.truncate(10);
1267
1268 (god_files, unstable_hotspots, instability)
1269}
1270
1271fn call_metrics(report: &ScanReport) -> CallMetrics {
1272 let mut fan_in: HashMap<usize, usize> = HashMap::new();
1273 let mut fan_out: HashMap<usize, usize> = HashMap::new();
1274
1275 for edge in &report.call_edges {
1276 *fan_in.entry(edge.callee_function).or_default() += 1;
1277 *fan_out.entry(edge.caller_function).or_default() += 1;
1278 }
1279
1280 CallMetrics {
1281 total_calls: report.calls.len(),
1282 resolved_edges: report.call_edges.len(),
1283 resolution_ratio: ratio(report.call_edges.len(), report.calls.len()),
1284 max_function_fan_in: fan_in.values().copied().max().unwrap_or(0),
1285 max_function_fan_out: fan_out.values().copied().max().unwrap_or(0),
1286 top_called_functions: function_call_metrics(report, &fan_in),
1287 top_calling_functions: function_call_metrics(report, &fan_out),
1288 }
1289}
1290
1291fn function_call_metrics(
1292 report: &ScanReport,
1293 counts: &HashMap<usize, usize>,
1294) -> Vec<FunctionCallMetric> {
1295 let mut metrics: Vec<FunctionCallMetric> = counts
1296 .iter()
1297 .filter_map(|(function_id, calls)| {
1298 let function = report.functions.get(*function_id)?;
1299 let file = report.files.get(function.file_id)?;
1300 Some(FunctionCallMetric {
1301 function_id: *function_id,
1302 file_id: function.file_id,
1303 path: file.path.to_string_lossy().into_owned(),
1304 name: function.name.clone(),
1305 calls: *calls,
1306 })
1307 })
1308 .collect();
1309
1310 metrics.sort_by(|a, b| {
1311 b.calls
1312 .cmp(&a.calls)
1313 .then_with(|| a.path.cmp(&b.path))
1314 .then_with(|| a.name.cmp(&b.name))
1315 });
1316 metrics.truncate(10);
1317 metrics
1318}
1319
1320fn architecture_metrics(report: &ScanReport, config: &RaysenseConfig) -> ArchitectureMetrics {
1321 let adjacency = file_adjacency(report);
1322 let reverse = reverse_adjacency(&adjacency);
1323 let foundation_files = foundation_file_ids(report, config);
1324 let distance_metrics = module_distance_metrics(report, config);
1325 let (attack_surface_files, total_graph_files) = attack_surface_metrics(report, &adjacency);
1326 let levels = dependency_levels(report, &adjacency, &reverse);
1327 let upward_violations = upward_violations(report, &adjacency, &levels);
1328 let non_foundation_distance: Vec<f64> = distance_metrics
1329 .iter()
1330 .filter(|metric| !metric.is_foundation)
1331 .map(|metric| metric.distance)
1332 .collect();
1333 let mut max_blast_radius = 0usize;
1334 let mut max_blast_radius_file = String::new();
1335 let mut max_non_foundation_blast_radius = 0usize;
1336 let mut max_non_foundation_blast_radius_file = String::new();
1337 for file in &report.files {
1338 let radius = reachable_count(file.file_id, &reverse);
1339 if radius > max_blast_radius {
1340 max_blast_radius = radius;
1341 max_blast_radius_file = file.path.to_string_lossy().into_owned();
1342 }
1343 if !foundation_files.contains(&file.file_id) && radius > max_non_foundation_blast_radius {
1344 max_non_foundation_blast_radius = radius;
1345 max_non_foundation_blast_radius_file = file.path.to_string_lossy().into_owned();
1346 }
1347 }
1348
1349 ArchitectureMetrics {
1350 module_depth: report
1351 .files
1352 .iter()
1353 .map(|file| file.module.split('.').count())
1354 .max()
1355 .unwrap_or(0),
1356 max_blast_radius,
1357 max_blast_radius_file,
1358 max_non_foundation_blast_radius,
1359 max_non_foundation_blast_radius_file,
1360 attack_surface_files,
1361 attack_surface_ratio: ratio(attack_surface_files, total_graph_files),
1362 total_graph_files,
1363 average_distance_from_main_sequence: if non_foundation_distance.is_empty() {
1364 0.0
1365 } else {
1366 round3(
1367 non_foundation_distance.iter().sum::<f64>() / non_foundation_distance.len() as f64,
1368 )
1369 },
1370 levels,
1371 upward_violation_ratio: ratio(upward_violations.len(), report.imports.len()),
1372 upward_violations,
1373 unstable_modules: module_stability(report, config),
1374 stable_foundations: stable_foundation_metrics(report, config),
1375 distance_metrics,
1376 cycles: cycle_components(report, &adjacency),
1377 }
1378}
1379
1380fn complexity_metrics(report: &ScanReport, config: &RaysenseConfig) -> ComplexityMetrics {
1381 let mut incoming_by_function: HashMap<usize, usize> = HashMap::new();
1382 let sources = source_cache(report);
1383 for edge in &report.call_edges {
1384 *incoming_by_function
1385 .entry(edge.callee_function)
1386 .or_default() += 1;
1387 }
1388
1389 let mut values = Vec::new();
1390 let mut cognitive_values = Vec::new();
1391 let mut all_functions = Vec::new();
1392 let mut complex_functions = Vec::new();
1393 let mut dead_functions = Vec::new();
1394 let mut by_name: BTreeMap<String, Vec<FunctionComplexityMetric>> = BTreeMap::new();
1395 let mut by_fingerprint: BTreeMap<String, Vec<FunctionComplexityMetric>> = BTreeMap::new();
1396 let mut by_semantic_shape: BTreeMap<String, Vec<FunctionComplexityMetric>> = BTreeMap::new();
1397 let mut public_api_functions = 0usize;
1398
1399 for function in &report.functions {
1400 let Some(file) = report.files.get(function.file_id) else {
1401 continue;
1402 };
1403 let path = file.path.to_string_lossy().into_owned();
1404 let source = sources
1405 .get(&function.file_id)
1406 .map(String::as_str)
1407 .unwrap_or("");
1408 let body = function_body(source, function);
1409 let value = lexical_complexity(&body, &file.language_name);
1410 let cognitive_value = cognitive_complexity(&body, &file.language_name);
1411 values.push(value as f64);
1412 cognitive_values.push(cognitive_value as f64);
1413 let metric = FunctionComplexityMetric {
1414 function_id: function.function_id,
1415 file_id: function.file_id,
1416 path,
1417 name: function.name.clone(),
1418 value,
1419 cognitive_value,
1420 };
1421 all_functions.push(metric.clone());
1422 by_name
1423 .entry(function.name.clone())
1424 .or_default()
1425 .push(metric.clone());
1426 let public_api_like = is_public_api_like(file, function, &body, config);
1427 if public_api_like {
1428 public_api_functions += 1;
1429 }
1430 if let Some(fingerprint) = normalized_body_fingerprint(&body) {
1431 by_fingerprint
1432 .entry(fingerprint)
1433 .or_default()
1434 .push(metric.clone());
1435 }
1436 if let Some(shape) = semantic_shape_fingerprint(&body) {
1437 by_semantic_shape
1438 .entry(shape)
1439 .or_default()
1440 .push(metric.clone());
1441 }
1442 if value >= 10 {
1443 complex_functions.push(metric.clone());
1444 }
1445 if incoming_by_function
1446 .get(&function.function_id)
1447 .copied()
1448 .unwrap_or(0)
1449 == 0
1450 && !is_entry_like_function(&function.name)
1451 && !public_api_like
1452 {
1453 dead_functions.push(metric);
1454 }
1455 }
1456
1457 complex_functions.sort_by(|a, b| b.value.cmp(&a.value).then_with(|| a.path.cmp(&b.path)));
1458 complex_functions.truncate(20);
1459 dead_functions.sort_by(|a, b| b.value.cmp(&a.value).then_with(|| a.path.cmp(&b.path)));
1460 dead_functions.truncate(50);
1461
1462 let mut duplicate_groups: Vec<DuplicateFunctionGroup> = by_fingerprint
1463 .into_iter()
1464 .filter(|(_, functions)| functions.len() > 1)
1465 .map(|(fingerprint, functions)| DuplicateFunctionGroup {
1466 fingerprint,
1467 name: shared_duplicate_name(&functions),
1468 functions,
1469 })
1470 .collect();
1471 duplicate_groups.extend(
1472 by_name
1473 .into_iter()
1474 .filter(|(_, functions)| functions.len() > 1)
1475 .map(|(name, functions)| DuplicateFunctionGroup {
1476 fingerprint: format!("name:{name}"),
1477 name,
1478 functions,
1479 }),
1480 );
1481 duplicate_groups.sort_by(|a, b| {
1482 b.functions
1483 .len()
1484 .cmp(&a.functions.len())
1485 .then_with(|| a.name.cmp(&b.name))
1486 });
1487 duplicate_groups.truncate(20);
1488 let mut semantic_duplicate_groups: Vec<DuplicateFunctionGroup> = by_semantic_shape
1489 .into_iter()
1490 .filter(|(_, functions)| functions.len() > 1)
1491 .map(|(fingerprint, functions)| DuplicateFunctionGroup {
1492 fingerprint,
1493 name: shared_duplicate_name(&functions),
1494 functions,
1495 })
1496 .collect();
1497 semantic_duplicate_groups.sort_by(|a, b| {
1498 b.functions
1499 .len()
1500 .cmp(&a.functions.len())
1501 .then_with(|| a.name.cmp(&b.name))
1502 });
1503 semantic_duplicate_groups.truncate(20);
1504
1505 let max_function_complexity = values.iter().copied().fold(0.0, f64::max) as usize;
1506 let max_cognitive_complexity = cognitive_values.iter().copied().fold(0.0, f64::max) as usize;
1507 let average_function_complexity = if values.is_empty() {
1508 0.0
1509 } else {
1510 round3(values.iter().sum::<f64>() / values.len() as f64)
1511 };
1512 let average_cognitive_complexity = if cognitive_values.is_empty() {
1513 0.0
1514 } else {
1515 round3(cognitive_values.iter().sum::<f64>() / cognitive_values.len() as f64)
1516 };
1517 let duplicate_count = duplicate_groups
1518 .iter()
1519 .map(|group| group.functions.len().saturating_sub(1))
1520 .sum::<usize>();
1521 let redundancy_ratio = ratio(
1522 dead_functions.len() + duplicate_count,
1523 report.functions.len(),
1524 );
1525
1526 let (complexity_entropy, complexity_entropy_bits) =
1527 complexity_distribution_entropy(&all_functions);
1528
1529 ComplexityMetrics {
1530 max_function_complexity,
1531 max_cognitive_complexity,
1532 average_function_complexity,
1533 average_cognitive_complexity,
1534 complexity_gini: gini(&values),
1535 complexity_entropy,
1536 complexity_entropy_bits,
1537 all_functions,
1538 complex_functions,
1539 dead_functions,
1540 duplicate_groups,
1541 semantic_duplicate_groups,
1542 redundancy_ratio,
1543 public_api_functions,
1544 }
1545}
1546
1547fn root_causes(report: &ScanReport, metrics: &MetricsSummary) -> RootCauseScores {
1548 let structural_uniformity = round3(
1554 ((metrics.size.file_size_entropy + metrics.complexity.complexity_entropy) / 2.0)
1555 .clamp(0.0, 1.0),
1556 );
1557 RootCauseScores {
1558 modularity: (1.0 - metrics.coupling.cross_unstable_ratio).clamp(0.0, 1.0),
1559 acyclicity: 1.0 / (1.0 + report.graph.cycle_count as f64),
1560 depth: 1.0 / (1.0 + metrics.architecture.module_depth.saturating_sub(4) as f64),
1561 equality: (1.0 - metrics.complexity.complexity_gini).clamp(0.0, 1.0),
1562 redundancy: (1.0 - metrics.complexity.redundancy_ratio).clamp(0.0, 1.0),
1563 structural_uniformity,
1564 }
1565}
1566
1567fn quality_signal(scores: &RootCauseScores, weights: &ScoreConfig) -> u32 {
1568 let values = [
1569 (scores.modularity, weights.modularity_weight),
1570 (scores.acyclicity, weights.acyclicity_weight),
1571 (scores.depth, weights.depth_weight),
1572 (scores.equality, weights.equality_weight),
1573 (scores.redundancy, weights.redundancy_weight),
1574 (
1575 scores.structural_uniformity,
1576 weights.structural_uniformity_weight,
1577 ),
1578 ];
1579 let weight_sum = values
1580 .iter()
1581 .map(|(_, weight)| weight.max(0.0))
1582 .sum::<f64>()
1583 .max(0.0001);
1584 let weighted_log = values
1585 .iter()
1586 .map(|(value, weight)| value.max(0.0001).ln() * weight.max(0.0))
1587 .sum::<f64>();
1588 ((weighted_log / weight_sum).exp() * 10000.0).round() as u32
1589}
1590
1591fn file_adjacency(report: &ScanReport) -> HashMap<usize, Vec<usize>> {
1592 let mut adjacency: HashMap<usize, Vec<usize>> = HashMap::new();
1593 for import in &report.imports {
1594 if let Some(to_file) = import.resolved_file {
1595 if to_file == import.from_file {
1596 continue;
1597 }
1598 adjacency.entry(import.from_file).or_default().push(to_file);
1599 }
1600 }
1601 adjacency
1602}
1603
1604fn reverse_adjacency(adjacency: &HashMap<usize, Vec<usize>>) -> HashMap<usize, Vec<usize>> {
1605 let mut reverse: HashMap<usize, Vec<usize>> = HashMap::new();
1606 for (from, targets) in adjacency {
1607 for to in targets {
1608 reverse.entry(*to).or_default().push(*from);
1609 }
1610 }
1611 reverse
1612}
1613
1614fn reachable_count(start: usize, adjacency: &HashMap<usize, Vec<usize>>) -> usize {
1615 let mut seen = HashSet::new();
1616 let mut queue: VecDeque<usize> = adjacency.get(&start).cloned().unwrap_or_default().into();
1617 while let Some(next) = queue.pop_front() {
1618 if seen.insert(next) {
1619 if let Some(children) = adjacency.get(&next) {
1620 queue.extend(children);
1621 }
1622 }
1623 }
1624 seen.remove(&start);
1625 seen.len()
1626}
1627
1628fn attack_surface_metrics(
1629 report: &ScanReport,
1630 adjacency: &HashMap<usize, Vec<usize>>,
1631) -> (usize, usize) {
1632 let graph_files: HashSet<usize> = adjacency
1633 .iter()
1634 .flat_map(|(from, targets)| std::iter::once(*from).chain(targets.iter().copied()))
1635 .collect();
1636 if graph_files.is_empty() || report.entry_points.is_empty() {
1637 return (0, graph_files.len());
1638 }
1639
1640 let mut seen = HashSet::new();
1641 let mut queue = VecDeque::new();
1642 for entry in &report.entry_points {
1643 if graph_files.contains(&entry.file_id) && seen.insert(entry.file_id) {
1644 queue.push_back(entry.file_id);
1645 }
1646 }
1647 while let Some(file_id) = queue.pop_front() {
1648 let Some(targets) = adjacency.get(&file_id) else {
1649 continue;
1650 };
1651 for target in targets {
1652 if seen.insert(*target) {
1653 queue.push_back(*target);
1654 }
1655 }
1656 }
1657
1658 (seen.len(), graph_files.len())
1659}
1660
1661fn dependency_levels(
1662 report: &ScanReport,
1663 adjacency: &HashMap<usize, Vec<usize>>,
1664 reverse: &HashMap<usize, Vec<usize>>,
1665) -> BTreeMap<String, usize> {
1666 let mut indegree: HashMap<usize, usize> =
1667 report.files.iter().map(|file| (file.file_id, 0)).collect();
1668 for targets in adjacency.values() {
1669 for target in targets {
1670 *indegree.entry(*target).or_default() += 1;
1671 }
1672 }
1673 let mut queue: VecDeque<usize> = indegree
1674 .iter()
1675 .filter_map(|(file_id, degree)| (*degree == 0).then_some(*file_id))
1676 .collect();
1677 let mut levels: HashMap<usize, usize> = HashMap::new();
1678 while let Some(file_id) = queue.pop_front() {
1679 let parent_level = reverse
1680 .get(&file_id)
1681 .into_iter()
1682 .flatten()
1683 .filter_map(|parent| levels.get(parent).copied())
1684 .max()
1685 .unwrap_or(0);
1686 levels.entry(file_id).or_insert(parent_level);
1687 if let Some(children) = adjacency.get(&file_id) {
1688 for child in children {
1689 let next_level = levels.get(&file_id).copied().unwrap_or(0) + 1;
1690 levels
1691 .entry(*child)
1692 .and_modify(|level| *level = (*level).max(next_level))
1693 .or_insert(next_level);
1694 if let Some(degree) = indegree.get_mut(child) {
1695 *degree = degree.saturating_sub(1);
1696 if *degree == 0 {
1697 queue.push_back(*child);
1698 }
1699 }
1700 }
1701 }
1702 }
1703 report
1704 .files
1705 .iter()
1706 .map(|file| {
1707 (
1708 file.path.to_string_lossy().into_owned(),
1709 levels.get(&file.file_id).copied().unwrap_or(0),
1710 )
1711 })
1712 .collect()
1713}
1714
1715fn upward_violations(
1716 report: &ScanReport,
1717 adjacency: &HashMap<usize, Vec<usize>>,
1718 levels: &BTreeMap<String, usize>,
1719) -> Vec<DependencyViolationMetric> {
1720 let mut violations = Vec::new();
1721 for import in &report.imports {
1722 let Some(to_file_id) = import.resolved_file else {
1723 continue;
1724 };
1725 if to_file_id == import.from_file || import.resolution != ImportResolution::Local {
1726 continue;
1727 }
1728 let Some(from_file) = report.files.get(import.from_file) else {
1729 continue;
1730 };
1731 let Some(to_file) = report.files.get(to_file_id) else {
1732 continue;
1733 };
1734 let from_path = from_file.path.to_string_lossy().into_owned();
1735 let to_path = to_file.path.to_string_lossy().into_owned();
1736 let from_level = levels.get(&from_path).copied().unwrap_or(0);
1737 let to_level = levels.get(&to_path).copied().unwrap_or(0);
1738 let reason = if from_level < to_level {
1739 Some("upward_level".to_string())
1740 } else if reachable_from(to_file_id, import.from_file, adjacency) {
1741 Some("cycle_edge".to_string())
1742 } else {
1743 None
1744 };
1745 let Some(reason) = reason else {
1746 continue;
1747 };
1748 violations.push(DependencyViolationMetric {
1749 from_file_id: import.from_file,
1750 from_path,
1751 from_level,
1752 to_file_id,
1753 to_path,
1754 to_level,
1755 reason,
1756 });
1757 }
1758
1759 violations.sort_by(|a, b| {
1760 let a_diff = a.to_level.abs_diff(a.from_level);
1761 let b_diff = b.to_level.abs_diff(b.from_level);
1762 b_diff
1763 .cmp(&a_diff)
1764 .then_with(|| a.from_path.cmp(&b.from_path))
1765 .then_with(|| a.to_path.cmp(&b.to_path))
1766 });
1767 violations.truncate(20);
1768 violations
1769}
1770
1771fn reachable_from(start: usize, target: usize, adjacency: &HashMap<usize, Vec<usize>>) -> bool {
1772 let mut seen = HashSet::new();
1773 let mut queue: VecDeque<usize> = adjacency.get(&start).cloned().unwrap_or_default().into();
1774 while let Some(next) = queue.pop_front() {
1775 if next == target {
1776 return true;
1777 }
1778 if seen.insert(next) {
1779 if let Some(children) = adjacency.get(&next) {
1780 queue.extend(children);
1781 }
1782 }
1783 }
1784 false
1785}
1786
1787fn module_stability(report: &ScanReport, config: &RaysenseConfig) -> Vec<ModuleStabilityMetric> {
1788 let stable = stable_foundation_modules(report, config);
1789 let mut metrics = module_stability_all(report, config);
1790 metrics.retain(|metric| !stable.contains(&metric.module));
1791 metrics.truncate(20);
1792 metrics
1793}
1794
1795fn stable_foundation_metrics(
1796 report: &ScanReport,
1797 config: &RaysenseConfig,
1798) -> Vec<ModuleStabilityMetric> {
1799 let stable = stable_foundation_modules(report, config);
1800 let mut metrics: Vec<ModuleStabilityMetric> = module_stability_all(report, config)
1801 .into_iter()
1802 .filter(|metric| stable.contains(&metric.module))
1803 .collect();
1804 metrics.sort_by(|a, b| {
1805 b.fan_in
1806 .cmp(&a.fan_in)
1807 .then_with(|| a.module.cmp(&b.module))
1808 });
1809 metrics.truncate(20);
1810 metrics
1811}
1812
1813fn stable_foundation_modules(report: &ScanReport, config: &RaysenseConfig) -> HashSet<String> {
1814 module_stability_all(report, config)
1815 .into_iter()
1816 .filter(|metric| metric.fan_in >= 2 && (metric.fan_out == 0 || metric.instability <= 0.15))
1817 .map(|metric| metric.module)
1818 .collect()
1819}
1820
1821pub fn is_foundation_file(report: &ScanReport, config: &RaysenseConfig, file_id: usize) -> bool {
1822 foundation_file_ids(report, config).contains(&file_id)
1823}
1824
1825fn foundation_file_ids(report: &ScanReport, config: &RaysenseConfig) -> HashSet<usize> {
1826 let stable_modules = stable_foundation_modules(report, config);
1827 let file_fan_in = file_fan_in(report);
1828
1829 report
1830 .files
1831 .iter()
1832 .filter(|file| {
1833 stable_modules.contains(&module_group(file, config))
1834 || file_fan_in.get(&file.file_id).copied().unwrap_or(0) >= 5
1835 || is_package_index_path(&normalize_rule_path(&file.path))
1836 })
1837 .map(|file| file.file_id)
1838 .collect()
1839}
1840
1841fn file_fan_in(report: &ScanReport) -> HashMap<usize, usize> {
1842 let mut fan_in: HashMap<usize, usize> = HashMap::new();
1843 for import in &report.imports {
1844 let Some(to_file_id) = import.resolved_file else {
1845 continue;
1846 };
1847 if to_file_id != import.from_file && import.resolution == ImportResolution::Local {
1848 *fan_in.entry(to_file_id).or_default() += 1;
1849 }
1850 }
1851 fan_in
1852}
1853
1854fn module_stability_all(
1855 report: &ScanReport,
1856 config: &RaysenseConfig,
1857) -> Vec<ModuleStabilityMetric> {
1858 let mut fan_in: HashMap<String, usize> = HashMap::new();
1859 let mut fan_out: HashMap<String, usize> = HashMap::new();
1860 for import in &report.imports {
1861 let Some(to_file_id) = import.resolved_file else {
1862 continue;
1863 };
1864 if to_file_id == import.from_file {
1865 continue;
1866 }
1867 let Some(from_file) = report.files.get(import.from_file) else {
1868 continue;
1869 };
1870 let Some(to_file) = report.files.get(to_file_id) else {
1871 continue;
1872 };
1873 let from = module_group(from_file, config);
1874 let to = module_group(to_file, config);
1875 if from != to {
1876 *fan_out.entry(from).or_default() += 1;
1877 *fan_in.entry(to).or_default() += 1;
1878 }
1879 }
1880 let mut modules: HashSet<String> = fan_in
1881 .keys()
1882 .cloned()
1883 .chain(fan_out.keys().cloned())
1884 .collect();
1885 modules.extend(report.files.iter().map(|file| module_group(file, config)));
1886 let mut metrics: Vec<ModuleStabilityMetric> = modules
1887 .into_iter()
1888 .map(|module| {
1889 let incoming = fan_in.get(&module).copied().unwrap_or(0);
1890 let outgoing = fan_out.get(&module).copied().unwrap_or(0);
1891 ModuleStabilityMetric {
1892 module,
1893 fan_in: incoming,
1894 fan_out: outgoing,
1895 instability: round3(ratio(outgoing, incoming + outgoing)),
1896 }
1897 })
1898 .collect();
1899 metrics.sort_by(|a, b| {
1900 b.fan_out
1901 .cmp(&a.fan_out)
1902 .then_with(|| a.module.cmp(&b.module))
1903 });
1904 metrics
1905}
1906
1907fn module_distance_metrics(
1908 report: &ScanReport,
1909 config: &RaysenseConfig,
1910) -> Vec<ModuleDistanceMetric> {
1911 let mut abstract_by_module: HashMap<String, usize> = HashMap::new();
1912 let mut total_by_module: HashMap<String, usize> = HashMap::new();
1913 let sources = source_cache(report);
1914
1915 for file in &report.files {
1916 let Some(source) = sources.get(&file.file_id) else {
1917 continue;
1918 };
1919 let (abstract_count, total_count) = type_counts(source, file, config);
1920 if total_count == 0 {
1921 continue;
1922 }
1923 let module = module_group(file, config);
1924 *abstract_by_module.entry(module.clone()).or_default() += abstract_count;
1925 *total_by_module.entry(module).or_default() += total_count;
1926 }
1927
1928 let stability_by_module: HashMap<String, ModuleStabilityMetric> =
1929 module_stability_all(report, config)
1930 .into_iter()
1931 .map(|metric| (metric.module.clone(), metric))
1932 .collect();
1933 let stable_modules = stable_foundation_modules(report, config);
1934
1935 let mut metrics: Vec<ModuleDistanceMetric> = total_by_module
1936 .into_iter()
1937 .map(|(module, total_types)| {
1938 let abstract_count = abstract_by_module.get(&module).copied().unwrap_or(0);
1939 let stability = stability_by_module.get(&module);
1940 let fan_in = stability.map(|metric| metric.fan_in).unwrap_or(0);
1941 let fan_out = stability.map(|metric| metric.fan_out).unwrap_or(0);
1942 let instability = if fan_in + fan_out == 0 {
1943 0.5
1944 } else {
1945 ratio(fan_out, fan_in + fan_out)
1946 };
1947 let abstractness = ratio(abstract_count, total_types);
1948 let distance = (abstractness + instability - 1.0).abs();
1949 ModuleDistanceMetric {
1950 module: module.clone(),
1951 abstractness: round3(abstractness),
1952 instability: round3(instability),
1953 distance: round3(distance),
1954 abstract_count,
1955 total_types,
1956 fan_in,
1957 fan_out,
1958 is_foundation: instability <= 0.30 || stable_modules.contains(&module),
1959 }
1960 })
1961 .collect();
1962
1963 metrics.sort_by(|a, b| {
1964 b.distance
1965 .partial_cmp(&a.distance)
1966 .unwrap_or(std::cmp::Ordering::Equal)
1967 .then_with(|| a.module.cmp(&b.module))
1968 });
1969 metrics.truncate(20);
1970 metrics
1971}
1972
1973fn type_counts(source: &str, file: &FileFact, config: &RaysenseConfig) -> (usize, usize) {
1974 let mut abstract_count = 0usize;
1975 let mut total_count = 0usize;
1976 let plugin = plugin_for_file(file, config);
1977 for line in source.lines() {
1978 let clean = line.split("//").next().unwrap_or(line).trim();
1979 if clean.is_empty()
1980 || clean.starts_with('#')
1981 || clean.starts_with('*')
1982 || clean.starts_with("/*")
1983 {
1984 continue;
1985 }
1986 let is_configured_abstract = plugin.is_some_and(|plugin| {
1987 plugin
1988 .abstract_type_prefixes
1989 .iter()
1990 .any(|prefix| clean.starts_with(prefix))
1991 });
1992 let is_configured_concrete = plugin.is_some_and(|plugin| {
1993 plugin
1994 .concrete_type_prefixes
1995 .iter()
1996 .any(|prefix| clean.starts_with(prefix))
1997 });
1998 let is_abstract =
1999 is_configured_abstract || is_abstract_type_line(clean, &file.language_name);
2000 let is_type = is_abstract
2001 || is_configured_concrete
2002 || is_concrete_type_line(clean, &file.language_name);
2003 if is_type {
2004 total_count += 1;
2005 if is_abstract {
2006 abstract_count += 1;
2007 }
2008 }
2009 }
2010 (abstract_count, total_count)
2011}
2012
2013pub(crate) fn is_abstract_type_line(line: &str, language: &str) -> bool {
2014 match language {
2015 "rust" => line.starts_with("trait ") || line.starts_with("pub trait "),
2016 "typescript" | "tsx" | "javascript" => {
2017 line.starts_with("interface ")
2018 || line.starts_with("export interface ")
2019 || line.starts_with("abstract class ")
2020 || line.starts_with("export abstract class ")
2021 }
2022 "python" => {
2023 line.starts_with("class ") && (line.contains("Protocol") || line.contains("ABC"))
2024 }
2025 "c++" | "cpp" => line.starts_with("class ") && line.contains("= 0"),
2026 _ => false,
2027 }
2028}
2029
2030pub(crate) fn is_concrete_type_line(line: &str, language: &str) -> bool {
2031 match language {
2032 "rust" => {
2033 line.starts_with("struct ")
2034 || line.starts_with("pub struct ")
2035 || line.starts_with("enum ")
2036 || line.starts_with("pub enum ")
2037 || line.starts_with("type ")
2038 || line.starts_with("pub type ")
2039 }
2040 "typescript" | "tsx" | "javascript" => {
2041 line.starts_with("class ")
2042 || line.starts_with("export class ")
2043 || line.starts_with("type ")
2044 || line.starts_with("export type ")
2045 }
2046 "python" => line.starts_with("class "),
2047 "c" | "c++" | "cpp" => {
2048 line.starts_with("struct ")
2049 || line.starts_with("typedef struct")
2050 || line.starts_with("enum ")
2051 || line.starts_with("typedef enum")
2052 || line.starts_with("class ")
2053 }
2054 _ => false,
2055 }
2056}
2057
2058fn cycle_components(
2059 report: &ScanReport,
2060 adjacency: &HashMap<usize, Vec<usize>>,
2061) -> Vec<Vec<String>> {
2062 let mut cycles = Vec::new();
2063 for file in &report.files {
2064 let mut stack = adjacency.get(&file.file_id).cloned().unwrap_or_default();
2065 let mut seen = HashSet::new();
2066 while let Some(next) = stack.pop() {
2067 if next == file.file_id {
2068 cycles.push(vec![file.path.to_string_lossy().into_owned()]);
2069 break;
2070 }
2071 if seen.insert(next) {
2072 if let Some(children) = adjacency.get(&next) {
2073 stack.extend(children);
2074 }
2075 }
2076 }
2077 }
2078 cycles.truncate(20);
2079 cycles
2080}
2081
2082fn distribution_entropy(counts: &[usize]) -> (f64, f64) {
2083 let total: usize = counts.iter().sum();
2084 if total == 0 {
2085 return (0.0, 0.0);
2086 }
2087 let distinct = counts.iter().filter(|count| **count > 0).count();
2088 if distinct <= 1 {
2089 return (0.0, 0.0);
2090 }
2091 let total = total as f64;
2092 let entropy_bits: f64 = counts
2093 .iter()
2094 .filter(|count| **count > 0)
2095 .map(|count| {
2096 let p = *count as f64 / total;
2097 -p * p.log2()
2098 })
2099 .sum();
2100 let max_entropy = (distinct as f64).log2();
2101 let entropy = if max_entropy > 0.0 {
2102 entropy_bits / max_entropy
2103 } else {
2104 0.0
2105 };
2106 (round3(entropy), round3(entropy_bits))
2107}
2108
2109fn file_lines_bucket(lines: usize) -> usize {
2112 if lines == 0 {
2113 0
2114 } else {
2115 (usize::BITS - lines.leading_zeros()) as usize
2116 }
2117}
2118
2119fn file_size_distribution_entropy(report: &ScanReport) -> (f64, f64) {
2120 let mut buckets: BTreeMap<usize, usize> = BTreeMap::new();
2121 for file in &report.files {
2122 *buckets.entry(file_lines_bucket(file.lines)).or_default() += 1;
2123 }
2124 let counts: Vec<usize> = buckets.into_values().collect();
2125 distribution_entropy(&counts)
2126}
2127
2128fn complexity_distribution_entropy(functions: &[FunctionComplexityMetric]) -> (f64, f64) {
2129 let mut buckets: BTreeMap<usize, usize> = BTreeMap::new();
2130 for function in functions {
2131 *buckets.entry(function.value).or_default() += 1;
2132 }
2133 let counts: Vec<usize> = buckets.into_values().collect();
2134 distribution_entropy(&counts)
2135}
2136
2137fn gini(values: &[f64]) -> f64 {
2138 if values.len() < 2 {
2139 return 0.0;
2140 }
2141 let mut sorted = values.to_vec();
2142 sorted.sort_by(|a, b| a.total_cmp(b));
2143 let sum = sorted.iter().sum::<f64>();
2144 if sum == 0.0 {
2145 return 0.0;
2146 }
2147 let n = sorted.len() as f64;
2148 let weighted = sorted
2149 .iter()
2150 .enumerate()
2151 .map(|(idx, value)| (idx as f64 + 1.0) * value)
2152 .sum::<f64>();
2153 round3((2.0 * weighted) / (n * sum) - (n + 1.0) / n)
2154}
2155
2156fn is_entry_like_function(name: &str) -> bool {
2157 matches!(name, "main" | "init" | "start" | "run" | "new")
2158 || name.starts_with("test_")
2159 || name.ends_with("_test")
2160}
2161
2162fn source_cache(report: &ScanReport) -> HashMap<usize, String> {
2163 report
2164 .files
2165 .iter()
2166 .filter_map(|file| {
2167 fs::read_to_string(report.snapshot.root.join(&file.path))
2168 .ok()
2169 .map(|source| (file.file_id, source))
2170 })
2171 .collect()
2172}
2173
2174fn function_body(source: &str, function: &crate::facts::FunctionFact) -> String {
2175 source
2176 .lines()
2177 .skip(function.start_line.saturating_sub(1))
2178 .take(function.end_line.saturating_sub(function.start_line) + 1)
2179 .collect::<Vec<_>>()
2180 .join("\n")
2181}
2182
2183fn lexical_complexity(body: &str, language: &str) -> usize {
2184 let mut value = 1usize;
2185 for token in normalized_tokens(body) {
2186 if matches!(
2187 token.as_str(),
2188 "if" | "else"
2189 | "elif"
2190 | "for"
2191 | "while"
2192 | "loop"
2193 | "match"
2194 | "case"
2195 | "catch"
2196 | "except"
2197 | "switch"
2198 | "guard"
2199 | "when"
2200 ) {
2201 value += 1;
2202 }
2203 }
2204 value += body.matches("&&").count();
2205 value += body.matches("||").count();
2206 value += body.matches('?').count();
2207 if matches!(language, "python" | "ruby" | "swift") {
2208 value += body.matches(" and ").count();
2209 value += body.matches(" or ").count();
2210 }
2211 value
2212}
2213
2214fn cognitive_complexity(body: &str, language: &str) -> usize {
2215 let mut score = 0usize;
2216 let mut nesting = 0usize;
2217 for line in strip_strings_and_comments(body).lines() {
2218 let trimmed = line.trim();
2219 if trimmed.starts_with('}') {
2220 nesting = nesting.saturating_sub(1);
2221 }
2222 let tokens = normalized_tokens(trimmed);
2223 if tokens.iter().any(|token| is_branch_token(token)) {
2224 score += 1 + nesting;
2225 }
2226 score += trimmed.matches("&&").count();
2227 score += trimmed.matches("||").count();
2228 if matches!(language, "python" | "ruby" | "swift") {
2229 score += trimmed.matches(" and ").count();
2230 score += trimmed.matches(" or ").count();
2231 }
2232 if trimmed.ends_with('{') || trimmed.ends_with(':') {
2233 nesting += 1;
2234 }
2235 nesting = nesting.saturating_sub(trimmed.matches('}').count());
2236 }
2237 score
2238}
2239
2240fn is_branch_token(token: &str) -> bool {
2241 matches!(
2242 token,
2243 "if" | "elif"
2244 | "else"
2245 | "for"
2246 | "while"
2247 | "loop"
2248 | "match"
2249 | "case"
2250 | "catch"
2251 | "except"
2252 | "switch"
2253 | "guard"
2254 | "when"
2255 )
2256}
2257
2258fn normalized_body_fingerprint(body: &str) -> Option<String> {
2259 let tokens = normalized_tokens(body);
2260 if tokens.len() < 12 {
2261 return None;
2262 }
2263 let normalized = tokens
2264 .iter()
2265 .map(|token| {
2266 if token.chars().all(|ch| ch.is_ascii_digit()) {
2267 "0"
2268 } else if is_keyword_token(token) {
2269 token.as_str()
2270 } else {
2271 "id"
2272 }
2273 })
2274 .collect::<Vec<_>>()
2275 .join(" ");
2276 Some(short_hash(&normalized))
2277}
2278
2279fn semantic_shape_fingerprint(body: &str) -> Option<String> {
2280 let tokens = normalized_tokens(body);
2281 if tokens.len() < 20 {
2282 return None;
2283 }
2284 let shape = tokens
2285 .iter()
2286 .filter(|token| {
2287 is_keyword_token(token) || matches!(token.as_str(), "{" | "}" | "(" | ")" | "?" | ":")
2288 })
2289 .map(String::as_str)
2290 .collect::<Vec<_>>()
2291 .join(" ");
2292 if shape.split_whitespace().count() < 4 {
2293 return None;
2294 }
2295 Some(format!("shape:{}", short_hash(&shape)))
2296}
2297
2298fn normalized_tokens(body: &str) -> Vec<String> {
2299 let mut tokens = Vec::new();
2300 let mut current = String::new();
2301 for ch in strip_strings_and_comments(body).chars() {
2302 if ch.is_ascii_alphanumeric() || ch == '_' {
2303 current.push(ch.to_ascii_lowercase());
2304 } else {
2305 if !current.is_empty() {
2306 tokens.push(std::mem::take(&mut current));
2307 }
2308 if matches!(ch, '{' | '}' | '(' | ')' | '[' | ']' | '?' | ':') {
2309 tokens.push(ch.to_string());
2310 }
2311 }
2312 }
2313 if !current.is_empty() {
2314 tokens.push(current);
2315 }
2316 tokens
2317}
2318
2319fn strip_strings_and_comments(body: &str) -> String {
2320 let mut out = String::with_capacity(body.len());
2321 let mut chars = body.chars().peekable();
2322 let mut in_string = None;
2323 let mut in_line_comment = false;
2324 let mut in_block_comment = false;
2325 while let Some(ch) = chars.next() {
2326 if in_line_comment {
2327 if ch == '\n' {
2328 in_line_comment = false;
2329 out.push('\n');
2330 } else {
2331 out.push(' ');
2332 }
2333 continue;
2334 }
2335 if in_block_comment {
2336 if ch == '*' && chars.peek() == Some(&'/') {
2337 chars.next();
2338 in_block_comment = false;
2339 out.push(' ');
2340 } else {
2341 out.push(if ch == '\n' { '\n' } else { ' ' });
2342 }
2343 continue;
2344 }
2345 if let Some(quote) = in_string {
2346 if ch == '\\' {
2347 chars.next();
2348 out.push(' ');
2349 } else if ch == quote {
2350 in_string = None;
2351 out.push(' ');
2352 } else {
2353 out.push(if ch == '\n' { '\n' } else { ' ' });
2354 }
2355 continue;
2356 }
2357 if ch == '/' && chars.peek() == Some(&'/') {
2358 chars.next();
2359 in_line_comment = true;
2360 out.push(' ');
2361 } else if ch == '/' && chars.peek() == Some(&'*') {
2362 chars.next();
2363 in_block_comment = true;
2364 out.push(' ');
2365 } else if ch == '"' || ch == '\'' || ch == '`' {
2366 in_string = Some(ch);
2367 out.push(' ');
2368 } else {
2369 out.push(ch);
2370 }
2371 }
2372 out
2373}
2374
2375fn is_keyword_token(token: &str) -> bool {
2376 matches!(
2377 token,
2378 "if" | "else"
2379 | "elif"
2380 | "for"
2381 | "while"
2382 | "loop"
2383 | "match"
2384 | "case"
2385 | "catch"
2386 | "except"
2387 | "switch"
2388 | "return"
2389 | "break"
2390 | "continue"
2391 | "async"
2392 | "await"
2393 | "yield"
2394 | "try"
2395 | "throw"
2396 )
2397}
2398
2399fn is_public_api_like(
2400 file: &crate::facts::FileFact,
2401 function: &crate::facts::FunctionFact,
2402 body: &str,
2403 config: &RaysenseConfig,
2404) -> bool {
2405 let name = function.name.as_str();
2406 let path = normalize_rule_path(&file.path);
2407 is_test_path_configured(&path, config)
2408 || matches_configured_path(&path, &config.scan.public_api_paths)
2409 || matches!(name, "main" | "init" | "start" | "run" | "new")
2410 || name.starts_with("test_")
2411 || name.ends_with("_test")
2412 || body.lines().next().is_some_and(|line| {
2413 let trimmed = line.trim_start();
2414 trimmed.starts_with("pub ")
2415 || trimmed.starts_with("pub(")
2416 || trimmed.starts_with("export ")
2417 || trimmed.starts_with("public ")
2418 || trimmed.starts_with("def __")
2419 })
2420 || path.ends_with("lib.rs")
2421 || is_package_index_path(&path)
2422}
2423
2424fn is_package_index_path(path: &str) -> bool {
2425 path.ends_with("mod.rs")
2426 || path.ends_with("__init__.py")
2427 || path.ends_with("index.ts")
2428 || path.ends_with("index.tsx")
2429 || path.ends_with("index.js")
2430}
2431
2432fn shared_duplicate_name(functions: &[FunctionComplexityMetric]) -> String {
2433 let Some(first) = functions.first() else {
2434 return String::new();
2435 };
2436 if functions.iter().all(|function| function.name == first.name) {
2437 first.name.clone()
2438 } else {
2439 "similar_body".to_string()
2440 }
2441}
2442
2443fn short_hash(value: &str) -> String {
2444 use sha2::{Digest, Sha256};
2445 let mut hasher = Sha256::new();
2446 hasher.update(value.as_bytes());
2447 let hash = format!("{:x}", hasher.finalize());
2448 hash[..16].to_string()
2449}
2450
2451fn size_metrics(report: &ScanReport) -> SizeMetrics {
2452 let max_file_lines = report
2453 .files
2454 .iter()
2455 .map(|file| file.lines)
2456 .max()
2457 .unwrap_or(0);
2458 let max_function_lines = report
2459 .functions
2460 .iter()
2461 .map(|function| function.end_line.saturating_sub(function.start_line) + 1)
2462 .max()
2463 .unwrap_or(0);
2464
2465 let (file_size_entropy, file_size_entropy_bits) = file_size_distribution_entropy(report);
2466
2467 let total_lines: usize = report.files.iter().map(|file| file.lines).sum();
2468 let total_comment_lines: usize = report.files.iter().map(|file| file.comment_lines).sum();
2469 let comment_ratio = if total_lines == 0 {
2470 0.0
2471 } else {
2472 round3(total_comment_lines as f64 / total_lines as f64)
2473 };
2474
2475 SizeMetrics {
2476 max_file_lines,
2477 max_function_lines,
2478 large_files: report.files.iter().filter(|file| file.lines >= 500).count(),
2479 long_functions: report
2480 .functions
2481 .iter()
2482 .filter(|function| function.end_line.saturating_sub(function.start_line) + 1 >= 80)
2483 .count(),
2484 file_size_entropy,
2485 file_size_entropy_bits,
2486 total_lines,
2487 total_comment_lines,
2488 comment_ratio,
2489 }
2490}
2491
2492fn entry_point_metrics(report: &ScanReport) -> EntryPointMetrics {
2493 let mut metrics = EntryPointMetrics::default();
2494 for entry in &report.entry_points {
2495 match entry.kind {
2496 EntryPointKind::Binary => metrics.binaries += 1,
2497 EntryPointKind::Example => metrics.examples += 1,
2498 EntryPointKind::Test => metrics.tests += 1,
2499 }
2500 }
2501 metrics
2502}
2503
2504fn test_gap_metrics(report: &ScanReport, config: &RaysenseConfig) -> TestGapMetrics {
2505 let test_paths: HashSet<String> = report
2506 .files
2507 .iter()
2508 .filter(|file| is_test_path_configured(&normalize_rule_path(&file.path), config))
2509 .map(|file| file.path.to_string_lossy().replace('\\', "/"))
2510 .collect();
2511
2512 let mut production_files = 0;
2513 let mut files_without_nearby_tests = 0;
2514 let mut candidates = Vec::new();
2515
2516 for file in &report.files {
2517 let path = normalize_rule_path(&file.path);
2518 if is_test_path_configured(&path, config) {
2519 continue;
2520 }
2521 if !report
2522 .functions
2523 .iter()
2524 .any(|function| function.file_id == file.file_id)
2525 {
2526 continue;
2527 }
2528 production_files += 1;
2529 let framework = test_framework(file);
2530 let expected_tests = expected_test_paths(&path, &framework, config);
2531 let matched_tests = expected_tests
2532 .iter()
2533 .filter(|path| test_paths.contains(*path))
2534 .cloned()
2535 .collect::<Vec<_>>();
2536 if matched_tests.is_empty() {
2537 files_without_nearby_tests += 1;
2538 candidates.push(TestGapCandidate {
2539 file_id: file.file_id,
2540 path,
2541 framework,
2542 expected_tests,
2543 matched_tests,
2544 });
2545 }
2546 }
2547 candidates.sort_by(|a, b| a.path.cmp(&b.path));
2548 candidates.truncate(100);
2549
2550 TestGapMetrics {
2551 production_files,
2552 test_files: report
2553 .files
2554 .iter()
2555 .filter(|file| is_test_path_configured(&normalize_rule_path(&file.path), config))
2556 .count(),
2557 files_without_nearby_tests,
2558 candidates,
2559 }
2560}
2561
2562fn expected_test_paths(path: &str, framework: &str, config: &RaysenseConfig) -> Vec<String> {
2563 let normalized = path.replace('\\', "/");
2564 let path = Path::new(&normalized);
2565 let stem = path
2566 .file_stem()
2567 .and_then(|stem| stem.to_str())
2568 .unwrap_or("");
2569 let ext = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
2570 let parent = path.parent().unwrap_or_else(|| Path::new(""));
2571 let parent = parent.to_string_lossy().replace('\\', "/");
2572 let mut out = Vec::new();
2573 if !stem.is_empty() && !ext.is_empty() {
2574 out.push(format!("tests/{stem}_test.{ext}"));
2575 out.push(format!("tests/test_{stem}.{ext}"));
2576 for root in configured_test_roots(config) {
2577 out.push(format!("{root}/{stem}_test.{ext}"));
2578 out.push(format!("{root}/test_{stem}.{ext}"));
2579 }
2580 out.push(
2581 format!("{parent}/{stem}_test.{ext}")
2582 .trim_start_matches('/')
2583 .to_string(),
2584 );
2585 out.push(
2586 format!("{parent}/{stem}.test.{ext}")
2587 .trim_start_matches('/')
2588 .to_string(),
2589 );
2590 }
2591 if normalized.starts_with("src/") {
2592 out.push(normalized.replacen("src/", "tests/", 1));
2593 for root in configured_test_roots(config) {
2594 out.push(normalized.replacen("src/", &format!("{root}/"), 1));
2595 }
2596 }
2597 match framework {
2598 "rust" => {
2599 out.push(format!("tests/{stem}.rs"));
2600 out.push(format!("src/{stem}/tests.rs"));
2601 }
2602 "python" => {
2603 out.push(format!("tests/test_{stem}.py"));
2604 }
2605 "typescript" | "javascript" => {
2606 out.push(
2607 format!("{parent}/{stem}.spec.{ext}")
2608 .trim_start_matches('/')
2609 .to_string(),
2610 );
2611 out.push(
2612 format!("{parent}/{stem}.test.{ext}")
2613 .trim_start_matches('/')
2614 .to_string(),
2615 );
2616 }
2617 _ => {}
2618 }
2619 out.sort();
2620 out.dedup();
2621 out
2622}
2623
2624fn test_framework(file: &crate::facts::FileFact) -> String {
2625 match file.language_name.as_str() {
2626 "rust" => "rust",
2627 "python" => "python",
2628 "typescript" => "typescript",
2629 "go" => "go",
2630 "java" => "junit",
2631 "csharp" => "dotnet",
2632 other => other,
2633 }
2634 .to_string()
2635}
2636
2637fn dsm_metrics(report: &ScanReport, config: &RaysenseConfig) -> DsmMetrics {
2638 let mut edges: BTreeMap<(String, String), usize> = BTreeMap::new();
2639 let mut modules = HashSet::new();
2640
2641 for file in &report.files {
2642 modules.insert(module_group(file, config));
2643 }
2644
2645 for import in &report.imports {
2646 let Some(to_file_id) = import.resolved_file else {
2647 continue;
2648 };
2649 if to_file_id == import.from_file {
2650 continue;
2651 }
2652 let Some(from_file) = report.files.get(import.from_file) else {
2653 continue;
2654 };
2655 let Some(to_file) = report.files.get(to_file_id) else {
2656 continue;
2657 };
2658 let from_module = module_group(from_file, config);
2659 let to_module = module_group(to_file, config);
2660 if from_module != to_module {
2661 *edges.entry((from_module, to_module)).or_default() += 1;
2662 }
2663 }
2664
2665 let mut top_module_edges: Vec<ModuleEdgeMetric> = edges
2666 .iter()
2667 .map(|((from_module, to_module), edges)| ModuleEdgeMetric {
2668 from_module: from_module.clone(),
2669 to_module: to_module.clone(),
2670 edges: *edges,
2671 })
2672 .collect();
2673 top_module_edges.sort_by(|a, b| {
2674 b.edges
2675 .cmp(&a.edges)
2676 .then_with(|| a.from_module.cmp(&b.from_module))
2677 .then_with(|| a.to_module.cmp(&b.to_module))
2678 });
2679 top_module_edges.truncate(10);
2680
2681 DsmMetrics {
2682 module_count: modules.len(),
2683 module_edges: edges.values().sum(),
2684 top_module_edges,
2685 }
2686}
2687
2688fn evolution_metrics(
2689 report: &ScanReport,
2690 complexity: &ComplexityMetrics,
2691 test_gap: &TestGapMetrics,
2692) -> EvolutionMetrics {
2693 let root = &report.snapshot.root;
2694 let prefix = match git_output(root, ["rev-parse", "--show-prefix"]) {
2695 Ok(output) => output.trim().replace('\\', "/"),
2696 Err(reason) => {
2697 return EvolutionMetrics {
2698 available: false,
2699 reason,
2700 ..EvolutionMetrics::default()
2701 };
2702 }
2703 };
2704
2705 let log = match git_output(
2706 root,
2707 [
2708 "log",
2709 "-n",
2710 "500",
2711 "--format=commit:%H|%ae|%at|%s",
2712 "--name-only",
2713 ],
2714 ) {
2715 Ok(output) => output,
2716 Err(reason) => {
2717 return EvolutionMetrics {
2718 available: false,
2719 reason,
2720 ..EvolutionMetrics::default()
2721 };
2722 }
2723 };
2724
2725 let scanned_files: HashSet<String> = report
2726 .files
2727 .iter()
2728 .map(|file| file.path.to_string_lossy().replace('\\', "/"))
2729 .collect();
2730 let mut commits_sampled = 0;
2731 let mut bug_fix_commits = 0usize;
2732 let mut file_commits: BTreeMap<String, usize> = BTreeMap::new();
2733 let mut file_bug_fix_commits: BTreeMap<String, usize> = BTreeMap::new();
2734 let mut author_commits: BTreeMap<String, usize> = BTreeMap::new();
2735 let mut file_author_commits: BTreeMap<String, BTreeMap<String, usize>> = BTreeMap::new();
2736 let mut file_age_window: BTreeMap<String, (i64, i64)> = BTreeMap::new();
2737 let mut pair_counts: BTreeMap<(String, String), usize> = BTreeMap::new();
2738 let mut current_author: Option<String> = None;
2739 let mut current_timestamp: Option<i64> = None;
2740 let mut current_is_bug_fix = false;
2741 let mut commit_files = HashSet::new();
2742
2743 for line in log.lines() {
2744 let line = line.trim();
2745 if line.is_empty() {
2746 continue;
2747 }
2748 if let Some(rest) = line.strip_prefix("commit:") {
2749 flush_commit_files_with_author(
2750 &mut file_commits,
2751 &mut file_bug_fix_commits,
2752 &mut file_author_commits,
2753 &mut file_age_window,
2754 &mut pair_counts,
2755 &mut commit_files,
2756 current_author.as_deref(),
2757 current_timestamp,
2758 current_is_bug_fix,
2759 );
2760 commits_sampled += 1;
2761 let mut parts = rest.splitn(4, '|');
2765 let _hash = parts.next();
2766 let author = parts.next().map(|email| email.trim().to_string());
2767 let timestamp = parts.next().and_then(|raw| raw.trim().parse::<i64>().ok());
2768 let subject = parts.next().unwrap_or("").trim();
2769 let is_bug_fix = is_bug_fix_subject(subject);
2770 if is_bug_fix {
2771 bug_fix_commits += 1;
2772 }
2773 if let Some(author) = author.as_ref() {
2774 if !author.is_empty() {
2775 *author_commits.entry(author.clone()).or_default() += 1;
2776 }
2777 }
2778 current_author = author;
2779 current_timestamp = timestamp;
2780 current_is_bug_fix = is_bug_fix;
2781 continue;
2782 }
2783
2784 if let Some(path) = scan_relative_git_path(line, &prefix) {
2785 if scanned_files.contains(&path) {
2786 commit_files.insert(path);
2787 }
2788 }
2789 }
2790 flush_commit_files_with_author(
2791 &mut file_commits,
2792 &mut file_bug_fix_commits,
2793 &mut file_author_commits,
2794 &mut file_age_window,
2795 &mut pair_counts,
2796 &mut commit_files,
2797 current_author.as_deref(),
2798 current_timestamp,
2799 current_is_bug_fix,
2800 );
2801
2802 let mut top_changed_files: Vec<EvolutionFileMetric> = file_commits
2803 .iter()
2804 .map(|(path, commits)| EvolutionFileMetric {
2805 path: path.clone(),
2806 commits: *commits,
2807 })
2808 .collect();
2809 top_changed_files.sort_by(|a, b| b.commits.cmp(&a.commits).then_with(|| a.path.cmp(&b.path)));
2810 top_changed_files.truncate(10);
2811
2812 let author_count = author_commits.len();
2813 let mut top_authors: Vec<EvolutionAuthorMetric> = author_commits
2814 .iter()
2815 .map(|(author, commits)| EvolutionAuthorMetric {
2816 author: author.clone(),
2817 commits: *commits,
2818 })
2819 .collect();
2820 top_authors.sort_by(|a, b| {
2821 b.commits
2822 .cmp(&a.commits)
2823 .then_with(|| a.author.cmp(&b.author))
2824 });
2825 top_authors.truncate(10);
2826
2827 let mut file_ownership: Vec<EvolutionFileOwnership> = file_author_commits
2828 .iter()
2829 .map(|(path, by_author)| {
2830 let total_commits: usize = by_author.values().sum();
2831 let mut sorted: Vec<(&String, &usize)> = by_author.iter().collect();
2832 sorted.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0)));
2833 let (top_author, top_commits) = sorted
2834 .first()
2835 .map(|(name, count)| ((*name).clone(), **count))
2836 .unwrap_or_default();
2837 let bus_factor = bus_factor_for(&sorted, total_commits);
2838 EvolutionFileOwnership {
2839 path: path.clone(),
2840 top_author,
2841 top_author_commits: top_commits,
2842 total_commits,
2843 author_count: by_author.len(),
2844 bus_factor,
2845 }
2846 })
2847 .collect();
2848 file_ownership.sort_by(|a, b| {
2850 a.bus_factor
2851 .cmp(&b.bus_factor)
2852 .then_with(|| b.total_commits.cmp(&a.total_commits))
2853 .then_with(|| a.path.cmp(&b.path))
2854 });
2855 file_ownership.truncate(20);
2856
2857 let temporal_hotspots = temporal_hotspots(&file_commits, complexity);
2858 let now_unix = std::time::SystemTime::now()
2859 .duration_since(std::time::UNIX_EPOCH)
2860 .map(|dur| dur.as_secs() as i64)
2861 .unwrap_or(0);
2862 let file_ages = file_ages(&file_age_window, now_unix);
2863 let change_coupling = change_coupling(&pair_counts, &file_commits);
2864 let bug_prone_files = bug_prone_files(&file_bug_fix_commits, &file_commits);
2865 let edit_risk_files = edit_risk_files(&file_commits, complexity, &file_ownership, test_gap);
2866
2867 EvolutionMetrics {
2868 available: true,
2869 reason: String::new(),
2870 commits_sampled,
2871 changed_files: file_commits.len(),
2872 top_changed_files,
2873 author_count,
2874 top_authors,
2875 file_ownership,
2876 temporal_hotspots,
2877 file_ages,
2878 change_coupling,
2879 bug_fix_commits,
2880 bug_prone_files,
2881 edit_risk_files,
2882 }
2883}
2884
2885fn is_bug_fix_subject(subject: &str) -> bool {
2892 let lower = subject.trim_start().to_ascii_lowercase();
2893 for prefix in ["bugfix", "hotfix", "revert", "fix"] {
2894 if let Some(rest) = lower.strip_prefix(prefix) {
2895 let next = rest.chars().next().unwrap_or(' ');
2896 if next == ':' || next == '!' || next == '(' || next.is_whitespace() {
2897 return true;
2898 }
2899 }
2900 }
2901 false
2902}
2903
2904fn edit_risk_files(
2908 file_commits: &BTreeMap<String, usize>,
2909 complexity: &ComplexityMetrics,
2910 file_ownership: &[EvolutionFileOwnership],
2911 test_gap: &TestGapMetrics,
2912) -> Vec<EvolutionEditRiskFile> {
2913 if file_commits.is_empty() || complexity.all_functions.is_empty() {
2914 return Vec::new();
2915 }
2916
2917 let mut max_complexity_per_file: HashMap<&str, usize> = HashMap::new();
2918 for func in &complexity.all_functions {
2919 let entry = max_complexity_per_file
2920 .entry(func.path.as_str())
2921 .or_default();
2922 if func.value > *entry {
2923 *entry = func.value;
2924 }
2925 }
2926
2927 let bus_factor_by_path: HashMap<&str, usize> = file_ownership
2928 .iter()
2929 .map(|entry| (entry.path.as_str(), entry.bus_factor))
2930 .collect();
2931
2932 let test_gap_paths: HashSet<&str> = test_gap
2933 .candidates
2934 .iter()
2935 .map(|candidate| candidate.path.as_str())
2936 .collect();
2937
2938 let mut entries: Vec<EvolutionEditRiskFile> = file_commits
2939 .iter()
2940 .filter_map(|(path, commits)| {
2941 let max_cc = max_complexity_per_file.get(path.as_str()).copied()?;
2942 if *commits == 0 || max_cc == 0 {
2943 return None;
2944 }
2945 let bus_factor = bus_factor_by_path
2947 .get(path.as_str())
2948 .copied()
2949 .unwrap_or(1)
2950 .max(1);
2951 let bus_inverse = 1.0 + (1.0 / bus_factor as f64);
2952 let has_nearby_tests = !test_gap_paths.contains(path.as_str());
2953 let test_gap_factor = if has_nearby_tests { 1.0 } else { 1.5 };
2954 let risk_score = (*commits as f64) * (max_cc as f64) * bus_inverse * test_gap_factor;
2955 Some(EvolutionEditRiskFile {
2956 path: path.clone(),
2957 commits: *commits,
2958 max_complexity: max_cc,
2959 bus_factor,
2960 has_nearby_tests,
2961 risk_score,
2962 })
2963 })
2964 .collect();
2965
2966 entries.sort_by(|a, b| {
2967 b.risk_score
2968 .partial_cmp(&a.risk_score)
2969 .unwrap_or(std::cmp::Ordering::Equal)
2970 .then_with(|| b.commits.cmp(&a.commits))
2971 .then_with(|| a.path.cmp(&b.path))
2972 });
2973 entries.truncate(20);
2974 entries
2975}
2976
2977fn bug_prone_files(
2981 file_bug_fix_commits: &BTreeMap<String, usize>,
2982 file_commits: &BTreeMap<String, usize>,
2983) -> Vec<EvolutionBugProneFile> {
2984 let mut entries: Vec<EvolutionBugProneFile> = file_bug_fix_commits
2985 .iter()
2986 .filter(|(_, count)| **count > 0)
2987 .map(|(path, bug_fix_commits)| {
2988 let total_commits = file_commits.get(path).copied().unwrap_or(*bug_fix_commits);
2989 let bug_fix_ratio = if total_commits == 0 {
2990 0.0
2991 } else {
2992 *bug_fix_commits as f64 / total_commits as f64
2993 };
2994 EvolutionBugProneFile {
2995 path: path.clone(),
2996 bug_fix_commits: *bug_fix_commits,
2997 total_commits,
2998 bug_fix_ratio,
2999 }
3000 })
3001 .collect();
3002 entries.sort_by(|a, b| {
3003 b.bug_fix_commits
3004 .cmp(&a.bug_fix_commits)
3005 .then_with(|| {
3006 b.bug_fix_ratio
3007 .partial_cmp(&a.bug_fix_ratio)
3008 .unwrap_or(std::cmp::Ordering::Equal)
3009 })
3010 .then_with(|| a.path.cmp(&b.path))
3011 });
3012 entries.truncate(20);
3013 entries
3014}
3015
3016fn change_coupling(
3020 pair_counts: &BTreeMap<(String, String), usize>,
3021 file_commits: &BTreeMap<String, usize>,
3022) -> Vec<EvolutionChangeCoupling> {
3023 const MIN_CO_COMMITS: usize = 3;
3024 let mut pairs: Vec<EvolutionChangeCoupling> = pair_counts
3025 .iter()
3026 .filter_map(|((a, b), count)| {
3027 if *count < MIN_CO_COMMITS {
3028 return None;
3029 }
3030 let count_a = file_commits.get(a).copied().unwrap_or(0);
3031 let count_b = file_commits.get(b).copied().unwrap_or(0);
3032 let union = count_a + count_b - count;
3033 if union == 0 {
3034 return None;
3035 }
3036 let strength = (*count as f64) / (union as f64);
3037 Some(EvolutionChangeCoupling {
3038 left: a.clone(),
3039 right: b.clone(),
3040 co_commits: *count,
3041 coupling_strength: round3(strength),
3042 })
3043 })
3044 .collect();
3045 pairs.sort_by(|a, b| {
3046 b.coupling_strength
3047 .partial_cmp(&a.coupling_strength)
3048 .unwrap_or(std::cmp::Ordering::Equal)
3049 .then_with(|| b.co_commits.cmp(&a.co_commits))
3050 .then_with(|| a.left.cmp(&b.left))
3051 .then_with(|| a.right.cmp(&b.right))
3052 });
3053 pairs.truncate(20);
3054 pairs
3055}
3056
3057fn file_ages(window: &BTreeMap<String, (i64, i64)>, now_unix: i64) -> Vec<EvolutionFileAge> {
3061 if window.is_empty() || now_unix <= 0 {
3062 return Vec::new();
3063 }
3064 const SECONDS_PER_DAY: i64 = 86_400;
3065 let mut ages: Vec<EvolutionFileAge> = window
3066 .iter()
3067 .filter_map(|(path, (first, last))| {
3068 if *first <= 0 || *last <= 0 || *first > now_unix {
3069 return None;
3070 }
3071 let age_days = ((now_unix - *first).max(0) / SECONDS_PER_DAY) as u64;
3072 let last_changed_days = ((now_unix - *last).max(0) / SECONDS_PER_DAY) as u64;
3073 Some(EvolutionFileAge {
3074 path: path.clone(),
3075 first_commit_unix: *first,
3076 last_commit_unix: *last,
3077 age_days,
3078 last_changed_days,
3079 })
3080 })
3081 .collect();
3082 ages.sort_by(|a, b| {
3083 b.age_days
3084 .cmp(&a.age_days)
3085 .then_with(|| b.last_changed_days.cmp(&a.last_changed_days))
3086 .then_with(|| a.path.cmp(&b.path))
3087 });
3088 ages.truncate(20);
3089 ages
3090}
3091
3092fn temporal_hotspots(
3096 file_commits: &BTreeMap<String, usize>,
3097 complexity: &ComplexityMetrics,
3098) -> Vec<EvolutionTemporalHotspot> {
3099 if file_commits.is_empty() || complexity.all_functions.is_empty() {
3100 return Vec::new();
3101 }
3102
3103 let mut max_complexity_per_file: HashMap<&str, usize> = HashMap::new();
3104 for func in &complexity.all_functions {
3105 let entry = max_complexity_per_file
3106 .entry(func.path.as_str())
3107 .or_default();
3108 if func.value > *entry {
3109 *entry = func.value;
3110 }
3111 }
3112
3113 let mut hotspots: Vec<EvolutionTemporalHotspot> = file_commits
3114 .iter()
3115 .filter_map(|(path, commits)| {
3116 let max_cc = max_complexity_per_file.get(path.as_str()).copied()?;
3117 let risk = commits.saturating_mul(max_cc);
3118 if risk == 0 {
3119 return None;
3120 }
3121 Some(EvolutionTemporalHotspot {
3122 path: path.clone(),
3123 commits: *commits,
3124 max_complexity: max_cc,
3125 risk_score: risk,
3126 })
3127 })
3128 .collect();
3129
3130 hotspots.sort_by(|a, b| {
3131 b.risk_score
3132 .cmp(&a.risk_score)
3133 .then_with(|| b.commits.cmp(&a.commits))
3134 .then_with(|| a.path.cmp(&b.path))
3135 });
3136 hotspots.truncate(10);
3137 hotspots
3138}
3139
3140fn bus_factor_for(sorted: &[(&String, &usize)], total: usize) -> usize {
3141 if total == 0 {
3142 return 0;
3143 }
3144 let target = (total as f64 * 0.8).ceil() as usize;
3145 let mut covered = 0usize;
3146 for (idx, (_, commits)) in sorted.iter().enumerate() {
3147 covered += **commits;
3148 if covered >= target {
3149 return idx + 1;
3150 }
3151 }
3152 sorted.len().max(1)
3153}
3154
3155const MAX_FILES_PER_COMMIT_FOR_COUPLING: usize = 50;
3159
3160#[allow(clippy::too_many_arguments)]
3161fn flush_commit_files_with_author(
3162 file_commits: &mut BTreeMap<String, usize>,
3163 file_bug_fix_commits: &mut BTreeMap<String, usize>,
3164 file_author_commits: &mut BTreeMap<String, BTreeMap<String, usize>>,
3165 file_age_window: &mut BTreeMap<String, (i64, i64)>,
3166 pair_counts: &mut BTreeMap<(String, String), usize>,
3167 commit_files: &mut HashSet<String>,
3168 author: Option<&str>,
3169 timestamp: Option<i64>,
3170 is_bug_fix: bool,
3171) {
3172 if commit_files.len() <= MAX_FILES_PER_COMMIT_FOR_COUPLING {
3173 let sorted: Vec<&String> = {
3174 let mut v: Vec<&String> = commit_files.iter().collect();
3175 v.sort();
3176 v
3177 };
3178 for i in 0..sorted.len() {
3179 for j in (i + 1)..sorted.len() {
3180 let key = (sorted[i].clone(), sorted[j].clone());
3181 *pair_counts.entry(key).or_default() += 1;
3182 }
3183 }
3184 }
3185 for path in commit_files.drain() {
3186 *file_commits.entry(path.clone()).or_default() += 1;
3187 if is_bug_fix {
3188 *file_bug_fix_commits.entry(path.clone()).or_default() += 1;
3189 }
3190 if let Some(author) = author {
3191 if !author.is_empty() {
3192 *file_author_commits
3193 .entry(path.clone())
3194 .or_default()
3195 .entry(author.to_string())
3196 .or_default() += 1;
3197 }
3198 }
3199 if let Some(ts) = timestamp {
3200 file_age_window
3201 .entry(path)
3202 .and_modify(|(first, last)| {
3203 if ts < *first {
3204 *first = ts;
3205 }
3206 if ts > *last {
3207 *last = ts;
3208 }
3209 })
3210 .or_insert((ts, ts));
3211 }
3212 }
3213}
3214
3215fn trend_metrics(report: &ScanReport) -> TrendMetrics {
3216 let path = report.snapshot.root.join(".raysense/trends/history.json");
3217 let Ok(content) = fs::read_to_string(&path) else {
3218 return TrendMetrics::default();
3219 };
3220 let Ok(samples) = serde_json::from_str::<Vec<TrendSample>>(&content) else {
3221 return TrendMetrics::default();
3222 };
3223 let (Some(first), Some(last)) = (samples.first(), samples.last()) else {
3224 return TrendMetrics::default();
3225 };
3226
3227 let mut dimension_deltas = BTreeMap::new();
3232 if has_root_causes(&first.root_causes) && has_root_causes(&last.root_causes) {
3233 let pairs: [(&str, f64, f64); 6] = [
3234 (
3235 "modularity",
3236 first.root_causes.modularity,
3237 last.root_causes.modularity,
3238 ),
3239 (
3240 "acyclicity",
3241 first.root_causes.acyclicity,
3242 last.root_causes.acyclicity,
3243 ),
3244 ("depth", first.root_causes.depth, last.root_causes.depth),
3245 (
3246 "equality",
3247 first.root_causes.equality,
3248 last.root_causes.equality,
3249 ),
3250 (
3251 "redundancy",
3252 first.root_causes.redundancy,
3253 last.root_causes.redundancy,
3254 ),
3255 (
3256 "structural_uniformity",
3257 first.root_causes.structural_uniformity,
3258 last.root_causes.structural_uniformity,
3259 ),
3260 ];
3261 for (name, before, after) in pairs {
3262 dimension_deltas.insert(name.to_string(), round3(after - before));
3263 }
3264 }
3265
3266 TrendMetrics {
3267 available: true,
3268 samples: samples.len(),
3269 score_delta: last.score as i16 - first.score as i16,
3270 quality_signal_delta: last.quality_signal as i32 - first.quality_signal as i32,
3271 rule_delta: last.rules as isize - first.rules as isize,
3272 dimension_deltas,
3273 series: samples,
3274 }
3275}
3276
3277fn has_root_causes(scores: &RootCauseScores) -> bool {
3278 scores.modularity != 0.0
3279 || scores.acyclicity != 0.0
3280 || scores.depth != 0.0
3281 || scores.equality != 0.0
3282 || scores.redundancy != 0.0
3283 || scores.structural_uniformity != 0.0
3284}
3285
3286fn scan_relative_git_path(path: &str, prefix: &str) -> Option<String> {
3287 let path = path.replace('\\', "/");
3288 if prefix.is_empty() {
3289 return Some(path);
3290 }
3291 path.strip_prefix(prefix)
3292 .map(|path| path.trim_start_matches('/').to_string())
3293 .filter(|path| !path.is_empty())
3294}
3295
3296fn git_output<const N: usize>(root: &Path, args: [&str; N]) -> Result<String, String> {
3297 let output = Command::new("git")
3298 .arg("-C")
3299 .arg(root)
3300 .args(args)
3301 .output()
3302 .map_err(|error| format!("failed to run git: {error}"))?;
3303
3304 if !output.status.success() {
3305 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
3306 if stderr.is_empty() {
3307 return Err(format!("git exited with status {}", output.status));
3308 }
3309 return Err(stderr);
3310 }
3311
3312 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
3313}
3314
3315fn rules(
3316 report: &ScanReport,
3317 hotspots: &[FileHotspot],
3318 metrics: &MetricsSummary,
3319 config: &RaysenseConfig,
3320) -> Vec<RuleFinding> {
3321 let mut findings = Vec::new();
3322 let rules = &config.rules;
3323 let root_causes = root_causes(report, metrics);
3324 let quality_signal = quality_signal(&root_causes, &config.score);
3325
3326 if rules.min_quality_signal > 0 && quality_signal < rules.min_quality_signal {
3327 findings.push(RuleFinding {
3328 severity: RuleSeverity::Error,
3329 code: "min_quality_signal".to_string(),
3330 path: report.snapshot.root.to_string_lossy().into_owned(),
3331 message: format!(
3332 "quality signal {} below minimum {}",
3333 quality_signal, rules.min_quality_signal
3334 ),
3335 });
3336 }
3337
3338 push_min_score_finding(
3339 &mut findings,
3340 report,
3341 "min_modularity",
3342 "modularity",
3343 root_causes.modularity,
3344 rules.min_modularity,
3345 );
3346 push_min_score_finding(
3347 &mut findings,
3348 report,
3349 "min_acyclicity",
3350 "acyclicity",
3351 root_causes.acyclicity,
3352 rules.min_acyclicity,
3353 );
3354 push_min_score_finding(
3355 &mut findings,
3356 report,
3357 "min_depth",
3358 "depth",
3359 root_causes.depth,
3360 rules.min_depth,
3361 );
3362 push_min_score_finding(
3363 &mut findings,
3364 report,
3365 "min_equality",
3366 "equality",
3367 root_causes.equality,
3368 rules.min_equality,
3369 );
3370 push_min_score_finding(
3371 &mut findings,
3372 report,
3373 "min_redundancy",
3374 "redundancy",
3375 root_causes.redundancy,
3376 rules.min_redundancy,
3377 );
3378
3379 if report.graph.cycle_count > rules.max_cycles {
3380 findings.push(RuleFinding {
3381 severity: RuleSeverity::Error,
3382 code: "max_cycles".to_string(),
3383 path: report.snapshot.root.to_string_lossy().into_owned(),
3384 message: format!(
3385 "{} cycle participants exceeds max {}",
3386 report.graph.cycle_count, rules.max_cycles
3387 ),
3388 });
3389 }
3390
3391 if metrics.coupling.cross_module_ratio > rules.max_coupling_ratio {
3392 findings.push(RuleFinding {
3393 severity: RuleSeverity::Warning,
3394 code: "max_coupling".to_string(),
3395 path: report.snapshot.root.to_string_lossy().into_owned(),
3396 message: format!(
3397 "cross-module ratio {:.3} exceeds max {:.3}",
3398 metrics.coupling.cross_module_ratio, rules.max_coupling_ratio
3399 ),
3400 });
3401 }
3402
3403 for function in metrics
3404 .complexity
3405 .all_functions
3406 .iter()
3407 .filter(|function| {
3408 let threshold = report
3409 .files
3410 .get(function.file_id)
3411 .map(|file| function_complexity_limit(file, config))
3412 .unwrap_or(rules.max_function_complexity);
3413 function.value > threshold
3414 })
3415 .take(rules.max_call_hotspot_findings.max(1))
3416 {
3417 let threshold = report
3418 .files
3419 .get(function.file_id)
3420 .map(|file| function_complexity_limit(file, config))
3421 .unwrap_or(rules.max_function_complexity);
3422 findings.push(RuleFinding {
3423 severity: RuleSeverity::Warning,
3424 code: "max_function_complexity".to_string(),
3425 path: function.path.clone(),
3426 message: format!(
3427 "{} complexity {} exceeds max {}",
3428 function.name, function.value, threshold
3429 ),
3430 });
3431 }
3432
3433 for function in metrics
3434 .complexity
3435 .all_functions
3436 .iter()
3437 .filter(|function| {
3438 let threshold = report
3439 .files
3440 .get(function.file_id)
3441 .map(|file| cognitive_complexity_limit(file, config))
3442 .unwrap_or(rules.max_cognitive_complexity);
3443 threshold > 0 && function.cognitive_value > threshold
3444 })
3445 .take(rules.max_call_hotspot_findings.max(1))
3446 {
3447 let threshold = report
3448 .files
3449 .get(function.file_id)
3450 .map(|file| cognitive_complexity_limit(file, config))
3451 .unwrap_or(rules.max_cognitive_complexity);
3452 findings.push(RuleFinding {
3453 severity: RuleSeverity::Warning,
3454 code: "max_cognitive_complexity".to_string(),
3455 path: function.path.clone(),
3456 message: format!(
3457 "{} cognitive complexity {} exceeds max {}",
3458 function.name, function.cognitive_value, threshold
3459 ),
3460 });
3461 }
3462
3463 for (file, limit) in report
3464 .files
3465 .iter()
3466 .filter_map(|file| file_line_limit(file, config).map(|limit| (file, limit)))
3467 .filter(|(file, limit)| file.lines > *limit)
3468 .take(rules.max_large_file_findings.max(1))
3469 {
3470 findings.push(RuleFinding {
3471 severity: RuleSeverity::Error,
3472 code: "max_file_lines".to_string(),
3473 path: file.path.to_string_lossy().into_owned(),
3474 message: format!("{} lines exceeds max {}", file.lines, limit),
3475 });
3476 }
3477
3478 for (function, limit) in report
3479 .functions
3480 .iter()
3481 .filter_map(|function| {
3482 let file = report.files.get(function.file_id)?;
3483 function_line_limit(file, config).map(|limit| (function, limit))
3484 })
3485 .filter(|(function, limit)| {
3486 function.end_line.saturating_sub(function.start_line) + 1 > *limit
3487 })
3488 .take(rules.max_call_hotspot_findings.max(1))
3489 {
3490 let path = report
3491 .files
3492 .get(function.file_id)
3493 .map(|file| file.path.to_string_lossy().into_owned())
3494 .unwrap_or_else(|| report.snapshot.root.to_string_lossy().into_owned());
3495 let lines = function.end_line.saturating_sub(function.start_line) + 1;
3496 findings.push(RuleFinding {
3497 severity: RuleSeverity::Error,
3498 code: "max_function_lines".to_string(),
3499 path,
3500 message: format!(
3501 "{} has {} lines exceeding max {}",
3502 function.name, lines, limit
3503 ),
3504 });
3505 }
3506
3507 for hotspot in hotspots {
3508 let limit = report
3509 .files
3510 .get(hotspot.file_id)
3511 .map(|file| high_file_fan_in_limit(file, config))
3512 .unwrap_or(rules.high_file_fan_in);
3513 if hotspot.fan_in >= limit {
3514 findings.push(RuleFinding {
3515 severity: if rules.no_god_files {
3516 RuleSeverity::Warning
3517 } else {
3518 RuleSeverity::Info
3519 },
3520 code: "high_fan_in".to_string(),
3521 path: hotspot.path.clone(),
3522 message: format!("{} incoming dependency edges", hotspot.fan_in),
3523 });
3524 }
3525 }
3526
3527 if rules.no_god_files {
3528 for file in &metrics.coupling.god_files {
3529 findings.push(RuleFinding {
3530 severity: RuleSeverity::Warning,
3531 code: "no_god_files".to_string(),
3532 path: file.path.clone(),
3533 message: format!("{} outgoing dependency edges", file.fan_out),
3534 });
3535 }
3536 }
3537
3538 for import in &report.imports {
3539 let Some(to_file_id) = import.resolved_file else {
3540 continue;
3541 };
3542 if to_file_id == import.from_file {
3543 continue;
3544 }
3545 let Some(from_file) = report.files.get(import.from_file) else {
3546 continue;
3547 };
3548 let Some(to_file) = report.files.get(to_file_id) else {
3549 continue;
3550 };
3551
3552 let from_path = from_file.path.to_string_lossy();
3553 let to_path = to_file.path.to_string_lossy();
3554 if !is_test_path(&from_path) && is_test_path(&to_path) {
3555 findings.push(RuleFinding {
3556 severity: RuleSeverity::Warning,
3557 code: "production_depends_on_test".to_string(),
3558 path: from_path.into_owned(),
3559 message: format!("depends on test path {to_path}"),
3560 });
3561 }
3562 }
3563
3564 let mut large_files: Vec<_> = report
3565 .files
3566 .iter()
3567 .filter(|file| file.lines >= large_file_lines_limit(file, config))
3568 .collect();
3569 large_files.sort_by(|a, b| b.lines.cmp(&a.lines).then_with(|| a.path.cmp(&b.path)));
3570
3571 for file in large_files.iter().take(rules.max_large_file_findings) {
3572 findings.push(RuleFinding {
3573 severity: RuleSeverity::Info,
3574 code: "large_file".to_string(),
3575 path: file.path.to_string_lossy().into_owned(),
3576 message: format!("{} lines", file.lines),
3577 });
3578 }
3579 if large_files.len() > rules.max_large_file_findings {
3580 findings.push(RuleFinding {
3581 severity: RuleSeverity::Info,
3582 code: "large_file_summary".to_string(),
3583 path: report.snapshot.root.to_string_lossy().into_owned(),
3584 message: format!(
3585 "{} additional large files",
3586 large_files.len() - rules.max_large_file_findings
3587 ),
3588 });
3589 }
3590
3591 if rules.no_tests_detected
3592 && metrics.test_gap.production_files > 0
3593 && metrics.test_gap.test_files == 0
3594 && report.snapshot.function_count > 0
3595 {
3596 findings.push(RuleFinding {
3597 severity: RuleSeverity::Info,
3598 code: "no_tests_detected".to_string(),
3599 path: report.snapshot.root.to_string_lossy().into_owned(),
3600 message: format!(
3601 "{} production files and no test files detected",
3602 metrics.test_gap.production_files
3603 ),
3604 });
3605 }
3606
3607 if metrics.calls.total_calls >= rules.low_call_resolution_min_calls
3608 && metrics.calls.resolution_ratio < rules.low_call_resolution_ratio
3609 {
3610 findings.push(RuleFinding {
3611 severity: RuleSeverity::Info,
3612 code: "low_call_resolution".to_string(),
3613 path: report.snapshot.root.to_string_lossy().into_owned(),
3614 message: format!(
3615 "{} of {} calls resolved ({:.3})",
3616 metrics.calls.resolved_edges,
3617 metrics.calls.total_calls,
3618 metrics.calls.resolution_ratio
3619 ),
3620 });
3621 }
3622
3623 let function_threshold_in = |function: &FunctionCallMetric| -> usize {
3624 report
3625 .files
3626 .get(function.file_id)
3627 .map(|file| high_function_fan_in_limit(file, config))
3628 .unwrap_or(rules.high_function_fan_in)
3629 };
3630 let function_threshold_out = |function: &FunctionCallMetric| -> usize {
3631 report
3632 .files
3633 .get(function.file_id)
3634 .map(|file| high_function_fan_out_limit(file, config))
3635 .unwrap_or(rules.high_function_fan_out)
3636 };
3637 for function in metrics
3638 .calls
3639 .top_called_functions
3640 .iter()
3641 .filter(|function| function.calls >= function_threshold_in(function))
3642 .take(rules.max_call_hotspot_findings)
3643 {
3644 findings.push(RuleFinding {
3645 severity: RuleSeverity::Info,
3646 code: "high_function_fan_in".to_string(),
3647 path: function.path.clone(),
3648 message: format!(
3649 "{} has {} resolved incoming calls",
3650 function.name, function.calls
3651 ),
3652 });
3653 }
3654
3655 for function in metrics
3656 .calls
3657 .top_calling_functions
3658 .iter()
3659 .filter(|function| function.calls >= function_threshold_out(function))
3660 .take(rules.max_call_hotspot_findings)
3661 {
3662 findings.push(RuleFinding {
3663 severity: RuleSeverity::Info,
3664 code: "high_function_fan_out".to_string(),
3665 path: function.path.clone(),
3666 message: format!(
3667 "{} has {} resolved outgoing calls",
3668 function.name, function.calls
3669 ),
3670 });
3671 }
3672
3673 findings.extend(boundary_findings(report, &config.boundaries));
3674 let layer_findings = layer_findings(report, &config.boundaries);
3675 if rules.max_upward_layer_violations > 0
3676 && layer_findings.len() > rules.max_upward_layer_violations
3677 {
3678 findings.push(RuleFinding {
3679 severity: RuleSeverity::Error,
3680 code: "max_upward_layer_violations".to_string(),
3681 path: report.snapshot.root.to_string_lossy().into_owned(),
3682 message: format!(
3683 "{} upward layer violations exceeds max {}",
3684 layer_findings.len(),
3685 rules.max_upward_layer_violations
3686 ),
3687 });
3688 }
3689 findings.extend(layer_findings);
3690
3691 findings
3692}
3693
3694fn language_override_for<'a>(
3695 file: &FileFact,
3696 config: &'a RaysenseConfig,
3697) -> Option<&'a LanguageRuleOverride> {
3698 config
3699 .rules
3700 .language_overrides
3701 .iter()
3702 .find(|(name, _)| name.eq_ignore_ascii_case(&file.language_name))
3703 .map(|(_, value)| value)
3704}
3705
3706fn function_complexity_limit(file: &FileFact, config: &RaysenseConfig) -> usize {
3707 language_override_for(file, config)
3708 .and_then(|o| o.max_function_complexity)
3709 .or_else(|| plugin_for_file(file, config).and_then(|plugin| plugin.max_function_complexity))
3710 .unwrap_or(config.rules.max_function_complexity)
3711}
3712
3713fn cognitive_complexity_limit(file: &FileFact, config: &RaysenseConfig) -> usize {
3714 language_override_for(file, config)
3715 .and_then(|o| o.max_cognitive_complexity)
3716 .or_else(|| {
3717 plugin_for_file(file, config).and_then(|plugin| plugin.max_cognitive_complexity)
3718 })
3719 .unwrap_or(config.rules.max_cognitive_complexity)
3720}
3721
3722fn file_line_limit(file: &FileFact, config: &RaysenseConfig) -> Option<usize> {
3723 language_override_for(file, config)
3724 .and_then(|o| o.max_file_lines)
3725 .or_else(|| plugin_for_file(file, config).and_then(|plugin| plugin.max_file_lines))
3726 .or_else(|| (config.rules.max_file_lines > 0).then_some(config.rules.max_file_lines))
3727}
3728
3729fn function_line_limit(file: &FileFact, config: &RaysenseConfig) -> Option<usize> {
3730 language_override_for(file, config)
3731 .and_then(|o| o.max_function_lines)
3732 .or_else(|| plugin_for_file(file, config).and_then(|plugin| plugin.max_function_lines))
3733 .or_else(|| {
3734 (config.rules.max_function_lines > 0).then_some(config.rules.max_function_lines)
3735 })
3736}
3737
3738fn high_file_fan_in_limit(file: &FileFact, config: &RaysenseConfig) -> usize {
3739 language_override_for(file, config)
3740 .and_then(|o| o.high_file_fan_in)
3741 .unwrap_or(config.rules.high_file_fan_in)
3742}
3743
3744fn high_file_fan_out_limit(file: &FileFact, config: &RaysenseConfig) -> usize {
3745 language_override_for(file, config)
3746 .and_then(|o| o.high_file_fan_out)
3747 .unwrap_or(config.rules.high_file_fan_out)
3748}
3749
3750fn large_file_lines_limit(file: &FileFact, config: &RaysenseConfig) -> usize {
3751 language_override_for(file, config)
3752 .and_then(|o| o.large_file_lines)
3753 .unwrap_or(config.rules.large_file_lines)
3754}
3755
3756fn high_function_fan_in_limit(file: &FileFact, config: &RaysenseConfig) -> usize {
3757 language_override_for(file, config)
3758 .and_then(|o| o.high_function_fan_in)
3759 .unwrap_or(config.rules.high_function_fan_in)
3760}
3761
3762fn high_function_fan_out_limit(file: &FileFact, config: &RaysenseConfig) -> usize {
3763 language_override_for(file, config)
3764 .and_then(|o| o.high_function_fan_out)
3765 .unwrap_or(config.rules.high_function_fan_out)
3766}
3767
3768fn plugin_for_file<'a>(
3769 file: &FileFact,
3770 config: &'a RaysenseConfig,
3771) -> Option<&'a LanguagePluginConfig> {
3772 config
3773 .scan
3774 .plugins
3775 .iter()
3776 .find(|plugin| plugin.name.eq_ignore_ascii_case(&file.language_name))
3777}
3778
3779fn push_min_score_finding(
3780 findings: &mut Vec<RuleFinding>,
3781 report: &ScanReport,
3782 code: &str,
3783 label: &str,
3784 value: f64,
3785 min: f64,
3786) {
3787 if min > 0.0 && value < min {
3788 findings.push(RuleFinding {
3789 severity: RuleSeverity::Error,
3790 code: code.to_string(),
3791 path: report.snapshot.root.to_string_lossy().into_owned(),
3792 message: format!("{label} score {value:.3} below minimum {min:.3}"),
3793 });
3794 }
3795}
3796
3797fn remediations(rules: &[RuleFinding], metrics: &MetricsSummary) -> Vec<Remediation> {
3798 let mut out = Vec::new();
3799 for rule in rules {
3800 let action = match rule.code.as_str() {
3801 "min_quality_signal" => {
3802 "inspect the lowest root-cause score and fix the matching structural bottleneck"
3803 }
3804 "min_modularity" => "reduce cross-module edges or regroup files by cohesive module",
3805 "min_acyclicity" => "remove dependency cycles by introducing a lower-level interface",
3806 "min_depth" => "flatten long dependency chains or invert unnecessary layers",
3807 "min_equality" => {
3808 "split oversized files/functions and rebalance concentrated complexity"
3809 }
3810 "min_redundancy" => "remove dead functions or consolidate duplicated implementations",
3811 "max_file_lines" => "split the file into smaller cohesive modules",
3812 "max_function_lines" => "extract helpers or split the function into smaller steps",
3813 "max_function_complexity" => {
3814 "split the function, extract decision branches, or add a local policy override"
3815 }
3816 "max_cognitive_complexity" => {
3817 "flatten nesting, return early, or extract nested decision branches"
3818 }
3819 "high_fan_in" => "introduce a facade boundary or split shared responsibilities",
3820 "production_depends_on_test" => {
3821 "move shared fixtures into a production-safe support module"
3822 }
3823 "large_file" => "split file by cohesive type, operation, or module boundary",
3824 "no_tests_detected" => "add first tests at the expected test-gap paths",
3825 "low_call_resolution" => {
3826 "add language plugin patterns or enable a grammar-backed scanner"
3827 }
3828 "layer_order" => "invert the dependency or update ordered layer config",
3829 "max_upward_layer_violations" => {
3830 "remove upward layer dependencies or adjust layer ordering"
3831 }
3832 "max_cycles" => {
3833 "break one dependency edge in each cycle or configure an allowed boundary"
3834 }
3835 _ => "inspect the finding and tune policy or architecture",
3836 };
3837 out.push(Remediation {
3838 code: rule.code.clone(),
3839 path: rule.path.clone(),
3840 action: action.to_string(),
3841 command: format!("raysense check {} --json", shell_path(&rule.path)),
3842 });
3843 }
3844 for gap in metrics.test_gap.candidates.iter().take(10) {
3845 if let Some(path) = gap.expected_tests.first() {
3846 out.push(Remediation {
3847 code: "test_gap".to_string(),
3848 path: gap.path.clone(),
3849 action: format!("add a {} test for {}", gap.framework, gap.path),
3850 command: format!(
3851 "mkdir -p {} && touch {}",
3852 parent_path(path),
3853 shell_path(path)
3854 ),
3855 });
3856 }
3857 }
3858 out.truncate(50);
3859 out
3860}
3861
3862fn shell_path(path: &str) -> String {
3863 if path.contains(' ') {
3864 format!("'{}'", path.replace('\'', "'\\''"))
3865 } else {
3866 path.to_string()
3867 }
3868}
3869
3870fn parent_path(path: &str) -> String {
3871 Path::new(path)
3872 .parent()
3873 .map(|path| shell_path(&path.to_string_lossy()))
3874 .unwrap_or_else(|| ".".to_string())
3875}
3876
3877fn boundary_findings(report: &ScanReport, config: &BoundaryConfig) -> Vec<RuleFinding> {
3878 if config.forbidden_edges.is_empty() {
3879 return Vec::new();
3880 }
3881
3882 let forbidden: HashSet<(&str, &str)> = config
3883 .forbidden_edges
3884 .iter()
3885 .map(|edge| (edge.from.as_str(), edge.to.as_str()))
3886 .collect();
3887 let reasons: BTreeMap<(&str, &str), &str> = config
3888 .forbidden_edges
3889 .iter()
3890 .map(|edge| ((edge.from.as_str(), edge.to.as_str()), edge.reason.as_str()))
3891 .collect();
3892 let mut edges: BTreeMap<(String, String), (usize, String)> = BTreeMap::new();
3893
3894 for import in &report.imports {
3895 let Some(to_file_id) = import.resolved_file else {
3896 continue;
3897 };
3898 if to_file_id == import.from_file {
3899 continue;
3900 }
3901 let Some(from_file) = report.files.get(import.from_file) else {
3902 continue;
3903 };
3904 let Some(to_file) = report.files.get(to_file_id) else {
3905 continue;
3906 };
3907 let from_module = top_module(&from_file.module);
3908 let to_module = top_module(&to_file.module);
3909 if forbidden.contains(&(from_module, to_module)) {
3910 let reason = reasons
3911 .get(&(from_module, to_module))
3912 .copied()
3913 .unwrap_or_default()
3914 .to_string();
3915 edges
3916 .entry((from_module.to_string(), to_module.to_string()))
3917 .and_modify(|entry| entry.0 += 1)
3918 .or_insert((1, reason));
3919 }
3920 }
3921
3922 edges
3923 .into_iter()
3924 .map(|((from_module, to_module), (count, reason))| {
3925 let reason = reason.trim();
3926 let message = if reason.is_empty() {
3927 format!("{from_module} -> {to_module} has {count} dependency edges")
3928 } else {
3929 format!("{from_module} -> {to_module} has {count} dependency edges: {reason}")
3930 };
3931 RuleFinding {
3932 severity: RuleSeverity::Warning,
3933 code: "forbidden_module_edge".to_string(),
3934 path: report.snapshot.root.to_string_lossy().into_owned(),
3935 message,
3936 }
3937 })
3938 .collect()
3939}
3940
3941fn layer_findings(report: &ScanReport, config: &BoundaryConfig) -> Vec<RuleFinding> {
3942 if config.layers.is_empty() {
3943 return Vec::new();
3944 }
3945
3946 let mut findings = Vec::new();
3947 for import in &report.imports {
3948 let Some(to_file_id) = import.resolved_file else {
3949 continue;
3950 };
3951 if to_file_id == import.from_file {
3952 continue;
3953 }
3954 let Some(from_file) = report.files.get(import.from_file) else {
3955 continue;
3956 };
3957 let Some(to_file) = report.files.get(to_file_id) else {
3958 continue;
3959 };
3960 let from_path = normalize_rule_path(&from_file.path);
3961 let to_path = normalize_rule_path(&to_file.path);
3962 let Some(from_layer) = matching_layer(&from_path, &config.layers) else {
3963 continue;
3964 };
3965 let Some(to_layer) = matching_layer(&to_path, &config.layers) else {
3966 continue;
3967 };
3968 if from_layer.order < to_layer.order {
3969 findings.push(RuleFinding {
3970 severity: RuleSeverity::Warning,
3971 code: "layer_order".to_string(),
3972 path: from_path,
3973 message: format!(
3974 "{} depends upward on {} through {}",
3975 from_layer.name, to_layer.name, to_path
3976 ),
3977 });
3978 }
3979 }
3980 findings
3981}
3982
3983fn matching_layer<'a>(path: &str, layers: &'a [LayerConfig]) -> Option<&'a LayerConfig> {
3984 layers
3985 .iter()
3986 .filter(|layer| path_matches_rule(path, &layer.path))
3987 .max_by_key(|layer| layer.path.len())
3988}
3989
3990fn path_matches_rule(path: &str, pattern: &str) -> bool {
3991 let pattern = pattern.trim_matches('/');
3992 if let Some(prefix) = pattern.strip_suffix("/*") {
3993 path == prefix || path.starts_with(&format!("{prefix}/"))
3994 } else {
3995 path == pattern || path.starts_with(&format!("{pattern}/"))
3996 }
3997}
3998
3999fn normalize_rule_path(path: &Path) -> String {
4000 path.to_string_lossy().replace('\\', "/")
4001}
4002
4003fn module_group(file: &crate::facts::FileFact, config: &RaysenseConfig) -> String {
4004 let path = normalize_rule_path(&file.path);
4005 if let Some(group) = ecosystem_module_group(&path, &file.language_name) {
4006 return group;
4007 }
4008 for root in &config.scan.module_roots {
4009 let root = root.trim_matches('/');
4010 if root.is_empty() {
4011 continue;
4012 }
4013 if path == root || path.starts_with(&format!("{root}/")) {
4014 let rest = path.trim_start_matches(root).trim_start_matches('/');
4015 let next = rest.split('/').next().unwrap_or("");
4016 return if next.is_empty() {
4017 root.to_string()
4018 } else {
4019 format!("{root}/{next}")
4020 };
4021 }
4022 }
4023 for layer in &config.boundaries.layers {
4024 if path_matches_rule(&path, &layer.path) {
4025 return layer.name.clone();
4026 }
4027 }
4028 top_module(&file.module).to_string()
4029}
4030
4031fn ecosystem_module_group(path: &str, language: &str) -> Option<String> {
4032 let parts = path.split('/').collect::<Vec<_>>();
4033 if parts.len() >= 3 && parts[0] == "crates" {
4034 return Some(format!("crates/{}", parts[1]));
4035 }
4036 if parts.len() >= 3 && parts[0] == "packages" {
4037 return Some(format!("packages/{}", parts[1]));
4038 }
4039 if parts.len() >= 3 && parts[0] == "apps" {
4040 return Some(format!("apps/{}", parts[1]));
4041 }
4042 match language {
4043 "go" if parts.len() >= 2 => Some(parts[..parts.len().saturating_sub(1)].join("/")),
4044 "python" if parts.len() >= 2 && parts[0] == "src" => {
4045 parts.get(1).map(|item| (*item).to_string())
4046 }
4047 "java" | "kotlin" if parts.iter().any(|part| *part == "src") => parts
4048 .iter()
4049 .position(|part| *part == "java" || *part == "kotlin")
4050 .and_then(|idx| parts.get(idx + 1))
4051 .map(|item| (*item).to_string()),
4052 _ => None,
4053 }
4054}
4055
4056fn top_module(module: &str) -> &str {
4057 module.split(['.', '/']).next().unwrap_or(module)
4058}
4059
4060fn ratio(numerator: usize, denominator: usize) -> f64 {
4061 if denominator == 0 {
4062 return 0.0;
4063 }
4064 round3(numerator as f64 / denominator as f64)
4065}
4066
4067fn round3(value: f64) -> f64 {
4068 (value * 1000.0).round() / 1000.0
4069}
4070
4071fn is_test_path_configured(path: &str, config: &RaysenseConfig) -> bool {
4072 is_test_path(path) || matches_configured_path(path, &config.scan.test_roots)
4073}
4074
4075fn matches_configured_path(path: &str, patterns: &[String]) -> bool {
4076 patterns
4077 .iter()
4078 .map(|pattern| pattern.trim())
4079 .filter(|pattern| !pattern.is_empty())
4080 .any(|pattern| path_matches_rule(path, pattern))
4081}
4082
4083fn configured_test_roots(config: &RaysenseConfig) -> Vec<String> {
4084 let mut roots = if config.scan.test_roots.is_empty() {
4085 vec!["tests".to_string()]
4086 } else {
4087 config
4088 .scan
4089 .test_roots
4090 .iter()
4091 .map(|root| root.trim().trim_matches('/').to_string())
4092 .filter(|root| !root.is_empty())
4093 .collect()
4094 };
4095 roots.sort();
4096 roots.dedup();
4097 roots
4098}
4099
4100fn is_test_path(path: &str) -> bool {
4101 path.starts_with("test/")
4102 || path.starts_with("tests/")
4103 || path.contains("/test/")
4104 || path.contains("/tests/")
4105 || path.contains("_test.")
4106 || path.contains("_tests.")
4107}
4108
4109#[cfg(test)]
4110mod tests {
4111 use super::*;
4112 use crate::facts::{
4113 CallEdgeFact, CallFact, EntryPointFact, EntryPointKind, FileFact, FunctionFact, ImportFact,
4114 Language, SnapshotFact,
4115 };
4116 use crate::graph::compute_graph_metrics;
4117 use std::fs;
4118 use std::path::PathBuf;
4119 use std::time::{SystemTime, UNIX_EPOCH};
4120
4121 #[test]
4122 fn computes_resolution_breakdown_and_hotspots() {
4123 let files = vec![file(0, "a.rs"), file(1, "b.rs")];
4124 let imports = vec![
4125 import(0, 0, Some(1), ImportResolution::Local),
4126 import(1, 0, None, ImportResolution::External),
4127 import(2, 1, None, ImportResolution::Unresolved),
4128 ];
4129 let graph = compute_graph_metrics(&files, &imports);
4130 let report = ScanReport {
4131 snapshot: SnapshotFact {
4132 snapshot_id: "test".to_string(),
4133 root: PathBuf::from("."),
4134 file_count: files.len(),
4135 function_count: 0,
4136 import_count: imports.len(),
4137 call_count: 0,
4138 },
4139 files,
4140 functions: Vec::new(),
4141 entry_points: Vec::new(),
4142 imports,
4143 calls: Vec::new(),
4144 call_edges: Vec::new(),
4145 types: Vec::new(),
4146 graph,
4147 };
4148
4149 let health = compute_health(&report);
4150
4151 assert_eq!(health.resolution.local, 1);
4152 assert_eq!(health.resolution.external, 1);
4153 assert_eq!(health.resolution.unresolved, 1);
4154 assert_eq!(health.hotspots[0].path, "b.rs");
4155 assert!(health.coverage_score < 100);
4156 assert_eq!(health.structural_score, 100);
4157 assert!(health.score < 100);
4158 }
4159
4160 #[test]
4161 fn discounts_edges_to_stable_foundations() {
4162 let files = vec![
4163 file(0, "src/app/a.rs"),
4164 file(1, "src/app/b.rs"),
4165 file(2, "src/core/types.rs"),
4166 file(3, "src/feature/use_case.rs"),
4167 file(4, "src/infra/adapter.rs"),
4168 ];
4169 let imports = vec![
4170 import(0, 0, Some(2), ImportResolution::Local),
4171 import(1, 1, Some(2), ImportResolution::Local),
4172 import(2, 3, Some(4), ImportResolution::Local),
4173 ];
4174 let graph = compute_graph_metrics(&files, &imports);
4175 let report = ScanReport {
4176 snapshot: SnapshotFact {
4177 snapshot_id: "test".to_string(),
4178 root: PathBuf::from("."),
4179 file_count: files.len(),
4180 function_count: 0,
4181 import_count: imports.len(),
4182 call_count: 0,
4183 },
4184 files,
4185 functions: Vec::new(),
4186 entry_points: Vec::new(),
4187 imports,
4188 calls: Vec::new(),
4189 call_edges: Vec::new(),
4190 types: Vec::new(),
4191 graph,
4192 };
4193
4194 let mut config = RaysenseConfig::default();
4195 config.scan.module_roots = vec!["src".to_string()];
4196 let health = compute_health_with_config(&report, &config);
4197
4198 assert_eq!(health.metrics.coupling.cross_module_edges, 3);
4199 assert_eq!(health.metrics.coupling.cross_unstable_edges, 1);
4200 assert!(health
4201 .metrics
4202 .architecture
4203 .stable_foundations
4204 .iter()
4205 .any(|module| module.module == "src/core"));
4206 assert!(health.root_causes.modularity > 0.6);
4207 }
4208
4209 #[test]
4210 fn reports_non_foundation_blast_radius() {
4211 let files = vec![
4212 file(0, "src/core/types.rs"),
4213 file(1, "src/app1/a.rs"),
4214 file(2, "src/app2/a.rs"),
4215 file(3, "src/app3/a.rs"),
4216 file(4, "src/app4/a.rs"),
4217 file(5, "src/app5/a.rs"),
4218 file(6, "src/app6/a.rs"),
4219 ];
4220 let imports = vec![
4221 import(0, 1, Some(0), ImportResolution::Local),
4222 import(1, 2, Some(0), ImportResolution::Local),
4223 import(2, 3, Some(0), ImportResolution::Local),
4224 import(3, 4, Some(0), ImportResolution::Local),
4225 import(4, 5, Some(0), ImportResolution::Local),
4226 import(5, 6, Some(0), ImportResolution::Local),
4227 ];
4228 let graph = compute_graph_metrics(&files, &imports);
4229 let report = ScanReport {
4230 snapshot: SnapshotFact {
4231 snapshot_id: "test".to_string(),
4232 root: PathBuf::from("."),
4233 file_count: files.len(),
4234 function_count: 0,
4235 import_count: imports.len(),
4236 call_count: 0,
4237 },
4238 files,
4239 functions: Vec::new(),
4240 entry_points: Vec::new(),
4241 imports,
4242 calls: Vec::new(),
4243 call_edges: Vec::new(),
4244 types: Vec::new(),
4245 graph,
4246 };
4247
4248 let mut config = RaysenseConfig::default();
4249 config.scan.module_roots = vec!["src".to_string()];
4250 let health = compute_health_with_config(&report, &config);
4251
4252 assert_eq!(health.metrics.architecture.max_blast_radius, 6);
4253 assert_eq!(
4254 health.metrics.architecture.max_blast_radius_file,
4255 "src/core/types.rs"
4256 );
4257 assert_eq!(
4258 health.metrics.architecture.max_non_foundation_blast_radius,
4259 0
4260 );
4261 assert!(health
4262 .metrics
4263 .architecture
4264 .stable_foundations
4265 .iter()
4266 .any(|module| module.module == "src/core"));
4267 }
4268
4269 #[test]
4270 fn computes_distance_from_main_sequence() {
4271 let root = temp_health_root("distance");
4272 fs::create_dir_all(root.join("src/api")).unwrap();
4273 fs::create_dir_all(root.join("src/impls")).unwrap();
4274 fs::write(root.join("src/api/mod.rs"), "pub trait Store {}\n").unwrap();
4275 fs::write(root.join("src/impls/store.rs"), "pub struct DiskStore;\n").unwrap();
4276
4277 let files = vec![file(0, "src/api/mod.rs"), file(1, "src/impls/store.rs")];
4278 let imports = vec![import(0, 1, Some(0), ImportResolution::Local)];
4279 let graph = compute_graph_metrics(&files, &imports);
4280 let report = ScanReport {
4281 snapshot: SnapshotFact {
4282 snapshot_id: "test".to_string(),
4283 root,
4284 file_count: files.len(),
4285 function_count: 0,
4286 import_count: imports.len(),
4287 call_count: 0,
4288 },
4289 files,
4290 functions: Vec::new(),
4291 entry_points: Vec::new(),
4292 imports,
4293 calls: Vec::new(),
4294 call_edges: Vec::new(),
4295 types: Vec::new(),
4296 graph,
4297 };
4298
4299 let mut config = RaysenseConfig::default();
4300 config.scan.module_roots = vec!["src".to_string()];
4301 let health = compute_health_with_config(&report, &config);
4302
4303 let api = health
4304 .metrics
4305 .architecture
4306 .distance_metrics
4307 .iter()
4308 .find(|metric| metric.module == "src/api")
4309 .unwrap();
4310 assert_eq!(api.abstract_count, 1);
4311 assert_eq!(api.total_types, 1);
4312 assert_eq!(api.abstractness, 1.0);
4313 assert_eq!(api.instability, 0.0);
4314 assert_eq!(api.distance, 0.0);
4315 }
4316
4317 #[test]
4318 fn applies_plugin_type_prefixes_to_main_sequence_distance() {
4319 let root = temp_health_root("plugin_distance");
4320 fs::create_dir_all(root.join("src/api")).unwrap();
4321 fs::write(
4322 root.join("src/api/contract.foo"),
4323 "contract Store\nrecord Disk\n",
4324 )
4325 .unwrap();
4326
4327 let mut files = vec![file(0, "src/api/contract.foo")];
4328 files[0].language = Language::Unknown;
4329 files[0].language_name = "foo".to_string();
4330 let graph = compute_graph_metrics(&files, &[]);
4331 let report = ScanReport {
4332 snapshot: SnapshotFact {
4333 snapshot_id: "test".to_string(),
4334 root,
4335 file_count: files.len(),
4336 function_count: 0,
4337 import_count: 0,
4338 call_count: 0,
4339 },
4340 files,
4341 functions: Vec::new(),
4342 entry_points: Vec::new(),
4343 imports: Vec::new(),
4344 calls: Vec::new(),
4345 call_edges: Vec::new(),
4346 types: Vec::new(),
4347 graph,
4348 };
4349 let mut config = RaysenseConfig::default();
4350 config.scan.plugins.push(LanguagePluginConfig {
4351 name: "foo".to_string(),
4352 abstract_type_prefixes: vec!["contract ".to_string()],
4353 concrete_type_prefixes: vec!["record ".to_string()],
4354 ..LanguagePluginConfig::default()
4355 });
4356 config.scan.module_roots = vec!["src".to_string()];
4357 let health = compute_health_with_config(&report, &config);
4358 let metric = &health.metrics.architecture.distance_metrics[0];
4359
4360 assert_eq!(metric.abstract_count, 1);
4361 assert_eq!(metric.total_types, 2);
4362 assert_eq!(metric.abstractness, 0.5);
4363 }
4364
4365 #[test]
4366 fn computes_attack_surface_from_entry_points() {
4367 let files = vec![
4368 file(0, "src/main.rs"),
4369 file(1, "src/service.rs"),
4370 file(2, "src/repo.rs"),
4371 file(3, "src/orphan.rs"),
4372 file(4, "src/util.rs"),
4373 ];
4374 let imports = vec![
4375 import(0, 0, Some(1), ImportResolution::Local),
4376 import(1, 1, Some(2), ImportResolution::Local),
4377 import(2, 3, Some(4), ImportResolution::Local),
4378 ];
4379 let graph = compute_graph_metrics(&files, &imports);
4380 let report = ScanReport {
4381 snapshot: SnapshotFact {
4382 snapshot_id: "test".to_string(),
4383 root: PathBuf::from("."),
4384 file_count: files.len(),
4385 function_count: 0,
4386 import_count: imports.len(),
4387 call_count: 0,
4388 },
4389 files,
4390 functions: Vec::new(),
4391 entry_points: vec![EntryPointFact {
4392 entry_id: 0,
4393 file_id: 0,
4394 kind: EntryPointKind::Binary,
4395 symbol: "main".to_string(),
4396 }],
4397 imports,
4398 calls: Vec::new(),
4399 call_edges: Vec::new(),
4400 types: Vec::new(),
4401 graph,
4402 };
4403
4404 let health = compute_health(&report);
4405
4406 assert_eq!(health.metrics.architecture.attack_surface_files, 3);
4407 assert_eq!(health.metrics.architecture.total_graph_files, 5);
4408 assert_eq!(health.metrics.architecture.attack_surface_ratio, 0.6);
4409 }
4410
4411 #[test]
4412 fn computes_coupling_entropy_for_unstable_cross_module_edges() {
4413 let files = vec![
4414 file(0, "src/a/mod.rs"),
4415 file(1, "src/b/mod.rs"),
4416 file(2, "src/c/mod.rs"),
4417 file(3, "src/d/mod.rs"),
4418 ];
4419 let imports = vec![
4420 import(0, 0, Some(1), ImportResolution::Local),
4421 import(1, 2, Some(3), ImportResolution::Local),
4422 ];
4423 let graph = compute_graph_metrics(&files, &imports);
4424 let report = ScanReport {
4425 snapshot: SnapshotFact {
4426 snapshot_id: "test".to_string(),
4427 root: PathBuf::from("."),
4428 file_count: files.len(),
4429 function_count: 0,
4430 import_count: imports.len(),
4431 call_count: 0,
4432 },
4433 files,
4434 functions: Vec::new(),
4435 entry_points: Vec::new(),
4436 imports,
4437 calls: Vec::new(),
4438 call_edges: Vec::new(),
4439 types: Vec::new(),
4440 graph,
4441 };
4442
4443 let mut config = RaysenseConfig::default();
4444 config.scan.module_roots = vec!["src".to_string()];
4445 let health = compute_health_with_config(&report, &config);
4446
4447 assert_eq!(health.metrics.coupling.entropy_pairs, 2);
4448 assert_eq!(health.metrics.coupling.entropy_bits, 1.0);
4449 assert_eq!(health.metrics.coupling.entropy, 1.0);
4450 }
4451
4452 #[test]
4453 fn distribution_entropy_handles_uniform_and_concentrated() {
4454 assert_eq!(distribution_entropy(&[]), (0.0, 0.0));
4455 assert_eq!(distribution_entropy(&[0, 0, 0]), (0.0, 0.0));
4456 assert_eq!(distribution_entropy(&[10]), (0.0, 0.0));
4457 assert_eq!(distribution_entropy(&[10, 0]), (0.0, 0.0));
4458 assert_eq!(distribution_entropy(&[5, 5]), (1.0, 1.0));
4459 assert_eq!(distribution_entropy(&[3, 3, 3, 3]), (1.0, 2.0));
4460 }
4461
4462 #[test]
4463 fn file_lines_bucket_separates_orders_of_magnitude() {
4464 assert_eq!(file_lines_bucket(0), 0);
4465 assert_eq!(file_lines_bucket(1), 1);
4466 assert_eq!(file_lines_bucket(2), 2);
4467 assert_eq!(file_lines_bucket(3), 2);
4468 assert_eq!(file_lines_bucket(4), 3);
4469 assert_eq!(file_lines_bucket(7), 3);
4470 assert_eq!(file_lines_bucket(1000), file_lines_bucket(1023));
4471 assert_ne!(file_lines_bucket(100), file_lines_bucket(1000));
4472 }
4473
4474 fn report_with_file_lines(lines: &[usize]) -> ScanReport {
4475 let files: Vec<FileFact> = lines
4476 .iter()
4477 .enumerate()
4478 .map(|(idx, count)| {
4479 let mut f = file(idx, &format!("src/m{idx}.rs"));
4480 f.lines = *count;
4481 f
4482 })
4483 .collect();
4484 let imports = Vec::new();
4485 let graph = compute_graph_metrics(&files, &imports);
4486 ScanReport {
4487 snapshot: SnapshotFact {
4488 snapshot_id: "test".to_string(),
4489 root: PathBuf::from("."),
4490 file_count: files.len(),
4491 function_count: 0,
4492 import_count: 0,
4493 call_count: 0,
4494 },
4495 files,
4496 functions: Vec::new(),
4497 entry_points: Vec::new(),
4498 imports,
4499 calls: Vec::new(),
4500 call_edges: Vec::new(),
4501 types: Vec::new(),
4502 graph,
4503 }
4504 }
4505
4506 #[test]
4507 fn file_size_entropy_zero_for_identical_sizes() {
4508 let report = report_with_file_lines(&[100, 100, 100, 100]);
4509 let metrics = size_metrics(&report);
4510 assert_eq!(metrics.file_size_entropy, 0.0);
4511 assert_eq!(metrics.file_size_entropy_bits, 0.0);
4512 }
4513
4514 #[test]
4515 fn file_size_entropy_uniform_for_distinct_buckets() {
4516 let report = report_with_file_lines(&[1, 1, 1024, 1024]);
4519 let metrics = size_metrics(&report);
4520 assert_eq!(metrics.file_size_entropy, 1.0);
4521 assert_eq!(metrics.file_size_entropy_bits, 1.0);
4522 }
4523
4524 #[test]
4525 fn quality_signal_preserved_when_file_size_distribution_varies() {
4526 let uniform = report_with_file_lines(&[100, 100, 100, 100]);
4530 let spread = report_with_file_lines(&[1, 8, 64, 1024]);
4531
4532 let config = RaysenseConfig::default();
4533 let h_uniform = compute_health_with_config(&uniform, &config);
4534 let h_spread = compute_health_with_config(&spread, &config);
4535
4536 assert_ne!(
4537 h_uniform.metrics.size.file_size_entropy,
4538 h_spread.metrics.size.file_size_entropy
4539 );
4540 assert_eq!(h_uniform.score, h_spread.score);
4541 assert_eq!(h_uniform.quality_signal, h_spread.quality_signal);
4542 assert_eq!(
4543 h_uniform.root_causes.modularity,
4544 h_spread.root_causes.modularity
4545 );
4546 assert_eq!(
4547 h_uniform.root_causes.equality,
4548 h_spread.root_causes.equality
4549 );
4550 assert_eq!(
4551 h_uniform.root_causes.redundancy,
4552 h_spread.root_causes.redundancy
4553 );
4554 }
4555
4556 #[test]
4557 fn grade_thresholds_map_scores_to_letter_grades() {
4558 let thresholds = GradeThresholds::default();
4559 assert_eq!(grade_for(0.95, &thresholds), "A");
4560 assert_eq!(grade_for(0.9, &thresholds), "A");
4561 assert_eq!(grade_for(0.85, &thresholds), "B");
4562 assert_eq!(grade_for(0.8, &thresholds), "B");
4563 assert_eq!(grade_for(0.75, &thresholds), "C");
4564 assert_eq!(grade_for(0.7, &thresholds), "C");
4565 assert_eq!(grade_for(0.6, &thresholds), "D");
4566 assert_eq!(grade_for(0.5, &thresholds), "D");
4567 assert_eq!(grade_for(0.4, &thresholds), "F");
4568 }
4569
4570 #[test]
4571 fn custom_grade_thresholds_are_respected() {
4572 let thresholds = GradeThresholds {
4573 a: 0.95,
4574 b: 0.9,
4575 c: 0.85,
4576 d: 0.7,
4577 };
4578 assert_eq!(grade_for(0.92, &thresholds), "B");
4580 assert_eq!(grade_for(0.74, &thresholds), "D");
4582 }
4583
4584 #[test]
4585 fn is_bug_fix_subject_recognises_conventional_and_common_prefixes() {
4586 assert!(is_bug_fix_subject("fix: stop crash on empty input"));
4588 assert!(is_bug_fix_subject(
4589 "fix(parser): off-by-one in bracket match"
4590 ));
4591 assert!(is_bug_fix_subject("fix!: breaking signature change"));
4592 assert!(is_bug_fix_subject("fix typo in error message")); assert!(is_bug_fix_subject("bugfix: ratelimit underflow"));
4596 assert!(is_bug_fix_subject("hotfix: production redeploy"));
4597 assert!(is_bug_fix_subject("revert: bring back prior behaviour"));
4598 assert!(is_bug_fix_subject("Revert \"feat: new thing\"")); assert!(is_bug_fix_subject(" fix: leading spaces"));
4602
4603 assert!(!is_bug_fix_subject("feat: add validator"));
4605 assert!(!is_bug_fix_subject("fixing the parser is hard")); assert!(!is_bug_fix_subject("fixtures: regenerate snapshots")); assert!(!is_bug_fix_subject("docs: typo in README"));
4608 assert!(!is_bug_fix_subject(""));
4609 }
4610
4611 #[test]
4612 fn has_root_causes_distinguishes_zeroed_from_real_data() {
4613 assert!(!has_root_causes(&RootCauseScores::default()));
4616 let mut with_one = RootCauseScores::default();
4618 with_one.modularity = 0.5;
4619 assert!(has_root_causes(&with_one));
4620 }
4621
4622 #[test]
4623 fn edit_risk_files_combines_churn_complexity_busfactor_and_test_gap() {
4624 use std::collections::BTreeMap;
4625
4626 let mut file_commits: BTreeMap<String, usize> = BTreeMap::new();
4628 file_commits.insert("src/risky.rs".to_string(), 10);
4629 file_commits.insert("src/owned.rs".to_string(), 10);
4630 file_commits.insert("src/safe.rs".to_string(), 10);
4631
4632 let complexity = ComplexityMetrics {
4634 all_functions: vec![
4635 FunctionComplexityMetric {
4636 path: "src/risky.rs".to_string(),
4637 name: String::new(),
4638 file_id: 0,
4639 function_id: 0,
4640 value: 50,
4641 cognitive_value: 0,
4642 },
4643 FunctionComplexityMetric {
4644 path: "src/owned.rs".to_string(),
4645 name: String::new(),
4646 file_id: 1,
4647 function_id: 1,
4648 value: 50,
4649 cognitive_value: 0,
4650 },
4651 FunctionComplexityMetric {
4652 path: "src/safe.rs".to_string(),
4653 name: String::new(),
4654 file_id: 2,
4655 function_id: 2,
4656 value: 50,
4657 cognitive_value: 0,
4658 },
4659 ],
4660 ..ComplexityMetrics::default()
4661 };
4662
4663 let file_ownership = vec![
4665 EvolutionFileOwnership {
4666 path: "src/risky.rs".to_string(),
4667 top_author: "alice".to_string(),
4668 top_author_commits: 10,
4669 total_commits: 10,
4670 author_count: 1,
4671 bus_factor: 1,
4672 },
4673 EvolutionFileOwnership {
4674 path: "src/owned.rs".to_string(),
4675 top_author: "alice".to_string(),
4676 top_author_commits: 6,
4677 total_commits: 10,
4678 author_count: 2,
4679 bus_factor: 2,
4680 },
4681 EvolutionFileOwnership {
4682 path: "src/safe.rs".to_string(),
4683 top_author: "alice".to_string(),
4684 top_author_commits: 3,
4685 total_commits: 10,
4686 author_count: 5,
4687 bus_factor: 5,
4688 },
4689 ];
4690
4691 let test_gap = TestGapMetrics {
4693 production_files: 3,
4694 test_files: 2,
4695 files_without_nearby_tests: 1,
4696 candidates: vec![TestGapCandidate {
4697 file_id: 0,
4698 path: "src/risky.rs".to_string(),
4699 framework: "rust".to_string(),
4700 expected_tests: vec![],
4701 matched_tests: vec![],
4702 }],
4703 };
4704
4705 let ranked = edit_risk_files(&file_commits, &complexity, &file_ownership, &test_gap);
4706 assert_eq!(ranked.len(), 3);
4707
4708 assert_eq!(ranked[0].path, "src/risky.rs");
4713 assert!((ranked[0].risk_score - 1500.0).abs() < 1e-6);
4714 assert!(!ranked[0].has_nearby_tests);
4715 assert_eq!(ranked[0].bus_factor, 1);
4716
4717 assert_eq!(ranked[1].path, "src/owned.rs");
4718 assert!((ranked[1].risk_score - 750.0).abs() < 1e-6);
4719 assert!(ranked[1].has_nearby_tests);
4720
4721 assert_eq!(ranked[2].path, "src/safe.rs");
4722 assert!((ranked[2].risk_score - 600.0).abs() < 1e-6);
4723 }
4724
4725 #[test]
4726 fn bug_prone_files_ranks_by_count_then_ratio() {
4727 use std::collections::BTreeMap;
4728
4729 let mut bug_fixes: BTreeMap<String, usize> = BTreeMap::new();
4730 let mut totals: BTreeMap<String, usize> = BTreeMap::new();
4731
4732 bug_fixes.insert("src/parser.rs".to_string(), 8);
4733 totals.insert("src/parser.rs".to_string(), 12);
4734
4735 bug_fixes.insert("src/cli.rs".to_string(), 5);
4736 totals.insert("src/cli.rs".to_string(), 20);
4737
4738 bug_fixes.insert("src/lexer.rs".to_string(), 5);
4739 totals.insert("src/lexer.rs".to_string(), 8);
4740
4741 bug_fixes.insert("src/quiet.rs".to_string(), 0);
4742 totals.insert("src/quiet.rs".to_string(), 30);
4743
4744 let ranked = bug_prone_files(&bug_fixes, &totals);
4745
4746 assert!(!ranked.iter().any(|e| e.path == "src/quiet.rs"));
4748
4749 let paths: Vec<&str> = ranked.iter().map(|e| e.path.as_str()).collect();
4753 assert_eq!(paths, vec!["src/parser.rs", "src/lexer.rs", "src/cli.rs"]);
4754
4755 let parser = ranked.iter().find(|e| e.path == "src/parser.rs").unwrap();
4757 assert!((parser.bug_fix_ratio - (8.0 / 12.0)).abs() < 1e-9);
4758 }
4759
4760 #[test]
4761 fn bus_factor_returns_minimum_authors_for_eighty_percent_coverage() {
4762 let one = "alice".to_string();
4764 let only_alice = vec![(&one, &10usize)];
4765 assert_eq!(bus_factor_for(&only_alice, 10), 1);
4766
4767 let alice = "alice".to_string();
4769 let bob = "bob".to_string();
4770 let split = vec![(&alice, &5usize), (&bob, &5usize)];
4771 assert_eq!(bus_factor_for(&split, 10), 2);
4772
4773 let dominant = vec![(&alice, &9usize), (&bob, &1usize)];
4775 assert_eq!(bus_factor_for(&dominant, 10), 1);
4776
4777 assert_eq!(bus_factor_for(&[], 0), 0);
4779 }
4780
4781 #[test]
4782 fn structural_uniformity_averages_size_and_complexity_entropy() {
4783 let report = report_with_file_lines(&[1, 1, 1024, 1024]);
4784 let health = compute_health_with_config(&report, &RaysenseConfig::default());
4785 assert_eq!(health.metrics.size.file_size_entropy, 1.0);
4788 assert_eq!(health.metrics.complexity.complexity_entropy, 0.0);
4789 assert_eq!(health.root_causes.structural_uniformity, 0.5);
4790 }
4791
4792 #[test]
4793 fn plugin_config_round_trips_extended_semantic_fields() {
4794 let config: RaysenseConfig = toml::from_str(
4795 r#"
4796[[scan.plugins]]
4797name = "toy"
4798extensions = ["toy"]
4799resolver_alias_files = ["aliases.json"]
4800namespace_separator = "."
4801module_prefix_files = ["mod.toy", "init.toy"]
4802module_prefix_directives = ["package "]
4803entry_point_patterns = ["main", "init"]
4804test_module_patterns = ["tests/*"]
4805test_attribute_patterns = ["@Test"]
4806parameter_node_kinds = ["parameter"]
4807complexity_node_kinds = ["if_expression", "while_expression"]
4808logical_operator_kinds = ["&&", "||"]
4809abstract_base_classes = ["Base", "Abstract"]
4810"#,
4811 )
4812 .expect("plugin config with new fields parses");
4813
4814 let plugin = config
4815 .scan
4816 .plugins
4817 .iter()
4818 .find(|plugin| plugin.name == "toy")
4819 .expect("toy plugin present");
4820 assert_eq!(plugin.resolver_alias_files, vec!["aliases.json"]);
4821 assert_eq!(plugin.namespace_separator.as_deref(), Some("."));
4822 assert_eq!(plugin.module_prefix_files, vec!["mod.toy", "init.toy"]);
4823 assert_eq!(plugin.module_prefix_directives, vec!["package "]);
4824 assert_eq!(plugin.entry_point_patterns, vec!["main", "init"]);
4825 assert_eq!(plugin.test_module_patterns, vec!["tests/*"]);
4826 assert_eq!(plugin.test_attribute_patterns, vec!["@Test"]);
4827 assert_eq!(plugin.parameter_node_kinds, vec!["parameter"]);
4828 assert_eq!(
4829 plugin.complexity_node_kinds,
4830 vec!["if_expression", "while_expression"]
4831 );
4832 assert_eq!(plugin.logical_operator_kinds, vec!["&&", "||"]);
4833 assert_eq!(plugin.abstract_base_classes, vec!["Base", "Abstract"]);
4834 }
4835
4836 #[test]
4837 fn plugin_config_defaults_extended_fields_to_empty() {
4838 let config: RaysenseConfig = toml::from_str(
4839 r#"
4840[[scan.plugins]]
4841name = "minimal"
4842extensions = ["min"]
4843"#,
4844 )
4845 .expect("minimal plugin parses");
4846 let plugin = config
4847 .scan
4848 .plugins
4849 .iter()
4850 .find(|plugin| plugin.name == "minimal")
4851 .expect("minimal plugin present");
4852 assert!(plugin.resolver_alias_files.is_empty());
4853 assert!(plugin.namespace_separator.is_none());
4854 assert!(plugin.module_prefix_files.is_empty());
4855 assert!(plugin.module_prefix_directives.is_empty());
4856 assert!(plugin.entry_point_patterns.is_empty());
4857 assert!(plugin.test_module_patterns.is_empty());
4858 assert!(plugin.test_attribute_patterns.is_empty());
4859 assert!(plugin.parameter_node_kinds.is_empty());
4860 assert!(plugin.complexity_node_kinds.is_empty());
4861 assert!(plugin.logical_operator_kinds.is_empty());
4862 assert!(plugin.abstract_base_classes.is_empty());
4863 }
4864
4865 #[test]
4866 fn quality_signal_shifts_when_structural_uniformity_weight_set() {
4867 let monoculture = report_with_file_lines(&[100, 100, 100, 100]);
4872 let diverse = report_with_file_lines(&[1, 1, 1024, 1024]);
4873
4874 let mut config = RaysenseConfig::default();
4875 let baseline_mono = compute_health_with_config(&monoculture, &config).quality_signal;
4876 let baseline_div = compute_health_with_config(&diverse, &config).quality_signal;
4877 assert_eq!(baseline_mono, baseline_div);
4878
4879 config.score.structural_uniformity_weight = 1.0;
4880 let weighted_mono = compute_health_with_config(&monoculture, &config).quality_signal;
4881 let weighted_div = compute_health_with_config(&diverse, &config).quality_signal;
4882
4883 assert_ne!(weighted_mono, baseline_mono);
4884 assert_ne!(weighted_div, baseline_div);
4885 assert!(
4886 weighted_div > weighted_mono,
4887 "diverse distribution should outscore monoculture once weighted in"
4888 );
4889 }
4890
4891 #[test]
4892 fn computes_module_cohesion_from_internal_edges() {
4893 let files = vec![
4894 file(0, "src/a/one.rs"),
4895 file(1, "src/a/two.rs"),
4896 file(2, "src/a/three.rs"),
4897 ];
4898 let imports = vec![import(0, 0, Some(1), ImportResolution::Local)];
4899 let graph = compute_graph_metrics(&files, &imports);
4900 let report = ScanReport {
4901 snapshot: SnapshotFact {
4902 snapshot_id: "test".to_string(),
4903 root: PathBuf::from("."),
4904 file_count: files.len(),
4905 function_count: 0,
4906 import_count: imports.len(),
4907 call_count: 0,
4908 },
4909 files,
4910 functions: Vec::new(),
4911 entry_points: Vec::new(),
4912 imports,
4913 calls: Vec::new(),
4914 call_edges: Vec::new(),
4915 types: Vec::new(),
4916 graph,
4917 };
4918
4919 let mut config = RaysenseConfig::default();
4920 config.scan.module_roots = vec!["src".to_string()];
4921 let health = compute_health_with_config(&report, &config);
4922
4923 assert_eq!(health.metrics.coupling.cohesive_module_count, 1);
4924 assert_eq!(health.metrics.coupling.average_module_cohesion, Some(0.5));
4925 }
4926
4927 #[test]
4928 fn reports_file_instability_and_god_files() {
4929 let files = vec![
4930 file(0, "src/app.rs"),
4931 file(1, "src/a.rs"),
4932 file(2, "src/b.rs"),
4933 file(3, "src/c.rs"),
4934 ];
4935 let imports = vec![
4936 import(0, 0, Some(1), ImportResolution::Local),
4937 import(1, 0, Some(2), ImportResolution::Local),
4938 import(2, 0, Some(3), ImportResolution::Local),
4939 ];
4940 let graph = compute_graph_metrics(&files, &imports);
4941 let report = ScanReport {
4942 snapshot: SnapshotFact {
4943 snapshot_id: "test".to_string(),
4944 root: PathBuf::from("."),
4945 file_count: files.len(),
4946 function_count: 0,
4947 import_count: imports.len(),
4948 call_count: 0,
4949 },
4950 files,
4951 functions: Vec::new(),
4952 entry_points: Vec::new(),
4953 imports,
4954 calls: Vec::new(),
4955 call_edges: Vec::new(),
4956 types: Vec::new(),
4957 graph,
4958 };
4959 let mut config = RaysenseConfig::default();
4960 config.rules.high_file_fan_out = 2;
4961 let health = compute_health_with_config(&report, &config);
4962
4963 assert_eq!(health.metrics.coupling.god_files[0].path, "src/app.rs");
4964 assert_eq!(health.metrics.coupling.god_files[0].fan_out, 3);
4965 assert_eq!(
4966 health.metrics.coupling.most_unstable_files[0].path,
4967 "src/app.rs"
4968 );
4969 assert_eq!(
4970 health.metrics.coupling.most_unstable_files[0].instability,
4971 1.0
4972 );
4973 assert!(health.rules.iter().any(|rule| rule.code == "no_god_files"));
4974 }
4975
4976 #[test]
4977 fn reports_cycle_edges_as_upward_violations() {
4978 let files = vec![file(0, "src/a.rs"), file(1, "src/b.rs")];
4979 let imports = vec![
4980 import(0, 0, Some(1), ImportResolution::Local),
4981 import(1, 1, Some(0), ImportResolution::Local),
4982 ];
4983 let graph = compute_graph_metrics(&files, &imports);
4984 let report = ScanReport {
4985 snapshot: SnapshotFact {
4986 snapshot_id: "test".to_string(),
4987 root: PathBuf::from("."),
4988 file_count: files.len(),
4989 function_count: 0,
4990 import_count: imports.len(),
4991 call_count: 0,
4992 },
4993 files,
4994 functions: Vec::new(),
4995 entry_points: Vec::new(),
4996 imports,
4997 calls: Vec::new(),
4998 call_edges: Vec::new(),
4999 types: Vec::new(),
5000 graph,
5001 };
5002
5003 let health = compute_health(&report);
5004
5005 assert_eq!(health.metrics.architecture.upward_violations.len(), 2);
5006 assert!(health
5007 .metrics
5008 .architecture
5009 .upward_violations
5010 .iter()
5011 .all(|violation| violation.reason == "cycle_edge"));
5012 }
5013
5014 #[test]
5015 fn flags_production_dependencies_on_test_paths() {
5016 let files = vec![file(0, "src/a.c"), file(1, "test/test.h")];
5017 let imports = vec![import(0, 0, Some(1), ImportResolution::Local)];
5018 let graph = compute_graph_metrics(&files, &imports);
5019 let report = ScanReport {
5020 snapshot: SnapshotFact {
5021 snapshot_id: "test".to_string(),
5022 root: PathBuf::from("."),
5023 file_count: files.len(),
5024 function_count: 0,
5025 import_count: imports.len(),
5026 call_count: 0,
5027 },
5028 files,
5029 functions: Vec::new(),
5030 entry_points: Vec::new(),
5031 imports,
5032 calls: Vec::new(),
5033 call_edges: Vec::new(),
5034 types: Vec::new(),
5035 graph,
5036 };
5037
5038 let health = compute_health(&report);
5039
5040 assert_eq!(health.rules.len(), 1);
5041 assert_eq!(health.rules[0].code, "production_depends_on_test");
5042 assert!(health.structural_score < 100);
5043 }
5044
5045 #[test]
5046 fn computes_metric_families() {
5047 let mut files = vec![file(0, "core/a.rs"), file(1, "io/b.rs")];
5048 files[0].lines = 600;
5049 let imports = vec![import(0, 0, Some(1), ImportResolution::Local)];
5050 let graph = compute_graph_metrics(&files, &imports);
5051 let report = ScanReport {
5052 snapshot: SnapshotFact {
5053 snapshot_id: "test".to_string(),
5054 root: PathBuf::from("."),
5055 file_count: files.len(),
5056 function_count: 1,
5057 import_count: imports.len(),
5058 call_count: 0,
5059 },
5060 files,
5061 functions: vec![FunctionFact {
5062 function_id: 0,
5063 file_id: 0,
5064 name: "large".to_string(),
5065 start_line: 10,
5066 end_line: 95,
5067 }],
5068 entry_points: vec![EntryPointFact {
5069 entry_id: 0,
5070 file_id: 0,
5071 kind: EntryPointKind::Binary,
5072 symbol: "main".to_string(),
5073 }],
5074 imports,
5075 calls: Vec::new(),
5076 call_edges: Vec::new(),
5077 types: Vec::new(),
5078 graph,
5079 };
5080
5081 let health = compute_health(&report);
5082
5083 assert_eq!(health.metrics.coupling.cross_module_edges, 1);
5084 assert_eq!(health.metrics.size.large_files, 1);
5085 assert_eq!(health.metrics.size.long_functions, 1);
5086 assert_eq!(health.metrics.entry_points.binaries, 1);
5087 assert_eq!(health.metrics.dsm.module_edges, 1);
5088 }
5089
5090 #[test]
5091 fn computes_source_aware_complexity_duplicates_and_test_gaps() {
5092 let root = temp_health_root("source_metrics");
5093 fs::create_dir_all(root.join("src")).unwrap();
5094 let source = r#"
5095pub fn exported(value: i32) -> i32 {
5096 if value > 0 { value } else { 0 }
5097}
5098
5099fn first(value: i32) -> i32 {
5100 if value > 10 && value < 20 {
5101 return value;
5102 }
5103 0
5104}
5105
5106fn second(input: i32) -> i32 {
5107 if input > 10 && input < 20 {
5108 return input;
5109 }
5110 0
5111}
5112"#;
5113 fs::write(root.join("src/lib.rs"), source).unwrap();
5114
5115 let files = vec![file(0, "src/lib.rs")];
5116 let functions = vec![
5117 FunctionFact {
5118 function_id: 0,
5119 file_id: 0,
5120 name: "exported".to_string(),
5121 start_line: 2,
5122 end_line: 4,
5123 },
5124 FunctionFact {
5125 function_id: 1,
5126 file_id: 0,
5127 name: "first".to_string(),
5128 start_line: 6,
5129 end_line: 11,
5130 },
5131 FunctionFact {
5132 function_id: 2,
5133 file_id: 0,
5134 name: "second".to_string(),
5135 start_line: 13,
5136 end_line: 18,
5137 },
5138 ];
5139 let report = ScanReport {
5140 snapshot: SnapshotFact {
5141 snapshot_id: "test".to_string(),
5142 root: root.clone(),
5143 file_count: files.len(),
5144 function_count: functions.len(),
5145 import_count: 0,
5146 call_count: 0,
5147 },
5148 files,
5149 functions,
5150 entry_points: Vec::new(),
5151 imports: Vec::new(),
5152 calls: Vec::new(),
5153 call_edges: Vec::new(),
5154 types: Vec::new(),
5155 graph: compute_graph_metrics(&[], &[]),
5156 };
5157 let health = compute_health(&report);
5158 fs::remove_dir_all(&root).unwrap();
5159
5160 assert!(health.metrics.complexity.max_function_complexity >= 3);
5161 assert!(health
5162 .metrics
5163 .complexity
5164 .duplicate_groups
5165 .iter()
5166 .any(|group| group.functions.len() >= 2));
5167 assert!(health
5168 .metrics
5169 .complexity
5170 .dead_functions
5171 .iter()
5172 .all(|function| function.name != "exported"));
5173 assert_eq!(health.metrics.test_gap.files_without_nearby_tests, 1);
5174 assert!(health.metrics.test_gap.candidates[0]
5175 .expected_tests
5176 .iter()
5177 .any(|path| path == "tests/lib_test.rs"));
5178 }
5179
5180 #[test]
5181 fn applies_configured_public_api_paths_and_test_roots() {
5182 let root = temp_health_root("configured_paths");
5183 fs::create_dir_all(root.join("app")).unwrap();
5184 fs::create_dir_all(root.join("spec")).unwrap();
5185 fs::write(
5186 root.join("app/service.rs"),
5187 r#"
5188fn exported_surface() -> i32 {
5189 1
5190}
5191"#,
5192 )
5193 .unwrap();
5194 fs::write(root.join("spec/service_test.rs"), "fn service_test() {}\n").unwrap();
5195
5196 let files = vec![file(0, "app/service.rs"), file(1, "spec/service_test.rs")];
5197 let functions = vec![FunctionFact {
5198 function_id: 0,
5199 file_id: 0,
5200 name: "exported_surface".to_string(),
5201 start_line: 2,
5202 end_line: 4,
5203 }];
5204 let report = ScanReport {
5205 snapshot: SnapshotFact {
5206 snapshot_id: "test".to_string(),
5207 root: root.clone(),
5208 file_count: files.len(),
5209 function_count: functions.len(),
5210 import_count: 0,
5211 call_count: 0,
5212 },
5213 files,
5214 functions,
5215 entry_points: Vec::new(),
5216 imports: Vec::new(),
5217 calls: Vec::new(),
5218 call_edges: Vec::new(),
5219 types: Vec::new(),
5220 graph: compute_graph_metrics(&[], &[]),
5221 };
5222 let mut config = RaysenseConfig::default();
5223 config.scan.public_api_paths = vec!["app/*".to_string()];
5224 config.scan.test_roots = vec!["spec".to_string()];
5225
5226 let health = compute_health_with_config(&report, &config);
5227 fs::remove_dir_all(&root).unwrap();
5228
5229 assert_eq!(health.metrics.complexity.public_api_functions, 1);
5230 assert!(health.metrics.complexity.dead_functions.is_empty());
5231 assert_eq!(health.metrics.test_gap.test_files, 1);
5232 assert_eq!(health.metrics.test_gap.files_without_nearby_tests, 0);
5233 }
5234
5235 #[test]
5236 fn normalizes_git_paths_for_scanned_subdirectories() {
5237 assert_eq!(
5238 scan_relative_git_path("crates/core/src/lib.rs", "crates/core/"),
5239 Some("src/lib.rs".to_string())
5240 );
5241 assert_eq!(
5242 scan_relative_git_path("other/src/lib.rs", "crates/core/"),
5243 None
5244 );
5245 assert_eq!(
5246 scan_relative_git_path("src/lib.rs", ""),
5247 Some("src/lib.rs".to_string())
5248 );
5249 }
5250
5251 #[test]
5252 fn computes_call_metrics() {
5253 let files = vec![file(0, "src/a.rs")];
5254 let functions = vec![
5255 function(0, 0, "run"),
5256 function(1, 0, "load"),
5257 function(2, 0, "save"),
5258 ];
5259 let calls = vec![
5260 call(0, 0, Some(0), "load"),
5261 call(1, 0, Some(0), "save"),
5262 call(2, 0, Some(2), "load"),
5263 ];
5264 let call_edges = vec![
5265 call_edge(0, 0, 0, 1),
5266 call_edge(1, 1, 0, 2),
5267 call_edge(2, 2, 2, 1),
5268 ];
5269 let report = ScanReport {
5270 snapshot: SnapshotFact {
5271 snapshot_id: "test".to_string(),
5272 root: PathBuf::from("."),
5273 file_count: files.len(),
5274 function_count: functions.len(),
5275 import_count: 0,
5276 call_count: calls.len(),
5277 },
5278 files,
5279 functions,
5280 entry_points: Vec::new(),
5281 imports: Vec::new(),
5282 calls,
5283 call_edges,
5284 types: Vec::new(),
5285 graph: compute_graph_metrics(&[], &[]),
5286 };
5287
5288 let health = compute_health(&report);
5289
5290 assert_eq!(health.metrics.calls.total_calls, 3);
5291 assert_eq!(health.metrics.calls.resolved_edges, 3);
5292 assert_eq!(health.metrics.calls.max_function_fan_in, 2);
5293 assert_eq!(health.metrics.calls.max_function_fan_out, 2);
5294 assert_eq!(health.metrics.calls.top_called_functions[0].name, "load");
5295 assert_eq!(health.metrics.calls.top_calling_functions[0].name, "run");
5296 }
5297
5298 #[test]
5299 fn reports_call_metric_findings() {
5300 let files = vec![file(0, "src/a.rs")];
5301 let functions = vec![function(0, 0, "run"), function(1, 0, "load")];
5302 let mut calls = Vec::new();
5303 let mut call_edges = Vec::new();
5304 for id in 0..250 {
5305 calls.push(call(id, 0, Some(0), "load"));
5306 if id < 100 {
5307 call_edges.push(call_edge(id, id, 0, 1));
5308 }
5309 }
5310 let report = ScanReport {
5311 snapshot: SnapshotFact {
5312 snapshot_id: "test".to_string(),
5313 root: PathBuf::from("."),
5314 file_count: files.len(),
5315 function_count: functions.len(),
5316 import_count: 0,
5317 call_count: calls.len(),
5318 },
5319 files,
5320 functions,
5321 entry_points: Vec::new(),
5322 imports: Vec::new(),
5323 calls,
5324 call_edges,
5325 types: Vec::new(),
5326 graph: compute_graph_metrics(&[], &[]),
5327 };
5328
5329 let health = compute_health(&report);
5330 let codes: Vec<&str> = health.rules.iter().map(|rule| rule.code.as_str()).collect();
5331
5332 assert!(codes.contains(&"low_call_resolution"));
5333 assert!(codes.contains(&"high_function_fan_out"));
5334 }
5335
5336 #[test]
5337 fn applies_rule_config_thresholds() {
5338 let files = vec![file(0, "src/a.rs")];
5339 let functions = vec![function(0, 0, "run"), function(1, 0, "load")];
5340 let mut calls = Vec::new();
5341 let mut call_edges = Vec::new();
5342 for id in 0..250 {
5343 calls.push(call(id, 0, Some(0), "load"));
5344 if id < 100 {
5345 call_edges.push(call_edge(id, id, 0, 1));
5346 }
5347 }
5348 let report = ScanReport {
5349 snapshot: SnapshotFact {
5350 snapshot_id: "test".to_string(),
5351 root: PathBuf::from("."),
5352 file_count: files.len(),
5353 function_count: functions.len(),
5354 import_count: 0,
5355 call_count: calls.len(),
5356 },
5357 files,
5358 functions,
5359 entry_points: Vec::new(),
5360 imports: Vec::new(),
5361 calls,
5362 call_edges,
5363 types: Vec::new(),
5364 graph: compute_graph_metrics(&[], &[]),
5365 };
5366 let config: RaysenseConfig = toml::from_str(
5367 r#"
5368[rules]
5369low_call_resolution_ratio = 0.3
5370high_function_fan_in = 500
5371high_function_fan_out = 500
5372no_tests_detected = false
5373"#,
5374 )
5375 .unwrap();
5376
5377 let health = compute_health_with_config(&report, &config);
5378 let codes: Vec<&str> = health.rules.iter().map(|rule| rule.code.as_str()).collect();
5379
5380 assert!(!codes.contains(&"low_call_resolution"));
5381 assert!(!codes.contains(&"high_function_fan_in"));
5382 assert!(!codes.contains(&"high_function_fan_out"));
5383 assert!(!codes.contains(&"no_tests_detected"));
5384 }
5385
5386 #[test]
5387 fn applies_minimum_score_gates() {
5388 let files = vec![file(0, "src/a.rs"), file(1, "src/b.rs")];
5389 let imports = vec![
5390 import(0, 0, Some(1), ImportResolution::Local),
5391 import(1, 1, Some(0), ImportResolution::Local),
5392 ];
5393 let graph = compute_graph_metrics(&files, &imports);
5394 let report = ScanReport {
5395 snapshot: SnapshotFact {
5396 snapshot_id: "test".to_string(),
5397 root: PathBuf::from("."),
5398 file_count: files.len(),
5399 function_count: 0,
5400 import_count: imports.len(),
5401 call_count: 0,
5402 },
5403 files,
5404 functions: Vec::new(),
5405 entry_points: Vec::new(),
5406 imports,
5407 calls: Vec::new(),
5408 call_edges: Vec::new(),
5409 types: Vec::new(),
5410 graph,
5411 };
5412 let config: RaysenseConfig = toml::from_str(
5413 r#"
5414[rules]
5415min_quality_signal = 9999
5416min_acyclicity = 0.9
5417"#,
5418 )
5419 .unwrap();
5420
5421 let health = compute_health_with_config(&report, &config);
5422 let codes: Vec<&str> = health.rules.iter().map(|rule| rule.code.as_str()).collect();
5423
5424 assert!(codes.contains(&"min_quality_signal"));
5425 assert!(codes.contains(&"min_acyclicity"));
5426 }
5427
5428 #[test]
5429 fn applies_hard_size_gates() {
5430 let mut source = file(0, "src/a.rs");
5431 source.lines = 120;
5432 let mut long_function = function(0, 0, "long");
5433 long_function.end_line = 80;
5434 let report = ScanReport {
5435 snapshot: SnapshotFact {
5436 snapshot_id: "test".to_string(),
5437 root: PathBuf::from("."),
5438 file_count: 1,
5439 function_count: 1,
5440 import_count: 0,
5441 call_count: 0,
5442 },
5443 files: vec![source],
5444 functions: vec![long_function],
5445 entry_points: Vec::new(),
5446 imports: Vec::new(),
5447 calls: Vec::new(),
5448 call_edges: Vec::new(),
5449 types: Vec::new(),
5450 graph: compute_graph_metrics(&[], &[]),
5451 };
5452 let config: RaysenseConfig = toml::from_str(
5453 r#"
5454[rules]
5455max_file_lines = 100
5456max_function_lines = 50
5457"#,
5458 )
5459 .unwrap();
5460
5461 let health = compute_health_with_config(&report, &config);
5462 let codes: Vec<&str> = health.rules.iter().map(|rule| rule.code.as_str()).collect();
5463
5464 assert!(codes.contains(&"max_file_lines"));
5465 assert!(codes.contains(&"max_function_lines"));
5466 }
5467
5468 #[test]
5469 fn applies_plugin_threshold_overrides() {
5470 let mut source = file(0, "src/a.rs");
5471 source.lines = 120;
5472 let mut long_function = function(0, 0, "long");
5473 long_function.end_line = 80;
5474 let report = ScanReport {
5475 snapshot: SnapshotFact {
5476 snapshot_id: "test".to_string(),
5477 root: PathBuf::from("."),
5478 file_count: 1,
5479 function_count: 1,
5480 import_count: 0,
5481 call_count: 0,
5482 },
5483 files: vec![source],
5484 functions: vec![long_function],
5485 entry_points: Vec::new(),
5486 imports: Vec::new(),
5487 calls: Vec::new(),
5488 call_edges: Vec::new(),
5489 types: Vec::new(),
5490 graph: compute_graph_metrics(&[], &[]),
5491 };
5492 let config: RaysenseConfig = toml::from_str(
5493 r#"
5494[[scan.plugins]]
5495name = "rust"
5496extensions = ["rs"]
5497max_function_complexity = 0
5498max_file_lines = 100
5499max_function_lines = 50
5500
5501[rules]
5502max_file_lines = 0
5503max_function_lines = 0
5504no_tests_detected = false
5505"#,
5506 )
5507 .unwrap();
5508
5509 let health = compute_health_with_config(&report, &config);
5510 let codes: Vec<&str> = health.rules.iter().map(|rule| rule.code.as_str()).collect();
5511
5512 assert!(codes.contains(&"max_function_complexity"));
5513 assert!(codes.contains(&"max_file_lines"));
5514 assert!(codes.contains(&"max_function_lines"));
5515 }
5516
5517 #[test]
5518 fn reports_forbidden_module_edges() {
5519 let files = vec![file(0, "src/a.rs"), file(1, "test/b.rs")];
5520 let imports = vec![import(0, 0, Some(1), ImportResolution::Local)];
5521 let graph = compute_graph_metrics(&files, &imports);
5522 let report = ScanReport {
5523 snapshot: SnapshotFact {
5524 snapshot_id: "test".to_string(),
5525 root: PathBuf::from("."),
5526 file_count: files.len(),
5527 function_count: 0,
5528 import_count: imports.len(),
5529 call_count: 0,
5530 },
5531 files,
5532 functions: Vec::new(),
5533 entry_points: Vec::new(),
5534 imports,
5535 calls: Vec::new(),
5536 call_edges: Vec::new(),
5537 types: Vec::new(),
5538 graph,
5539 };
5540 let config: RaysenseConfig = toml::from_str(
5541 r#"
5542[[boundaries.forbidden_edges]]
5543from = "src"
5544to = "test"
5545reason = "runtime code must not depend on tests"
5546"#,
5547 )
5548 .unwrap();
5549
5550 let health = compute_health_with_config(&report, &config);
5551
5552 let finding = health
5553 .rules
5554 .iter()
5555 .find(|rule| rule.code == "forbidden_module_edge")
5556 .expect("forbidden edge rule should be reported");
5557 assert!(finding
5558 .message
5559 .contains("runtime code must not depend on tests"));
5560 }
5561
5562 #[test]
5563 fn caps_upward_layer_violations() {
5564 let files = vec![
5565 file(0, "src/infra/db.rs"),
5566 file(1, "src/api/http.rs"),
5567 file(2, "src/api/rpc.rs"),
5568 ];
5569 let imports = vec![
5570 import(0, 0, Some(1), ImportResolution::Local),
5571 import(1, 0, Some(2), ImportResolution::Local),
5572 ];
5573 let graph = compute_graph_metrics(&files, &imports);
5574 let report = ScanReport {
5575 snapshot: SnapshotFact {
5576 snapshot_id: "test".to_string(),
5577 root: PathBuf::from("."),
5578 file_count: files.len(),
5579 function_count: 0,
5580 import_count: imports.len(),
5581 call_count: 0,
5582 },
5583 files,
5584 functions: Vec::new(),
5585 entry_points: Vec::new(),
5586 imports,
5587 calls: Vec::new(),
5588 call_edges: Vec::new(),
5589 types: Vec::new(),
5590 graph,
5591 };
5592 let config: RaysenseConfig = toml::from_str(
5593 r#"
5594[rules]
5595max_upward_layer_violations = 1
5596
5597[[boundaries.layers]]
5598name = "infra"
5599path = "src/infra/*"
5600order = 0
5601
5602[[boundaries.layers]]
5603name = "api"
5604path = "src/api/*"
5605order = 2
5606"#,
5607 )
5608 .unwrap();
5609
5610 let health = compute_health_with_config(&report, &config);
5611
5612 assert!(health
5613 .rules
5614 .iter()
5615 .any(|rule| rule.code == "max_upward_layer_violations"));
5616 assert_eq!(
5617 health
5618 .rules
5619 .iter()
5620 .filter(|rule| rule.code == "layer_order")
5621 .count(),
5622 2
5623 );
5624 }
5625
5626 fn file(file_id: usize, path: &str) -> FileFact {
5627 FileFact {
5628 file_id,
5629 path: PathBuf::from(path),
5630 language: Language::Rust,
5631 language_name: "rust".to_string(),
5632 module: path.trim_end_matches(".rs").to_string(),
5633 lines: 1,
5634 bytes: 1,
5635 content_hash: String::new(),
5636 comment_lines: 0,
5637 }
5638 }
5639
5640 fn temp_health_root(name: &str) -> PathBuf {
5641 let nanos = SystemTime::now()
5642 .duration_since(UNIX_EPOCH)
5643 .unwrap()
5644 .as_nanos();
5645 std::env::temp_dir().join(format!("raysense-health-{name}-{nanos}"))
5646 }
5647
5648 fn import(
5649 import_id: usize,
5650 from_file: usize,
5651 resolved_file: Option<usize>,
5652 resolution: ImportResolution,
5653 ) -> ImportFact {
5654 ImportFact {
5655 import_id,
5656 from_file,
5657 target: String::new(),
5658 kind: "use".to_string(),
5659 resolution,
5660 resolved_file,
5661 }
5662 }
5663
5664 fn function(function_id: usize, file_id: usize, name: &str) -> FunctionFact {
5665 FunctionFact {
5666 function_id,
5667 file_id,
5668 name: name.to_string(),
5669 start_line: 1,
5670 end_line: 1,
5671 }
5672 }
5673
5674 fn call(
5675 call_id: usize,
5676 file_id: usize,
5677 caller_function: Option<usize>,
5678 target: &str,
5679 ) -> CallFact {
5680 CallFact {
5681 call_id,
5682 file_id,
5683 caller_function,
5684 target: target.to_string(),
5685 line: 1,
5686 }
5687 }
5688
5689 fn call_edge(
5690 edge_id: usize,
5691 call_id: usize,
5692 caller_function: usize,
5693 callee_function: usize,
5694 ) -> CallEdgeFact {
5695 CallEdgeFact {
5696 edge_id,
5697 call_id,
5698 caller_function,
5699 callee_function,
5700 }
5701 }
5702
5703 fn complexity_metric(file_id: usize, path: &str, value: usize) -> FunctionComplexityMetric {
5704 FunctionComplexityMetric {
5705 function_id: 0,
5706 file_id,
5707 path: path.to_string(),
5708 name: format!("fn_{file_id}"),
5709 value,
5710 cognitive_value: value,
5711 }
5712 }
5713
5714 #[test]
5715 fn temporal_hotspots_rank_by_churn_times_complexity() {
5716 let mut file_commits: BTreeMap<String, usize> = BTreeMap::new();
5717 file_commits.insert("src/hot.rs".to_string(), 12);
5718 file_commits.insert("src/quiet.rs".to_string(), 1);
5719 file_commits.insert("src/simple.rs".to_string(), 50);
5720 file_commits.insert("src/orphan.rs".to_string(), 3);
5721
5722 let complexity = ComplexityMetrics {
5723 all_functions: vec![
5724 complexity_metric(0, "src/hot.rs", 4),
5725 complexity_metric(0, "src/hot.rs", 9),
5726 complexity_metric(1, "src/quiet.rs", 20),
5727 complexity_metric(2, "src/simple.rs", 1),
5728 ],
5729 ..ComplexityMetrics::default()
5730 };
5731
5732 let hotspots = temporal_hotspots(&file_commits, &complexity);
5733
5734 assert_eq!(hotspots.len(), 3, "orphan.rs has no complexity → dropped");
5735 assert!(
5736 hotspots.iter().all(|h| h.path != "src/orphan.rs"),
5737 "files with no functions must not appear",
5738 );
5739
5740 let top = &hotspots[0];
5741 assert_eq!(top.path, "src/hot.rs");
5742 assert_eq!(top.commits, 12);
5743 assert_eq!(
5744 top.max_complexity, 9,
5745 "uses max function complexity per file"
5746 );
5747 assert_eq!(top.risk_score, 12 * 9);
5748
5749 let simple = hotspots.iter().find(|h| h.path == "src/simple.rs").unwrap();
5750 let quiet = hotspots.iter().find(|h| h.path == "src/quiet.rs").unwrap();
5751 assert_eq!(simple.risk_score, 50);
5752 assert_eq!(quiet.risk_score, 20);
5753 assert!(
5754 hotspots[1].risk_score >= hotspots[2].risk_score,
5755 "results are sorted by risk_score descending",
5756 );
5757 }
5758
5759 #[test]
5760 fn file_ages_rank_oldest_first_and_drop_invalid() {
5761 const DAY: i64 = 86_400;
5762 let now: i64 = 100 * DAY;
5763 let mut window: BTreeMap<String, (i64, i64)> = BTreeMap::new();
5764 window.insert("ancient.rs".to_string(), (10 * DAY, 90 * DAY));
5765 window.insert("recent.rs".to_string(), (95 * DAY, 99 * DAY));
5766 window.insert("middle.rs".to_string(), (50 * DAY, 60 * DAY));
5767 window.insert("future.rs".to_string(), (110 * DAY, 110 * DAY));
5769 window.insert("zero.rs".to_string(), (0, 0));
5771
5772 let ages = file_ages(&window, now);
5773
5774 assert_eq!(ages.len(), 3, "future.rs and zero.rs must be skipped");
5775 assert_eq!(ages[0].path, "ancient.rs");
5776 assert_eq!(ages[0].age_days, 90);
5777 assert_eq!(ages[0].last_changed_days, 10);
5778 assert_eq!(ages[1].path, "middle.rs");
5779 assert_eq!(ages[2].path, "recent.rs");
5780 assert_eq!(ages[2].age_days, 5);
5781 }
5782
5783 #[test]
5784 fn file_ages_returns_empty_when_now_is_unknown() {
5785 let mut window: BTreeMap<String, (i64, i64)> = BTreeMap::new();
5786 window.insert("a.rs".to_string(), (1, 2));
5787 assert!(file_ages(&window, 0).is_empty());
5788 }
5789
5790 #[test]
5791 fn change_coupling_ranks_pairs_by_jaccard_above_min_threshold() {
5792 let mut pair_counts: BTreeMap<(String, String), usize> = BTreeMap::new();
5793 pair_counts.insert(("a.rs".to_string(), "b.rs".to_string()), 5);
5794 pair_counts.insert(("a.rs".to_string(), "c.rs".to_string()), 4);
5795 pair_counts.insert(("b.rs".to_string(), "c.rs".to_string()), 2);
5796 pair_counts.insert(("d.rs".to_string(), "e.rs".to_string()), 3);
5797
5798 let mut file_commits: BTreeMap<String, usize> = BTreeMap::new();
5799 file_commits.insert("a.rs".to_string(), 5);
5800 file_commits.insert("b.rs".to_string(), 5);
5801 file_commits.insert("c.rs".to_string(), 6);
5802 file_commits.insert("d.rs".to_string(), 3);
5803 file_commits.insert("e.rs".to_string(), 3);
5804
5805 let pairs = change_coupling(&pair_counts, &file_commits);
5806
5807 assert_eq!(
5808 pairs.len(),
5809 3,
5810 "the 2-co-commit pair is below MIN_CO_COMMITS"
5811 );
5812 assert_eq!(pairs[0].left, "a.rs");
5813 assert_eq!(pairs[0].right, "b.rs");
5814 assert!(
5815 (pairs[0].coupling_strength - 1.0).abs() < 1e-9,
5816 "always co-changed"
5817 );
5818 let de = pairs.iter().find(|p| p.left == "d.rs").unwrap();
5819 assert!((de.coupling_strength - 1.0).abs() < 1e-9);
5820 let ac = pairs
5821 .iter()
5822 .find(|p| p.left == "a.rs" && p.right == "c.rs")
5823 .unwrap();
5824 assert!(ac.coupling_strength < 1.0);
5825 }
5826
5827 #[test]
5828 fn change_coupling_returns_empty_when_no_pair_meets_threshold() {
5829 let mut pair_counts: BTreeMap<(String, String), usize> = BTreeMap::new();
5830 pair_counts.insert(("a.rs".to_string(), "b.rs".to_string()), 1);
5831 let mut file_commits: BTreeMap<String, usize> = BTreeMap::new();
5832 file_commits.insert("a.rs".to_string(), 1);
5833 file_commits.insert("b.rs".to_string(), 1);
5834 let pairs = change_coupling(&pair_counts, &file_commits);
5835 assert!(pairs.is_empty());
5836 }
5837
5838 #[test]
5839 fn language_override_wins_over_global_for_complexity_limit() {
5840 let py_file = file(0, "src/util.py");
5841 let rs_file = file(1, "src/lib.rs");
5842 let mut config = RaysenseConfig::default();
5843 config.rules.max_function_complexity = 50; config.rules.language_overrides.insert(
5845 "python".to_string(),
5846 LanguageRuleOverride {
5847 max_function_complexity: Some(8),
5848 ..LanguageRuleOverride::default()
5849 },
5850 );
5851 let mut py = py_file;
5854 py.language_name = "python".to_string();
5855
5856 assert_eq!(function_complexity_limit(&py, &config), 8);
5857 assert_eq!(function_complexity_limit(&rs_file, &config), 50);
5858 }
5859
5860 #[test]
5861 fn language_override_falls_through_to_plugin_then_global() {
5862 let mut file_a = file(0, "src/a.go");
5863 file_a.language_name = "go".to_string();
5864 let mut file_b = file(1, "src/b.go");
5865 file_b.language_name = "go".to_string();
5866 let mut config = RaysenseConfig::default();
5867 config.rules.max_file_lines = 1000;
5868 config.scan.plugins.push(LanguagePluginConfig {
5870 name: "go".to_string(),
5871 max_file_lines: Some(600),
5872 ..LanguagePluginConfig::default()
5873 });
5874 assert_eq!(file_line_limit(&file_a, &config), Some(600));
5876 config.rules.language_overrides.insert(
5878 "go".to_string(),
5879 LanguageRuleOverride {
5880 max_file_lines: Some(200),
5881 ..LanguageRuleOverride::default()
5882 },
5883 );
5884 assert_eq!(file_line_limit(&file_b, &config), Some(200));
5885 }
5886
5887 #[test]
5888 fn temporal_hotspots_skip_zero_risk() {
5889 let mut file_commits: BTreeMap<String, usize> = BTreeMap::new();
5890 file_commits.insert("src/zero.rs".to_string(), 0);
5891 file_commits.insert("src/some.rs".to_string(), 4);
5892
5893 let complexity = ComplexityMetrics {
5894 all_functions: vec![
5895 complexity_metric(0, "src/zero.rs", 5),
5896 complexity_metric(1, "src/some.rs", 0),
5897 ],
5898 ..ComplexityMetrics::default()
5899 };
5900
5901 let hotspots = temporal_hotspots(&file_commits, &complexity);
5902 assert!(
5903 hotspots.is_empty(),
5904 "either factor being zero means no risk score",
5905 );
5906 }
5907}