Skip to main content

raysense/
health.rs

1/*
2 *   Copyright (c) 2025-2026 Anton Kundenko <singaraiona@gmail.com>
3 *   All rights reserved.
4 *
5 *   Permission is hereby granted, free of charge, to any person obtaining a copy
6 *   of this software and associated documentation files (the "Software"), to deal
7 *   in the Software without restriction, including without limitation the rights
8 *   to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 *   copies of the Software, and to permit persons to whom the Software is
10 *   furnished to do so, subject to the following conditions:
11 *
12 *   The above copyright notice and this permission notice shall be included in all
13 *   copies or substantial portions of the Software.
14 *
15 *   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 *   IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 *   FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 *   AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 *   LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 *   OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 *   SOFTWARE.
22 */
23
24use 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            // Default 0.0 keeps existing scores byte-exact; raise to opt the
109            // structural-distribution dimension into quality_signal.
110            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    /// Files whose contents declare path aliases (e.g. `tsconfig.json`,
140    /// `.cargo/config.toml`). Consumed by import resolution.
141    pub resolver_alias_files: Vec<String>,
142    /// Module-name separator used by the language (e.g. "." for Python,
143    /// "::" for Rust). Used when joining/splitting module paths.
144    pub namespace_separator: Option<String>,
145    /// File names that introduce a module by their location (e.g.
146    /// `mod.rs`, `__init__.py`).
147    pub module_prefix_files: Vec<String>,
148    /// Source-line directives that declare a module name (e.g. `package `,
149    /// `module `).
150    pub module_prefix_directives: Vec<String>,
151    /// Symbol names that should be treated as entry points (e.g. `main`,
152    /// `init`).
153    pub entry_point_patterns: Vec<String>,
154    /// Path patterns matching test modules (in addition to `test_path_patterns`).
155    pub test_module_patterns: Vec<String>,
156    /// Source-line attributes/decorators that mark a function as a test
157    /// (e.g. `#[test]`, `@Test`).
158    pub test_attribute_patterns: Vec<String>,
159    /// Tree-sitter node kinds representing function parameter declarations.
160    pub parameter_node_kinds: Vec<String>,
161    /// Tree-sitter node kinds that increment cyclomatic complexity (`if`,
162    /// `while`, `match_arm`, ...).
163    pub complexity_node_kinds: Vec<String>,
164    /// Tree-sitter node kinds for logical operators that contribute to
165    /// cognitive complexity (`&&`, `||`).
166    pub logical_operator_kinds: Vec<String>,
167    /// Built-in or well-known abstract base class names for the language.
168    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    /// Per-language overrides keyed by `language_name` (case-insensitive).
246    /// Each override field, when set, takes precedence over the matching
247    /// global field for files in that language. Useful when one language's
248    /// idioms make a global threshold either too strict or too lax (e.g.
249    /// Rust `match` arms inflate cyclomatic vs Python's flat conditionals).
250    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    /// Per-dimension drift, first sample to last. Keys are the
579    /// `RootCauseScores` field names (`modularity`, `acyclicity`,
580    /// `depth`, `equality`, `redundancy`, `structural_uniformity`).
581    /// Values are float deltas in `[-1, 1]`. Empty when older samples
582    /// lack `root_causes`.
583    #[serde(default)]
584    pub dimension_deltas: BTreeMap<String, f64>,
585    /// Full ordered series (oldest first). Empty in serde-default
586    /// state. Each sample now also carries its `root_causes` and a
587    /// human-readable timestamp; older samples written before this
588    /// field landed still parse via `#[serde(default)]`.
589    #[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    /// Count of sampled commits whose subject matches a bug-fix pattern
650    /// (`^(fix|bugfix|hotfix|revert)(\([^)]*\))?[:!]?\s`).
651    #[serde(default)]
652    pub bug_fix_commits: usize,
653    /// Top files ranked by absolute bug-fix-commit count, then by ratio.
654    #[serde(default)]
655    pub bug_prone_files: Vec<EvolutionBugProneFile>,
656    /// Top files ranked by composite edit-risk score: the next agent
657    /// edit is most likely to break files that are volatile, complex,
658    /// owned by one person, and lack nearby tests.
659    #[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/// `risk_score = commits * max_cyclomatic_complexity` - high values flag files
676/// that are both volatile and intricate.
677#[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/// Per-file commit-age window. Timestamps are bounded by the git log lookback,
686/// so `first_commit_unix` is the oldest commit *within the sample*, not
687/// necessarily the file's true creation date.
688#[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/// Pair of files that change together. `coupling_strength` is the Jaccard
698/// similarity of their commit sets in `[0, 1]` (1.0 = always co-changed).
699#[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/// Per-file bug-fix concentration. Files with a high `bug_fix_ratio`
708/// are unstable areas of the codebase: most of their churn is undoing
709/// previous changes rather than adding capability.
710#[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/// Composite edit-risk score per file. Combines four signals already
719/// present elsewhere in the report:
720///
721///   risk = commits * max_complexity * bus_inverse * test_gap_factor
722///
723/// where `bus_inverse = 1.0 + 1.0 / max(bus_factor, 1)` (so a single
724/// owner doubles the score, two owners give 1.5x, five give 1.2x), and
725/// `test_gap_factor = 1.5` when the file has no nearby tests, else 1.0.
726/// Files unchanged in the sampled history naturally score 0 and are
727/// dropped, since the metric is about which files the *next* edit is
728/// most likely to break.
729#[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    /// Minimum number of authors needed to cover at least 80% of commits to
747    /// this file. Lower values mean higher key-person risk.
748    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    // Combined structural-distribution health: average of file-size and
1549    // function-complexity normalized entropy. Higher = more variety across
1550    // log-buckets / complexity values, lower = monoculture or pathological
1551    // concentration. Treated as 0..1 so it composes with the other dimensions
1552    // when its weight is set.
1553    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
2109// Log-scale buckets so wildly different file sizes spread across distinct bins
2110// (e.g. 1000-line and 1100-line files share a bucket; 100 and 1000 do not).
2111fn 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            // The subject (`%s`) can contain '|' characters, so it must be
2762            // the last field. Use `splitn(4, '|')` so the fourth split takes
2763            // the rest of the line verbatim.
2764            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    // Order by key-person risk: lowest bus_factor first, then by churn.
2849    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
2885/// Subject-line classifier for bug-fix commits. Recognises the
2886/// Conventional Commits `fix:` prefix plus the common `bugfix`,
2887/// `hotfix`, and `revert` variants. Matches case-insensitively against
2888/// the start of the trimmed subject; a recognised prefix must be
2889/// followed by `:`, `!`, `(`, or whitespace so that words like
2890/// `fixing` or `feature` do not produce false positives.
2891fn 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
2904/// Compute the composite edit-risk score per file. See the
2905/// `EvolutionEditRiskFile` doc comment for the formula. Returns the
2906/// top 20 by `risk_score` desc, then commits desc, then path.
2907fn 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            // bus_factor missing -> default to 1 (most pessimistic = single owner).
2946            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
2977/// Top files ranked by absolute bug-fix-commit count, then by
2978/// bug-fix ratio, then by path. Files with zero bug-fix commits are
2979/// dropped. Top 20 returned.
2980fn 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
3016/// Files with at least 3 co-commits, ranked by Jaccard similarity. Pairs that
3017/// only ever appear together are at strength 1.0; pairs that share a few
3018/// commits but each change independently are much lower.
3019fn 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
3057/// Build the top-N oldest files within the git log window. Returns at most 20
3058/// entries sorted by `age_days` descending. Files with a zero or future
3059/// timestamp (clock skew, missing data) are skipped.
3060fn 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
3092/// Cross-reference commit churn with cyclomatic complexity to surface files
3093/// that are both volatile and intricate. Risk = commits × max-cyclomatic;
3094/// files with risk == 0 (no commits or trivial complexity) are dropped.
3095fn 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
3155/// Cap on files-per-commit considered for pair counting. A merge or
3156/// repo-wide rename touches hundreds of files but expresses no real coupling
3157/// signal; capping keeps pair generation `O(N²)` bounded.
3158const 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    // Per-dimension drift, first to last. Only emit when both
3228    // endpoint samples actually carry root_causes data (older samples
3229    // recorded before that field landed default to all-zero, which
3230    // would synthesise a misleading delta).
3231    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        // Two files at ~1 line, two files at ~1024 lines: two equally-populated
4517        // log2 buckets, so normalized entropy is 1.0 and absolute is log2(2) = 1.0 bits.
4518        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        // Same file count, same large_files/long_functions, but very different
4527        // size distributions. With the default ScoreConfig (structural_uniformity_weight = 0),
4528        // the two reports must produce byte-identical score / quality_signal.
4529        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        // Score 0.92 would be A by default, but B under stricter thresholds.
4579        assert_eq!(grade_for(0.92, &thresholds), "B");
4580        // Score 0.74 would be C by default, but D under stricter thresholds.
4581        assert_eq!(grade_for(0.74, &thresholds), "D");
4582    }
4583
4584    #[test]
4585    fn is_bug_fix_subject_recognises_conventional_and_common_prefixes() {
4586        // Conventional Commits "fix" forms.
4587        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")); // whitespace after prefix
4593
4594        // Common variants.
4595        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\"")); // git's default revert subject
4599
4600        // Indented / leading whitespace still counts.
4601        assert!(is_bug_fix_subject("  fix: leading spaces"));
4602
4603        // Negatives - words that start with `fix*` but are not fix commits.
4604        assert!(!is_bug_fix_subject("feat: add validator"));
4605        assert!(!is_bug_fix_subject("fixing the parser is hard")); // "fixing", not "fix"
4606        assert!(!is_bug_fix_subject("fixtures: regenerate snapshots")); // "fixtures"
4607        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        // All-zero RootCauseScores (e.g. from old samples that pre-date the
4614        // root_causes field) is treated as "no data".
4615        assert!(!has_root_causes(&RootCauseScores::default()));
4616        // Any non-zero dimension flips it to "has data".
4617        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        // 3 candidate files. We synthesise the four input signals.
4627        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        // Per-file max complexity, expressed via fake function records.
4633        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        // bus_factor: risky owned by one author, owned by 2, safe by 5.
4664        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        // test gap: only risky lacks nearby tests.
4692        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        // Expected scores:
4709        //   risky:  10 * 50 * (1 + 1/1) * 1.5  = 1500.0
4710        //   owned:  10 * 50 * (1 + 1/2) * 1.0  =  750.0
4711        //   safe:   10 * 50 * (1 + 1/5) * 1.0  =  600.0
4712        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        // Files with zero bug fixes are dropped.
4747        assert!(!ranked.iter().any(|e| e.path == "src/quiet.rs"));
4748
4749        // First by absolute fix count, then by ratio. parser (8 fixes) leads;
4750        // among the two files with 5 fixes, lexer (5/8=0.625) outranks
4751        // cli (5/20=0.25).
4752        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        // Ratio is computed against total_commits, not fix-only commits.
4756        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        // Single author owns all commits → bus factor 1.
4763        let one = "alice".to_string();
4764        let only_alice = vec![(&one, &10usize)];
4765        assert_eq!(bus_factor_for(&only_alice, 10), 1);
4766
4767        // 5+5 split → top author owns 50%; need both for 80% coverage.
4768        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        // 9+1 split → top author owns 90% (>= 80%) → bus factor 1.
4774        let dominant = vec![(&alice, &9usize), (&bob, &1usize)];
4775        assert_eq!(bus_factor_for(&dominant, 10), 1);
4776
4777        // No commits → bus factor 0.
4778        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        // No functions in this synthetic report, so complexity_entropy is 0.
4786        // file_size_entropy is 1.0 (two equally-populated log buckets).
4787        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        // Two reports with different structural uniformity values. With the
4868        // default weight (0.0) their quality_signal must match. With a non-zero
4869        // weight the score must shift in the direction of the higher-uniformity
4870        // report - that is the explicit opt-in behavior change.
4871        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        // Future timestamp from clock skew is dropped.
5768        window.insert("future.rs".to_string(), (110 * DAY, 110 * DAY));
5769        // Zero timestamp (no data) is dropped.
5770        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; // global ceiling
5844        config.rules.language_overrides.insert(
5845            "python".to_string(),
5846            LanguageRuleOverride {
5847                max_function_complexity: Some(8),
5848                ..LanguageRuleOverride::default()
5849            },
5850        );
5851        // Need to set the language_name on the test files since `file()` factory
5852        // uses Language::Rust by default - make a Python-named one.
5853        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        // Plugin sets a Go-specific limit of 600.
5869        config.scan.plugins.push(LanguagePluginConfig {
5870            name: "go".to_string(),
5871            max_file_lines: Some(600),
5872            ..LanguagePluginConfig::default()
5873        });
5874        // No language_overrides entry → plugin should win.
5875        assert_eq!(file_line_limit(&file_a, &config), Some(600));
5876        // Add a language override of 200 → it wins over the plugin.
5877        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}