Skip to main content

garbage_code_hunter/
signals.rs

1// StyleSignal — maps rule issues to behavioral style signals.
2
3use crate::analyzer::CodeIssue;
4use crate::language::Language;
5use crate::style_ir::StyleIr;
6use crate::treesitter::engine::ParsedFile;
7use std::collections::HashMap;
8
9// ── Signal Detector trait ─────────────────────────────────────────
10
11/// Direct signal detector that produces StyleSignal scores from parsed code.
12///
13/// Unlike rules that produce CodeIssues, a SignalDetector directly scores
14/// a file for a specific style signal without going through Rule → Issue pipeline.
15/// This enables the "few rules + strong aggregation" approach by replacing many
16/// small rules with one signal detector per behavioral dimension.
17pub trait SignalDetector: Send + Sync {
18    fn signal(&self) -> StyleSignal;
19    fn supported_languages(&self) -> &'static [Language];
20
21    /// Run detection on a parsed file. Returns the number of signal violations found.
22    fn count_violations(&self, file: &ParsedFile) -> usize;
23
24    /// Run detection using a pre-computed StyleIr (avoids redundant from_parsed calls).
25    /// Default: falls back to count_violations which recomputes the IR.
26    fn count_violations_with_ir(&self, _ir: &StyleIr, file: &ParsedFile) -> usize {
27        self.count_violations(file)
28    }
29
30    /// Whether this detector should be skipped for test files.
31    /// Override to `false` for signals that apply to test code too.
32    fn skips_test_files(&self) -> bool {
33        true
34    }
35
36    /// Produce per-file signal findings with violation counts.
37    ///
38    /// `is_test_file`: whether the file is identified as test code.
39    /// `skip_tests_config`: user config flag to skip tests (from config.toml).
40    ///
41    /// Default implementation: if the file is a test file AND the detector
42    /// skips tests AND the user config agrees, returns empty.
43    /// Otherwise wraps `count_violations` into a `(signal, count)` pair.
44    fn detect_findings(
45        &self,
46        file: &ParsedFile,
47        is_test_file: bool,
48        skip_tests_config: bool,
49    ) -> Vec<(StyleSignal, usize)> {
50        let skip = is_test_file && self.skips_test_files() && skip_tests_config;
51        let count = if skip { 0 } else { self.count_violations(file) };
52        if count > 0 {
53            vec![(self.signal(), count)]
54        } else {
55            vec![]
56        }
57    }
58
59    /// Produce per-file signal findings using a pre-computed StyleIr.
60    fn detect_findings_with_ir(
61        &self,
62        ir: &StyleIr,
63        file: &ParsedFile,
64        is_test_file: bool,
65        skip_tests_config: bool,
66    ) -> Vec<(StyleSignal, usize)> {
67        let skip = is_test_file && self.skips_test_files() && skip_tests_config;
68        let count = if skip {
69            0
70        } else {
71            self.count_violations_with_ir(ir, file)
72        };
73        if count > 0 {
74            vec![(self.signal(), count)]
75        } else {
76            vec![]
77        }
78    }
79}
80
81/// Helpers for converting raw violations to density-normalized signal scores.
82pub fn violations_to_score(count: usize, total_lines: usize) -> f64 {
83    let k_lines = (total_lines as f64 / 1000.0).max(0.001);
84    let density = count as f64 / k_lines;
85    ((density + 1.0).log2() * 6.0).min(25.0)
86}
87
88/// Aggregate scores from all detectors across all parsed files.
89pub fn aggregate_detector_scores(
90    detectors: &[Box<dyn SignalDetector>],
91    files: &[ParsedFile],
92    is_test_files: &[bool],
93    skip_tests_config: bool,
94) -> HashMap<StyleSignal, f64> {
95    let mut total_counts: HashMap<StyleSignal, usize> = HashMap::new();
96    let mut total_lines: HashMap<StyleSignal, usize> = HashMap::new();
97
98    for (i, file) in files.iter().enumerate() {
99        let is_test = is_test_files.get(i).copied().unwrap_or(false);
100        let lang = file.language;
101        let ir = StyleIr::from_parsed(file);
102        for detector in detectors {
103            if !detector.supported_languages().contains(&lang) {
104                continue;
105            }
106            let signal = detector.signal();
107            let skip = is_test && detector.skips_test_files() && skip_tests_config;
108            let raw = if skip {
109                0
110            } else if let Some(ref ir) = ir {
111                detector.count_violations_with_ir(ir, file)
112            } else {
113                detector.count_violations(file)
114            };
115            let count = if is_test {
116                (raw as f64 * 0.2).round() as usize
117            } else {
118                raw
119            };
120            *total_counts.entry(signal).or_insert(0) += count;
121            *total_lines.entry(signal).or_insert(0) += file.content.lines().count();
122        }
123    }
124
125    let mut scores = HashMap::new();
126    for (signal, count) in total_counts {
127        let lines = total_lines.get(&signal).copied().unwrap_or(1);
128        scores.insert(signal, violations_to_score(count, lines));
129    }
130    scores
131}
132
133pub use crate::detectors::{
134    CodeSmellsDetector, DuplicationDetector, LegacyCodeDetector, LineCountSmellDetector,
135    NamingChaosDetector, NestedHellDetector, PanicAddictionDetector, TodoMountainDetector,
136};
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
139pub enum StyleSignal {
140    Duplication,
141    PanicAddiction,
142    NamingChaos,
143    NestedHell,
144    HotfixCulture,
145    OverEngineering,
146    CodeSmells,
147    LegacyCode,
148    TodoMountain,
149    LineCountSmell,
150}
151
152impl StyleSignal {
153    pub fn all() -> &'static [StyleSignal] {
154        &[
155            StyleSignal::Duplication,
156            StyleSignal::PanicAddiction,
157            StyleSignal::NamingChaos,
158            StyleSignal::NestedHell,
159            StyleSignal::HotfixCulture,
160            StyleSignal::OverEngineering,
161            StyleSignal::CodeSmells,
162            StyleSignal::LegacyCode,
163            StyleSignal::TodoMountain,
164            StyleSignal::LineCountSmell,
165        ]
166    }
167
168    pub fn display_name(&self) -> &'static str {
169        match self {
170            StyleSignal::Duplication => "Duplication",
171            StyleSignal::PanicAddiction => "Panic Addiction",
172            StyleSignal::NamingChaos => "Naming Chaos",
173            StyleSignal::NestedHell => "Nested Hell",
174            StyleSignal::HotfixCulture => "Hotfix Culture",
175            StyleSignal::OverEngineering => "Over-Engineering",
176            StyleSignal::CodeSmells => "Code Smells",
177            StyleSignal::LegacyCode => "Legacy Code",
178            StyleSignal::TodoMountain => "Todo Mountain",
179            StyleSignal::LineCountSmell => "Line Count Smell",
180        }
181    }
182
183    pub fn display_name_zh(&self) -> String {
184        match self {
185            StyleSignal::Duplication => "重复代码",
186            StyleSignal::PanicAddiction => "恐慌成瘾",
187            StyleSignal::NamingChaos => "命名混乱",
188            StyleSignal::NestedHell => "嵌套地狱",
189            StyleSignal::HotfixCulture => "热修复文化",
190            StyleSignal::OverEngineering => "过度工程",
191            StyleSignal::CodeSmells => "代码异味",
192            StyleSignal::LegacyCode => "遗留代码",
193            StyleSignal::TodoMountain => "待办堆积",
194            StyleSignal::LineCountSmell => "文件过长",
195        }
196        .to_string()
197    }
198}
199
200// ── Language Capability Matrix ────────────────────────────────────
201
202/// Tracks which StyleSignals are detectable per language.
203///
204/// This matrix answers "Can we detect signal X in language Y?"
205/// through two paths:
206///   - `supported_signals()` — via classify_rule on issue rule names
207///   - `direct_signals()` — via SignalDetector (direct AST analysis)
208///
209/// All languages with a tree-sitter grammar currently support all 7
210/// signals through the cross-language rules (deep-nesting, long-function,
211/// god-function, file-too-long, todo-comment, commented-code, dead-code,
212/// terrible-naming, single-letter-variable, meaningless-naming,
213/// hungarian-notation, abbreviation-abuse, magic-number, println-debugging).
214/// Language-specific rules add precision but don't expand the signal set.
215pub struct LanguageCapabilityMatrix;
216
217impl LanguageCapabilityMatrix {
218    /// Returns StyleSignals that have at least one rule for this language.
219    pub fn supported_signals(lang: Language) -> &'static [StyleSignal] {
220        if lang.has_tree_sitter_grammar() {
221            StyleSignal::all()
222        } else {
223            &[]
224        }
225    }
226
227    /// Returns signals that have a direct SignalDetector implementation.
228    pub fn direct_signals(lang: Language) -> &'static [StyleSignal] {
229        static ALL_DIRECT: &[StyleSignal] = &[
230            StyleSignal::Duplication,
231            StyleSignal::CodeSmells,
232            StyleSignal::PanicAddiction,
233            StyleSignal::NamingChaos,
234            StyleSignal::NestedHell,
235            StyleSignal::HotfixCulture,
236            StyleSignal::OverEngineering,
237            StyleSignal::LegacyCode,
238            StyleSignal::TodoMountain,
239            StyleSignal::LineCountSmell,
240        ];
241        if lang.has_tree_sitter_grammar() {
242            ALL_DIRECT
243        } else {
244            &[]
245        }
246    }
247
248    /// Returns true if the given signal is detectable for this language.
249    pub fn supports_signal(lang: Language, signal: StyleSignal) -> bool {
250        Self::supported_signals(lang).contains(&signal)
251    }
252
253    /// Returns true if the given signal has a direct detector for this language.
254    pub fn has_direct_detector(lang: Language, signal: StyleSignal) -> bool {
255        Self::direct_signals(lang).contains(&signal)
256    }
257}
258
259pub fn classify_rule(rule_name: &str) -> StyleSignal {
260    match rule_name {
261        "code-duplication" | "cross-file-duplication" => StyleSignal::Duplication,
262        "unwrap-abuse" | "panic-abuse" | "bare-except" | "bare-rescue" | "empty-catch"
263        | "println-debugging" => StyleSignal::PanicAddiction,
264        "terrible-naming"
265        | "single-letter-variable"
266        | "meaningless-naming"
267        | "hungarian-notation"
268        | "abbreviation-abuse"
269        | "c-naming"
270        | "go-receiver-name"
271        | "go-mixed-caps"
272        | "ruby-predicate-method"
273        | "python-naming"
274        | "constant-name" => StyleSignal::NamingChaos,
275        "deep-nesting"
276        | "cyclomatic-complexity"
277        | "c-nesting"
278        | "complex-closure"
279        | "go-else-return"
280        | "negated-if" => StyleSignal::NestedHell,
281        "commented-code" | "c-commented-code" | "dead-code" | "c-dead-code" => {
282            StyleSignal::LegacyCode
283        }
284        "todo-comment" | "todo-fixme" | "todo-bug" | "todo-hack" => StyleSignal::TodoMountain,
285        "too-many-params" | "god-function" | "long-function" | "c-long-function"
286        | "c-god-function" | "module-complexity" | "trait-complexity" | "generic-abuse" => {
287            StyleSignal::OverEngineering
288        }
289        "file-too-long" => StyleSignal::LineCountSmell,
290        _ => StyleSignal::CodeSmells,
291    }
292}
293
294// ── Style IR: StyleProfile ────────────────────────────────────────
295
296/// StyleProfile is the intermediate representation between signal scores and
297/// personality inference. It holds the normalized signal vector and derives
298/// the dominant personality type from it.
299#[derive(Debug, Clone)]
300pub struct StyleProfile {
301    pub signal_scores: HashMap<StyleSignal, f64>,
302    pub dominant_signal: Option<StyleSignal>,
303}
304
305impl StyleProfile {
306    /// Build a StyleProfile from a signal-scores map.
307    pub fn from_signal_scores(signal_scores: HashMap<StyleSignal, f64>) -> Self {
308        let dominant_signal = StyleSignal::all()
309            .iter()
310            .max_by(|a, b| {
311                let sa = signal_scores.get(a).copied().unwrap_or(0.0);
312                let sb = signal_scores.get(b).copied().unwrap_or(0.0);
313                sa.partial_cmp(&sb).unwrap_or(std::cmp::Ordering::Equal)
314            })
315            .copied();
316        Self {
317            signal_scores,
318            dominant_signal,
319        }
320    }
321
322    /// Build a StyleProfile from raw signal counts (e.g. from classify_rule).
323    /// Scores are set directly from counts so dominant_signal reflects the
324    /// most frequent signal, matching the behavior of personality/profiles.rs.
325    pub fn from_signal_counts(counts: HashMap<StyleSignal, u32>) -> Self {
326        let max_count = counts.values().copied().max().unwrap_or(1).max(1) as f64;
327        let signal_scores: HashMap<StyleSignal, f64> = counts
328            .iter()
329            .map(|(s, &c)| (*s, c as f64 / max_count * 25.0))
330            .collect();
331        Self::from_signal_scores(signal_scores)
332    }
333
334    pub fn score(&self, signal: StyleSignal) -> f64 {
335        self.signal_scores.get(&signal).copied().unwrap_or(0.0)
336    }
337
338    /// Infer personality type from the signal vector.
339    ///
340    /// Mapping (scores range 0-25):
341    ///   - ≥ 12 → high (dominant)
342    ///   - ≥ 6  → medium (present)
343    ///   - < 6  → low
344    pub fn infer_personality_type(&self) -> &'static str {
345        let dup = self.score(StyleSignal::Duplication);
346        let panic = self.score(StyleSignal::PanicAddiction);
347        let naming = self.score(StyleSignal::NamingChaos);
348        let nested = self.score(StyleSignal::NestedHell);
349        let hotfix = self.score(StyleSignal::HotfixCulture);
350        let over_eng = self.score(StyleSignal::OverEngineering);
351
352        // Single high-dominance signals
353        if dup >= 12.0 && dup >= panic && dup >= naming && dup >= nested {
354            return "The Copy-Paste Artist";
355        }
356        if panic >= 12.0 && panic >= dup && panic >= naming && panic >= nested {
357            return "The YOLO Engineer";
358        }
359        if nested >= 12.0 && nested >= naming && nested >= hotfix {
360            return "The Trait Wizard";
361        }
362        if naming >= 12.0 && naming >= nested {
363            return "The Legacy Necromancer";
364        }
365        if hotfix >= 12.0 {
366            return "The Hotfix Mercenary";
367        }
368
369        // Compound personalities (medium signals)
370        if dup >= 6.0 && panic >= 6.0 {
371            return "The Startup Survivor";
372        }
373        if (naming >= 6.0 && nested >= 6.0) || over_eng >= 12.0 {
374            return "The Academic Wizard";
375        }
376        if over_eng >= 6.0 {
377            return "The Academic Wizard";
378        }
379
380        "The Enterprise Bureaucrat"
381    }
382}
383
384pub fn compute_signal_scores(
385    issues: &[CodeIssue],
386    total_lines: usize,
387) -> HashMap<StyleSignal, f64> {
388    let k_lines = total_lines as f64 / 1000.0;
389    let mut counts: HashMap<StyleSignal, usize> = HashMap::new();
390
391    for issue in issues {
392        let signal = classify_rule(&issue.rule_name);
393        *counts.entry(signal).or_insert(0) += 1;
394    }
395
396    let mut scores = HashMap::new();
397    for signal in StyleSignal::all() {
398        let count = counts.get(signal).copied().unwrap_or(0);
399        let density = if k_lines > 0.0 {
400            count as f64 / k_lines
401        } else {
402            0.0
403        };
404        let score = ((density + 1.0).log2() * 6.0).min(25.0);
405        scores.insert(*signal, score);
406    }
407
408    scores
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use std::path::PathBuf;
415
416    fn make_issue(rule_name: &str) -> CodeIssue {
417        CodeIssue {
418            file_path: PathBuf::from("test.rs"),
419            line: 1,
420            column: 0,
421            rule_name: rule_name.to_string(),
422            message: String::new(),
423            severity: crate::analyzer::Severity::Spicy,
424        }
425    }
426
427    // ── display_name ──────────────────────────────────────────────
428
429    /// Objective: Verify all signals have unique non-empty English display names.
430    #[test]
431    fn test_display_name_all_variants() {
432        let mut names = std::collections::HashSet::new();
433        for s in StyleSignal::all() {
434            let name = s.display_name();
435            assert!(!name.is_empty(), "{:?}.display_name should not be empty", s);
436            assert!(
437                names.insert(name),
438                "{:?}.display_name '{}' is not unique",
439                s,
440                name
441            );
442        }
443    }
444
445    /// Objective: Verify all signals have unique non-empty Chinese display names.
446    #[test]
447    fn test_display_name_zh_all_variants() {
448        let mut names = std::collections::HashSet::new();
449        for s in StyleSignal::all() {
450            let name = s.display_name_zh();
451            assert!(
452                !name.is_empty(),
453                "{:?}.display_name_zh should not be empty",
454                s
455            );
456            assert!(
457                names.insert(name.clone()),
458                "{:?}.display_name_zh '{}' is not unique",
459                s,
460                name
461            );
462        }
463    }
464
465    // ── classify_rule: all branches ───────────────────────────────
466
467    /// Objective: Verify all Duplication rule names map correctly.
468    #[test]
469    fn test_classify_duplication_all() {
470        assert_eq!(
471            classify_rule("code-duplication"),
472            StyleSignal::Duplication,
473            "code-duplication"
474        );
475        assert_eq!(
476            classify_rule("cross-file-duplication"),
477            StyleSignal::Duplication,
478            "cross-file-duplication"
479        );
480    }
481
482    /// Objective: Verify all PanicAddiction rule names map correctly.
483    #[test]
484    fn test_classify_panic_all() {
485        for name in &[
486            "unwrap-abuse",
487            "panic-abuse",
488            "bare-except",
489            "bare-rescue",
490            "empty-catch",
491            "println-debugging",
492        ] {
493            assert_eq!(
494                classify_rule(name),
495                StyleSignal::PanicAddiction,
496                "{name} should map to PanicAddiction"
497            );
498        }
499    }
500
501    /// Objective: Verify all NamingChaos rule names map correctly.
502    #[test]
503    fn test_classify_naming_all() {
504        for name in &[
505            "terrible-naming",
506            "single-letter-variable",
507            "meaningless-naming",
508            "hungarian-notation",
509            "abbreviation-abuse",
510            "c-naming",
511            "go-receiver-name",
512            "go-mixed-caps",
513            "ruby-predicate-method",
514            "python-naming",
515            "constant-name",
516        ] {
517            assert_eq!(
518                classify_rule(name),
519                StyleSignal::NamingChaos,
520                "{name} should map to NamingChaos"
521            );
522        }
523    }
524
525    /// Objective: Verify all NestedHell rule names map correctly.
526    #[test]
527    fn test_classify_nested_all() {
528        for name in &[
529            "deep-nesting",
530            "cyclomatic-complexity",
531            "c-nesting",
532            "complex-closure",
533            "go-else-return",
534            "negated-if",
535        ] {
536            assert_eq!(
537                classify_rule(name),
538                StyleSignal::NestedHell,
539                "{name} should map to NestedHell"
540            );
541        }
542    }
543
544    /// Objective: Verify all LegacyCode rule names map correctly.
545    #[test]
546    fn test_classify_legacy_code() {
547        for name in &[
548            "commented-code",
549            "c-commented-code",
550            "dead-code",
551            "c-dead-code",
552        ] {
553            assert_eq!(
554                classify_rule(name),
555                StyleSignal::LegacyCode,
556                "{name} should map to LegacyCode"
557            );
558        }
559    }
560
561    /// Objective: Verify all TodoMountain rule names map correctly.
562    #[test]
563    fn test_classify_todo_mountain() {
564        for name in &["todo-comment", "todo-fixme", "todo-bug", "todo-hack"] {
565            assert_eq!(
566                classify_rule(name),
567                StyleSignal::TodoMountain,
568                "{name} should map to TodoMountain"
569            );
570        }
571    }
572
573    /// Objective: Verify all OverEngineering rule names map correctly.
574    #[test]
575    fn test_classify_over_engineering_all() {
576        for name in &[
577            "too-many-params",
578            "god-function",
579            "long-function",
580            "c-long-function",
581            "c-god-function",
582            "module-complexity",
583            "trait-complexity",
584            "generic-abuse",
585        ] {
586            assert_eq!(
587                classify_rule(name),
588                StyleSignal::OverEngineering,
589                "{name} should map to OverEngineering"
590            );
591        }
592    }
593
594    /// Objective: Verify LineCountSmell rule names map correctly.
595    #[test]
596    fn test_classify_line_count_smell() {
597        assert_eq!(classify_rule("file-too-long"), StyleSignal::LineCountSmell);
598    }
599
600    /// Objective: Verify CodeSmells fallback for unknown and known non-mapped rules.
601    #[test]
602    fn test_classify_code_smells_fallback() {
603        assert_eq!(classify_rule("magic-number"), StyleSignal::CodeSmells);
604        assert_eq!(classify_rule("unknown-rule"), StyleSignal::CodeSmells);
605        assert_eq!(classify_rule(""), StyleSignal::CodeSmells);
606        assert_eq!(classify_rule("rust-doc-example"), StyleSignal::CodeSmells);
607    }
608
609    // ── LanguageCapabilityMatrix ──────────────────────────────────
610
611    /// Objective: Verify all grammar languages support all 10 signals.
612    /// Invariants: supported_signals returns StyleSignal::all() for grammar languages.
613    #[test]
614    fn test_matrix_supported_all_grammar_languages() {
615        for lang in crate::language::LANGUAGES_WITH_GRAMMAR {
616            let sigs = LanguageCapabilityMatrix::supported_signals(*lang);
617            assert_eq!(
618                sigs.len(),
619                10,
620                "{} should support 10 signals",
621                lang.display_name()
622            );
623        }
624    }
625
626    /// Objective: Verify Unknown language returns empty supported signals.
627    #[test]
628    fn test_matrix_supported_unknown() {
629        let sigs = LanguageCapabilityMatrix::supported_signals(Language::Unknown);
630        assert!(sigs.is_empty(), "Unknown should have no supported signals");
631    }
632
633    /// Objective: Verify supports_signal returns true for a known pairing.
634    #[test]
635    fn test_matrix_supports_signal_rust_panic() {
636        assert!(LanguageCapabilityMatrix::supports_signal(
637            Language::Rust,
638            StyleSignal::PanicAddiction
639        ));
640    }
641
642    /// Objective: Verify supports_signal returns false for Unknown.
643    #[test]
644    fn test_matrix_supports_signal_unknown() {
645        assert!(!LanguageCapabilityMatrix::supports_signal(
646            Language::Unknown,
647            StyleSignal::PanicAddiction
648        ));
649    }
650
651    /// Objective: Verify direct_signals returns all 10 signals for Rust.
652    #[test]
653    fn test_matrix_direct_signals_rust() {
654        let sigs = LanguageCapabilityMatrix::direct_signals(Language::Rust);
655        for signal in StyleSignal::all() {
656            assert!(
657                sigs.contains(signal),
658                "Rust should have direct {}",
659                signal.display_name()
660            );
661        }
662        assert_eq!(sigs.len(), 10, "Rust has all 10 direct signals");
663    }
664
665    /// Objective: Verify direct_signals returns all 10 signals for Go.
666    #[test]
667    fn test_matrix_direct_signals_go() {
668        let sigs = LanguageCapabilityMatrix::direct_signals(Language::Go);
669        for signal in StyleSignal::all() {
670            assert!(
671                sigs.contains(signal),
672                "Go should have direct {}",
673                signal.display_name()
674            );
675        }
676        assert_eq!(sigs.len(), 10, "Go has all 10 direct signals");
677    }
678
679    /// Objective: Verify direct_signals returns all 10 signals for Python.
680    #[test]
681    fn test_matrix_direct_signals_python() {
682        let sigs = LanguageCapabilityMatrix::direct_signals(Language::Python);
683        for signal in StyleSignal::all() {
684            assert!(
685                sigs.contains(signal),
686                "Python should have direct {}",
687                signal.display_name()
688            );
689        }
690        assert_eq!(sigs.len(), 10, "Python has all 10 direct signals");
691    }
692
693    /// Objective: Verify has_direct_detector matches direct_signals.
694    #[test]
695    fn test_matrix_has_direct_detector_rust() {
696        assert!(LanguageCapabilityMatrix::has_direct_detector(
697            Language::Rust,
698            StyleSignal::PanicAddiction
699        ));
700        assert!(LanguageCapabilityMatrix::has_direct_detector(
701            Language::Swift,
702            StyleSignal::PanicAddiction
703        ));
704        assert!(LanguageCapabilityMatrix::has_direct_detector(
705            Language::Zig,
706            StyleSignal::PanicAddiction
707        ));
708        assert!(!LanguageCapabilityMatrix::has_direct_detector(
709            Language::Unknown,
710            StyleSignal::PanicAddiction
711        ));
712    }
713
714    // ── compute_signal_scores ─────────────────────────────────────
715
716    /// Objective: Verify empty issues produce all zeros.
717    /// Invariants: All 10 signals present, each exactly 0.0.
718    #[test]
719    fn test_compute_signal_scores_empty() {
720        let scores = compute_signal_scores(&[], 1000);
721        assert_eq!(scores.len(), 10, "all 10 signals present");
722        for s in StyleSignal::all() {
723            assert!(
724                (scores[s] - 0.0).abs() < f64::EPSILON,
725                "empty issues => {s:?} = {}",
726                scores[s]
727            );
728        }
729    }
730
731    /// Objective: Verify mixed issues produce expected ordering.
732    /// Invariants: More issues in a category => higher score for that category.
733    #[test]
734    fn test_compute_signal_scores_mixed() {
735        let issues = vec![
736            make_issue("unwrap-abuse"),
737            make_issue("unwrap-abuse"),
738            make_issue("deep-nesting"),
739            make_issue("terrible-naming"),
740        ];
741        let scores = compute_signal_scores(&issues, 1000);
742        assert!(
743            scores[&StyleSignal::PanicAddiction] > scores[&StyleSignal::NamingChaos],
744            "2 panics should score higher than 1 naming"
745        );
746        assert!(
747            scores[&StyleSignal::PanicAddiction] > scores[&StyleSignal::NestedHell],
748            "2 panics should score higher than 1 nesting"
749        );
750    }
751
752    /// Objective: Verify compute_signal_scores handles zero total_lines without division by zero.
753    /// Invariants: k_lines = 0, density = 0, score = log2(0+1)*6 = 0.
754    #[test]
755    fn test_compute_signal_scores_zero_lines() {
756        let issues = vec![make_issue("unwrap-abuse")];
757        let scores = compute_signal_scores(&issues, 0);
758        // With 0 lines, k_lines = 0, density = 0/0 = 0 (the code branches to 0.0)
759        // score = (0+1).log2() * 6 = 0, clamped to min(25) = 0... wait log2(1) = 0, so 0 * 6 = 0
760        assert!(
761            scores.values().all(|&s| s >= 0.0),
762            "zero lines should not produce NaN or negative"
763        );
764        assert!(
765            scores[&StyleSignal::PanicAddiction] > 0.0
766                || (scores[&StyleSignal::PanicAddiction] - 0.0).abs() < f64::EPSILON,
767            "score with zero lines should be >= 0"
768        );
769    }
770
771    /// Objective: Verify score cap at 25.0 for high-density signals.
772    #[test]
773    fn test_compute_signal_scores_capped() {
774        // 1000 panics in 1 line → extremely high density → capped
775        let issues: Vec<_> = (0..1000).map(|_| make_issue("unwrap-abuse")).collect();
776        let scores = compute_signal_scores(&issues, 1);
777        assert!(
778            scores[&StyleSignal::PanicAddiction] <= 25.0,
779            "score should be capped at 25, got {}",
780            scores[&StyleSignal::PanicAddiction]
781        );
782    }
783
784    /// Objective: Verify each category contributes independently.
785    /// Invariants: Only the category with issues should have a non-zero score.
786    #[test]
787    fn test_compute_signal_scores_category_independence() {
788        let issues = vec![make_issue("deep-nesting")];
789        let scores = compute_signal_scores(&issues, 1000);
790        assert!(
791            scores[&StyleSignal::NestedHell] > 0.0,
792            "NestedHell should be non-zero"
793        );
794        for s in StyleSignal::all() {
795            if *s != StyleSignal::NestedHell {
796                assert!(
797                    (scores[s] - 0.0).abs() < f64::EPSILON,
798                    "only NestedHell should be non-zero, but {s:?} = {}",
799                    scores[s]
800                );
801            }
802        }
803    }
804
805    /// Objective: Verify the density formula: higher density with same count = higher score.
806    /// Invariants: Same issues in fewer lines => higher score.
807    #[test]
808    fn test_compute_signal_scores_density_scaling() {
809        let issues = vec![make_issue("unwrap-abuse")];
810        let sparse = compute_signal_scores(&issues, 100_000); // low density
811        let dense = compute_signal_scores(&issues, 10); // high density
812        assert!(
813            dense[&StyleSignal::PanicAddiction] > sparse[&StyleSignal::PanicAddiction],
814            "dense (10 lines) should score higher than sparse (100k lines)"
815        );
816    }
817
818    // ── StyleProfile ─────────────────────────────────────────────
819
820    fn make_profile(scores: &[(StyleSignal, f64)]) -> StyleProfile {
821        let map: HashMap<StyleSignal, f64> = scores.iter().cloned().collect();
822        StyleProfile::from_signal_scores(map)
823    }
824
825    /// Objective: Verify empty signal_scores produce default-0 scores.
826    /// Invariants: When all scores are equal (all 0), max_by returns the last variant (LineCountSmell).
827    #[test]
828    fn test_style_profile_empty() {
829        let p = StyleProfile::from_signal_scores(HashMap::new());
830        assert_eq!(p.dominant_signal, Some(StyleSignal::LineCountSmell));
831        assert_eq!(p.score(StyleSignal::Duplication), 0.0);
832        assert_eq!(p.infer_personality_type(), "The Enterprise Bureaucrat");
833    }
834
835    /// Objective: Verify high Duplication => Copy-Paste Artist.
836    #[test]
837    fn test_style_profile_copy_paste() {
838        let p = make_profile(&[
839            (StyleSignal::Duplication, 15.0),
840            (StyleSignal::PanicAddiction, 3.0),
841        ]);
842        assert_eq!(p.dominant_signal, Some(StyleSignal::Duplication));
843        assert_eq!(p.infer_personality_type(), "The Copy-Paste Artist");
844    }
845
846    /// Objective: Verify high PanicAddiction => YOLO Engineer.
847    #[test]
848    fn test_style_profile_yolo() {
849        let p = make_profile(&[
850            (StyleSignal::PanicAddiction, 15.0),
851            (StyleSignal::NamingChaos, 3.0),
852        ]);
853        assert_eq!(p.infer_personality_type(), "The YOLO Engineer");
854    }
855
856    /// Objective: Verify high NestedHell => Trait Wizard.
857    #[test]
858    fn test_style_profile_trait_wizard() {
859        let p = make_profile(&[
860            (StyleSignal::NestedHell, 15.0),
861            (StyleSignal::NamingChaos, 2.0),
862        ]);
863        assert_eq!(p.infer_personality_type(), "The Trait Wizard");
864    }
865
866    /// Objective: Verify high NamingChaos => Legacy Necromancer.
867    #[test]
868    fn test_style_profile_legacy_necromancer() {
869        let p = make_profile(&[
870            (StyleSignal::NamingChaos, 15.0),
871            (StyleSignal::NestedHell, 3.0),
872        ]);
873        assert_eq!(p.infer_personality_type(), "The Legacy Necromancer");
874    }
875
876    /// Objective: Verify high HotfixCulture => Hotfix Mercenary.
877    #[test]
878    fn test_style_profile_hotfix() {
879        let p = make_profile(&[(StyleSignal::HotfixCulture, 15.0)]);
880        assert_eq!(p.infer_personality_type(), "The Hotfix Mercenary");
881    }
882
883    /// Objective: Verify medium Duplication + medium PanicAddiction => Startup Survivor.
884    #[test]
885    fn test_style_profile_startup() {
886        let p = make_profile(&[
887            (StyleSignal::Duplication, 8.0),
888            (StyleSignal::PanicAddiction, 7.0),
889        ]);
890        assert_eq!(p.infer_personality_type(), "The Startup Survivor");
891    }
892
893    /// Objective: Verify medium NamingChaos + medium NestedHell => Academic Wizard.
894    /// Invariants: compound pattern takes precedence over individual medium scores.
895    #[test]
896    fn test_style_profile_academic_compound() {
897        let p = make_profile(&[
898            (StyleSignal::NamingChaos, 8.0),
899            (StyleSignal::NestedHell, 7.0),
900        ]);
901        assert_eq!(p.infer_personality_type(), "The Academic Wizard");
902    }
903
904    /// Objective: Verify high OverEngineering => Academic Wizard.
905    #[test]
906    fn test_style_profile_academic_over_eng() {
907        let p = make_profile(&[(StyleSignal::OverEngineering, 15.0)]);
908        assert_eq!(p.infer_personality_type(), "The Academic Wizard");
909    }
910
911    /// Objective: Verify all scores < 6 => Enterprise Bureaucrat.
912    #[test]
913    fn test_style_profile_enterprise() {
914        let p = make_profile(&[
915            (StyleSignal::Duplication, 4.0),
916            (StyleSignal::HotfixCulture, 3.0),
917        ]);
918        assert_eq!(p.infer_personality_type(), "The Enterprise Bureaucrat");
919    }
920
921    /// Objective: Verify all scores 0 => Enterprise Bureaucrat.
922    #[test]
923    fn test_style_profile_all_zero() {
924        let p = make_profile(&[
925            (StyleSignal::Duplication, 0.0),
926            (StyleSignal::PanicAddiction, 0.0),
927            (StyleSignal::NamingChaos, 0.0),
928            (StyleSignal::NestedHell, 0.0),
929            (StyleSignal::HotfixCulture, 0.0),
930            (StyleSignal::OverEngineering, 0.0),
931            (StyleSignal::CodeSmells, 0.0),
932            (StyleSignal::LegacyCode, 0.0),
933            (StyleSignal::TodoMountain, 0.0),
934            (StyleSignal::LineCountSmell, 0.0),
935        ]);
936        // All equal at 0, the last in `all()` (LineCountSmell) wins as dominant
937        assert_eq!(p.dominant_signal, Some(StyleSignal::LineCountSmell));
938        assert_eq!(p.infer_personality_type(), "The Enterprise Bureaucrat");
939    }
940
941    // ── SignalDetector — PanicAddictionDetector ────────────────────
942
943    use crate::treesitter::engine::TreeSitterEngine;
944
945    fn parse_rust(code: &str) -> ParsedFile {
946        let engine = TreeSitterEngine::new();
947        engine
948            .parse_file(std::path::Path::new("test.rs"), code)
949            .expect("Rust parse should succeed")
950    }
951
952    /// Objective: Verify violations_to_score returns 0 for 0 violations.
953    #[test]
954    fn test_violations_to_score_zero() {
955        let score = violations_to_score(0, 1000);
956        assert!(
957            (score - 0.0).abs() < f64::EPSILON,
958            "0 violations => score 0, got {score}"
959        );
960    }
961
962    /// Objective: Verify violations_to_score increases with more violations.
963    #[test]
964    fn test_violations_to_score_increasing() {
965        let low = violations_to_score(1, 1000);
966        let high = violations_to_score(10, 1000);
967        assert!(
968            high > low,
969            "more violations => higher score, {high} <= {low}"
970        );
971    }
972
973    /// Objective: Verify violations_to_score caps at 25.0.
974    #[test]
975    fn test_violations_to_score_capped() {
976        let score = violations_to_score(1_000_000, 1);
977        assert!(score <= 25.0, "score should be capped at 25, got {score}");
978    }
979
980    /// Objective: Verify aggregate_detector_scores works across multiple files.
981    #[test]
982    fn test_aggregate_detector_scores() {
983        let files = vec![parse_rust("fn a() { let x = v.unwrap(); }")];
984        let test_flags = vec![false];
985        let detectors: Vec<Box<dyn SignalDetector>> = vec![Box::new(PanicAddictionDetector::new())];
986        let scores = aggregate_detector_scores(&detectors, &files, &test_flags, true);
987        let panic_score = scores
988            .get(&StyleSignal::PanicAddiction)
989            .copied()
990            .unwrap_or(0.0);
991        assert!(
992            panic_score > 0.0,
993            "PanicAddiction score should be > 0, got {panic_score}"
994        );
995        assert!(
996            panic_score <= 25.0,
997            "PanicAddiction score should be <= 25, got {panic_score}"
998        );
999    }
1000}