1use crate::CoverageModel;
4
5pub const HOTSPOT_SCORE_THRESHOLD: f64 = 50.0;
6
7pub const COGNITIVE_EXTRACTION_THRESHOLD: u16 = 30;
8
9pub const DEFAULT_COGNITIVE_HIGH: u16 = 25;
10
11pub const DEFAULT_COGNITIVE_CRITICAL: u16 = 40;
12
13pub const DEFAULT_CYCLOMATIC_HIGH: u16 = 30;
14
15pub const DEFAULT_CYCLOMATIC_CRITICAL: u16 = 50;
16
17pub const MI_DENSITY_MIN_LINES: f64 = 50.0;
19
20pub const HEALTH_SCORE_FORMULA_VERSION: u32 = 2;
21
22pub const STYLING_HEALTH_FORMULA_VERSION: u32 = 3;
34
35#[expect(
38 clippy::trivially_copy_pass_by_ref,
39 reason = "serde skip_serializing_if requires a by-reference predicate"
40)]
41fn is_zero_u16(value: &u16) -> bool {
42 *value == 0
43}
44
45#[derive(Debug, Clone, serde::Serialize)]
46#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
47pub struct HealthScore {
48 pub formula_version: u32,
49 pub score: f64,
50 pub grade: &'static str,
51 pub penalties: HealthScorePenalties,
52}
53
54#[derive(Debug, Clone, serde::Serialize)]
56#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
57pub struct HealthScorePenalties {
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub dead_files: Option<f64>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub dead_exports: Option<f64>,
62 pub complexity: f64,
63 pub p90_complexity: f64,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub maintainability: Option<f64>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub hotspots: Option<f64>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub unused_deps: Option<f64>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub circular_deps: Option<f64>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub unit_size: Option<f64>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub coupling: Option<f64>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub duplication: Option<f64>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub prop_drilling: Option<f64>,
82}
83
84#[derive(Debug, Clone, serde::Serialize)]
94#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
95pub struct StylingHealth {
96 pub formula_version: u32,
97 pub score: f64,
98 pub grade: &'static str,
99 pub penalties: StylingHealthPenalties,
100 pub confidence: StylingHealthConfidence,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub confidence_reason: Option<String>,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
130#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
131#[serde(rename_all = "lowercase")]
132pub enum StylingHealthConfidence {
133 High,
136 Low,
142}
143
144#[derive(Debug, Clone, serde::Serialize)]
151#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
152pub struct StylingHealthPenalties {
153 pub duplication: f64,
156 pub dead_surface: f64,
163 pub broken_references: f64,
167 pub token_erosion: f64,
173 pub structural: f64,
176}
177
178#[must_use]
180#[expect(
181 clippy::cast_possible_truncation,
182 reason = "score is 0-100, fits in u32"
183)]
184pub const fn letter_grade(score: f64) -> &'static str {
185 let s = score as u32;
186 if s >= 85 {
187 "A"
188 } else if s >= 70 {
189 "B"
190 } else if s >= 55 {
191 "C"
192 } else if s >= 40 {
193 "D"
194 } else {
195 "F"
196 }
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
201#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
202#[serde(rename_all = "snake_case")]
203pub enum CoverageTier {
204 None,
205 Partial,
206 High,
207}
208
209const HIGH_COVERAGE_WATERMARK: f64 = 70.0;
211
212impl CoverageTier {
213 #[must_use]
215 pub fn from_pct(pct: f64) -> Self {
216 if pct <= 0.0 {
217 Self::None
218 } else if pct >= HIGH_COVERAGE_WATERMARK {
219 Self::High
220 } else {
221 Self::Partial
222 }
223 }
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
228#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
229#[serde(rename_all = "snake_case")]
230pub enum CoverageSource {
231 Istanbul,
232 Estimated,
233 EstimatedComponentInherited,
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
238#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
239#[serde(rename_all = "snake_case")]
240pub enum CoverageSourceConsistency {
241 Uniform,
242 Mixed,
243}
244
245#[must_use]
247pub fn summarize_coverage_source_consistency(
248 sources: impl IntoIterator<Item = CoverageSource>,
249) -> Option<CoverageSourceConsistency> {
250 let mut first = None;
251 for source in sources {
252 match first {
253 None => first = Some(source),
254 Some(existing) if existing != source => {
255 return Some(CoverageSourceConsistency::Mixed);
256 }
257 Some(_) => {}
258 }
259 }
260 first.map(|_| CoverageSourceConsistency::Uniform)
261}
262
263#[derive(Debug, Clone, serde::Serialize)]
276#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
277pub struct ReactHookProfile {
278 pub state: u16,
280 pub effect: u16,
282 pub memo: u16,
284 pub callback: u16,
286 pub custom: u16,
288 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub max_effect_dep_arity: Option<u32>,
294}
295
296impl ReactHookProfile {
297 #[must_use]
300 pub fn total(&self) -> u16 {
301 self.state
302 .saturating_add(self.effect)
303 .saturating_add(self.memo)
304 .saturating_add(self.callback)
305 .saturating_add(self.custom)
306 }
307
308 #[must_use]
310 pub fn is_empty(&self) -> bool {
311 self.total() == 0
312 }
313}
314
315#[derive(Debug, Clone, serde::Serialize)]
317#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
318pub struct ComplexityViolation {
319 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
320 pub path: std::path::PathBuf,
321 pub name: String,
322 pub line: u32,
323 pub col: u32,
324 pub cyclomatic: u16,
325 pub cognitive: u16,
326 pub line_count: u32,
327 pub param_count: u8,
328 #[serde(default, skip_serializing_if = "is_zero_u16")]
332 pub react_hook_count: u16,
333 #[serde(default, skip_serializing_if = "is_zero_u16")]
336 pub react_jsx_max_depth: u16,
337 #[serde(default, skip_serializing_if = "is_zero_u16")]
340 pub react_prop_count: u16,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub react_hook_profile: Option<ReactHookProfile>,
348 pub exceeded: ExceededThreshold,
349 pub severity: FindingSeverity,
350 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub crap: Option<f64>,
352 #[serde(default, skip_serializing_if = "Option::is_none")]
353 pub coverage_pct: Option<f64>,
354 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub coverage_tier: Option<CoverageTier>,
356 #[serde(default, skip_serializing_if = "Option::is_none")]
357 pub coverage_source: Option<CoverageSource>,
358 #[serde(
359 default,
360 serialize_with = "fallow_types::serde_path::serialize_option",
361 skip_serializing_if = "Option::is_none"
362 )]
363 pub inherited_from: Option<std::path::PathBuf>,
364 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub component_rollup: Option<ComponentRollup>,
366 #[serde(default, skip_serializing_if = "Vec::is_empty")]
371 pub contributions: Vec<fallow_types::extract::ComplexityContribution>,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub effective_thresholds: Option<HealthEffectiveThresholds>,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub threshold_source: Option<ThresholdSource>,
379}
380
381pub const DEFAULT_MAX_UNIT_SIZE: u32 = 60;
385
386#[derive(Debug, Clone, Copy, serde::Serialize)]
388#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
389#[allow(
390 clippy::struct_field_names,
391 reason = "target-dependent clippy lint; wire fields mirror max_* config keys"
392)]
393pub struct HealthEffectiveThresholds {
394 pub max_cyclomatic: u16,
395 pub max_cognitive: u16,
396 pub max_crap: f64,
397 pub max_unit_size: u32,
401}
402
403#[derive(Debug, Clone, Copy, serde::Serialize)]
405#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
406#[allow(
407 clippy::struct_field_names,
408 reason = "target-dependent clippy lint; wire fields mirror max_* config keys"
409)]
410pub struct HealthConfiguredThresholds {
411 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub max_cyclomatic: Option<u16>,
413 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub max_cognitive: Option<u16>,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub max_crap: Option<f64>,
417 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub max_unit_size: Option<u32>,
419}
420
421#[derive(Debug, Clone, Copy, serde::Serialize)]
423#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
424#[serde(rename_all = "snake_case")]
425pub enum ThresholdSource {
426 Override,
427}
428
429#[derive(Debug, Clone, Copy, serde::Serialize)]
431#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
432#[serde(rename_all = "snake_case")]
433pub enum ThresholdOverrideStatus {
434 Active,
435 Stale,
436 NoMatch,
437}
438
439#[derive(Debug, Clone, Copy, serde::Serialize)]
441#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
442pub struct ThresholdOverrideMetrics {
443 pub cyclomatic: u16,
444 pub cognitive: u16,
445 #[serde(default, skip_serializing_if = "Option::is_none")]
446 pub crap: Option<f64>,
447}
448
449#[derive(Debug, Clone, serde::Serialize)]
452#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
453pub struct ThresholdOverrideState {
454 pub status: ThresholdOverrideStatus,
455 pub override_index: usize,
456 #[serde(
457 default,
458 serialize_with = "fallow_types::serde_path::serialize_option",
459 skip_serializing_if = "Option::is_none"
460 )]
461 pub path: Option<std::path::PathBuf>,
462 #[serde(default, skip_serializing_if = "Option::is_none")]
463 pub function: Option<String>,
464 pub configured_thresholds: HealthConfiguredThresholds,
465 pub effective_thresholds: HealthEffectiveThresholds,
466 #[serde(default, skip_serializing_if = "Option::is_none")]
467 pub metrics: Option<ThresholdOverrideMetrics>,
468 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub reason: Option<String>,
470}
471
472#[derive(Debug, Clone, serde::Serialize)]
473#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
474pub struct ComponentRollup {
475 pub component: String,
476 pub class_worst_function: String,
477 pub class_cyclomatic: u16,
478 pub class_cognitive: u16,
479 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
480 pub template_path: std::path::PathBuf,
481 pub template_cyclomatic: u16,
482 pub template_cognitive: u16,
483}
484
485#[derive(Debug, Clone, Copy, serde::Serialize)]
487#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
488#[serde(rename_all = "snake_case")]
489pub enum ExceededThreshold {
490 Cyclomatic,
492 Cognitive,
494 Both,
496 Crap,
498 CyclomaticCrap,
500 CognitiveCrap,
502 All,
504}
505
506impl ExceededThreshold {
507 #[must_use]
513 pub fn from_bools(cyclomatic: bool, cognitive: bool, crap: bool) -> Self {
514 match (cyclomatic, cognitive, crap) {
515 (true, true, true) => Self::All,
516 (true, true, false) => Self::Both,
517 (true, false, true) => Self::CyclomaticCrap,
518 (false, true, true) => Self::CognitiveCrap,
519 (true, false, false) => Self::Cyclomatic,
520 (false, true, false) => Self::Cognitive,
521 (false, false, true) => Self::Crap,
522 (false, false, false) => {
523 unreachable!("ExceededThreshold requires at least one threshold exceeded")
524 }
525 }
526 }
527
528 #[must_use]
530 pub const fn includes_cyclomatic(self) -> bool {
531 matches!(
532 self,
533 Self::Cyclomatic | Self::Both | Self::CyclomaticCrap | Self::All
534 )
535 }
536
537 #[must_use]
539 pub const fn includes_cognitive(self) -> bool {
540 matches!(
541 self,
542 Self::Cognitive | Self::Both | Self::CognitiveCrap | Self::All
543 )
544 }
545
546 #[must_use]
548 pub const fn includes_crap(self) -> bool {
549 matches!(
550 self,
551 Self::Crap | Self::CyclomaticCrap | Self::CognitiveCrap | Self::All
552 )
553 }
554}
555
556#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
561#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
562#[serde(rename_all = "snake_case")]
563pub enum FindingSeverity {
564 Moderate,
566 High,
568 Critical,
570}
571
572pub const DEFAULT_CRAP_HIGH: f64 = 50.0;
574
575pub const DEFAULT_CRAP_CRITICAL: f64 = 100.0;
579
580#[expect(
586 clippy::too_many_arguments,
587 reason = "public library API for napi/embedders; the metric values and their high/critical threshold pairs are a stable positional contract that bundling would break"
588)]
589pub fn compute_finding_severity(
590 cognitive: u16,
591 cyclomatic: u16,
592 crap: Option<f64>,
593 cognitive_high: u16,
594 cognitive_critical: u16,
595 cyclomatic_high: u16,
596 cyclomatic_critical: u16,
597) -> FindingSeverity {
598 let cog = if cognitive >= cognitive_critical {
599 FindingSeverity::Critical
600 } else if cognitive >= cognitive_high {
601 FindingSeverity::High
602 } else {
603 FindingSeverity::Moderate
604 };
605
606 let cyc = if cyclomatic >= cyclomatic_critical {
607 FindingSeverity::Critical
608 } else if cyclomatic >= cyclomatic_high {
609 FindingSeverity::High
610 } else {
611 FindingSeverity::Moderate
612 };
613
614 let crap_sev = crap.map_or(FindingSeverity::Moderate, |c| {
615 if c >= DEFAULT_CRAP_CRITICAL {
616 FindingSeverity::Critical
617 } else if c >= DEFAULT_CRAP_HIGH {
618 FindingSeverity::High
619 } else {
620 FindingSeverity::Moderate
621 }
622 });
623
624 cog.max(cyc).max(crap_sev)
625}
626
627#[derive(Debug, Clone, serde::Serialize)]
629#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
630pub struct LargeFunctionEntry {
631 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
632 pub path: std::path::PathBuf,
633 pub name: String,
634 pub line: u32,
635 pub line_count: u32,
636}
637
638#[derive(Debug, Clone, serde::Serialize)]
640#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
641pub struct HealthSummary {
642 pub files_analyzed: usize,
643 pub functions_analyzed: usize,
644 pub functions_above_threshold: usize,
645 pub max_cyclomatic_threshold: u16,
646 pub max_cognitive_threshold: u16,
647 pub max_crap_threshold: f64,
648 pub max_unit_size_threshold: u32,
654 #[serde(default, skip_serializing_if = "Option::is_none")]
655 pub files_scored: Option<usize>,
656 #[serde(default, skip_serializing_if = "Option::is_none")]
657 pub average_maintainability: Option<f64>,
658 #[serde(default, skip_serializing_if = "Option::is_none")]
659 pub coverage_model: Option<CoverageModel>,
660 #[serde(default, skip_serializing_if = "Option::is_none")]
661 pub coverage_source_consistency: Option<CoverageSourceConsistency>,
662 #[serde(default, skip_serializing_if = "Option::is_none")]
663 pub istanbul_matched: Option<usize>,
664 #[serde(default, skip_serializing_if = "Option::is_none")]
665 pub istanbul_total: Option<usize>,
666 pub severity_critical_count: usize,
667 pub severity_high_count: usize,
668 pub severity_moderate_count: usize,
669}
670
671impl Default for HealthSummary {
672 fn default() -> Self {
673 Self {
674 files_analyzed: 0,
675 functions_analyzed: 0,
676 functions_above_threshold: 0,
677 max_cyclomatic_threshold: 20,
678 max_cognitive_threshold: 15,
679 max_crap_threshold: 30.0,
680 max_unit_size_threshold: DEFAULT_MAX_UNIT_SIZE,
681 files_scored: None,
682 average_maintainability: None,
683 coverage_model: None,
684 coverage_source_consistency: None,
685 istanbul_matched: None,
686 istanbul_total: None,
687 severity_critical_count: 0,
688 severity_high_count: 0,
689 severity_moderate_count: 0,
690 }
691 }
692}
693
694#[derive(Debug, Clone, serde::Serialize)]
696#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
697pub struct FileHealthScore {
698 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
699 pub path: std::path::PathBuf,
700 pub fan_in: usize,
701 pub fan_out: usize,
702 pub dead_code_ratio: f64,
703 pub complexity_density: f64,
704 pub maintainability_index: f64,
705 pub total_cyclomatic: u32,
706 pub total_cognitive: u32,
707 pub function_count: usize,
708 pub lines: u32,
709 pub crap_max: f64,
710 pub crap_above_threshold: usize,
711}
712
713#[derive(Debug, Clone, serde::Serialize)]
715#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
716pub struct HotspotEntry {
717 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
718 pub path: std::path::PathBuf,
719 pub score: f64,
720 pub commits: u32,
721 pub weighted_commits: f64,
722 pub lines_added: u32,
723 pub lines_deleted: u32,
724 pub complexity_density: f64,
725 pub fan_in: usize,
726 pub trend: fallow_types::churn::ChurnTrend,
727 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub ownership: Option<OwnershipMetrics>,
729 #[serde(skip_serializing_if = "std::ops::Not::not")]
730 #[cfg_attr(feature = "schema", schemars(default))]
731 pub is_test_path: bool,
732}
733
734#[derive(Debug, Clone, serde::Serialize)]
735#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
736pub struct ContributorEntry {
737 pub identifier: String,
738 pub format: ContributorIdentifierFormat,
739 pub share: f64,
740 pub stale_days: u64,
741 pub commits: u32,
742}
743
744#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
745#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
746#[serde(rename_all = "kebab-case")]
747pub enum ContributorIdentifierFormat {
748 Raw,
749 Handle,
750 Anonymized,
751 Hash,
752}
753
754#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
755#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
756#[serde(rename_all = "snake_case")]
757pub enum OwnershipState {
758 Active,
759 Unowned,
760 DeclaredInactive,
761 Drifting,
762}
763
764#[derive(Debug, Clone, serde::Serialize)]
765#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
766pub struct OwnershipMetrics {
767 pub bus_factor: u32,
768
769 pub contributor_count: u32,
770
771 pub top_contributor: ContributorEntry,
772
773 #[serde(default, skip_serializing_if = "Vec::is_empty")]
774 #[cfg_attr(feature = "schema", schemars(default))]
775 pub recent_contributors: Vec<ContributorEntry>,
776
777 #[serde(default, skip_serializing_if = "Vec::is_empty")]
778 #[cfg_attr(feature = "schema", schemars(default))]
779 pub suggested_reviewers: Vec<ContributorEntry>,
780
781 #[serde(default, skip_serializing_if = "Option::is_none")]
782 pub declared_owner: Option<String>,
783
784 pub unowned: Option<bool>,
785
786 pub ownership_state: OwnershipState,
787
788 pub drift: bool,
789
790 #[serde(default, skip_serializing_if = "Option::is_none")]
791 pub drift_reason: Option<String>,
792}
793
794#[derive(Debug, Clone, serde::Serialize)]
795#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
796pub struct HotspotSummary {
797 pub since: String,
798 pub min_commits: u32,
799 pub files_analyzed: usize,
800 pub files_excluded: usize,
801 pub shallow_clone: bool,
802}
803
804#[cfg(test)]
805mod tests {
806 use super::*;
807
808 #[test]
809 fn exceeded_threshold_serializes_as_snake_case() {
810 let json = serde_json::to_string(&ExceededThreshold::Both)
811 .expect("threshold variant should serialize");
812 assert_eq!(json, r#""both""#);
813
814 let json = serde_json::to_string(&ExceededThreshold::Cyclomatic)
815 .expect("threshold variant should serialize");
816 assert_eq!(json, r#""cyclomatic""#);
817 }
818
819 #[test]
820 fn exceeded_threshold_all_variants_serialize() {
821 for (variant, expected) in [
822 (ExceededThreshold::Cyclomatic, r#""cyclomatic""#),
823 (ExceededThreshold::Cognitive, r#""cognitive""#),
824 (ExceededThreshold::Both, r#""both""#),
825 (ExceededThreshold::Crap, r#""crap""#),
826 (ExceededThreshold::CyclomaticCrap, r#""cyclomatic_crap""#),
827 (ExceededThreshold::CognitiveCrap, r#""cognitive_crap""#),
828 (ExceededThreshold::All, r#""all""#),
829 ] {
830 let json = serde_json::to_string(&variant).expect("threshold variant should serialize");
831 assert_eq!(json, expected, "wire form for {variant:?} should be stable");
832 }
833 }
834
835 #[test]
836 fn letter_grade_boundaries() {
837 assert_eq!(letter_grade(100.0), "A");
838 assert_eq!(letter_grade(85.0), "A");
839 assert_eq!(letter_grade(84.9), "B");
840 assert_eq!(letter_grade(70.0), "B");
841 assert_eq!(letter_grade(69.9), "C");
842 assert_eq!(letter_grade(55.0), "C");
843 assert_eq!(letter_grade(54.9), "D");
844 assert_eq!(letter_grade(40.0), "D");
845 assert_eq!(letter_grade(39.9), "F");
846 assert_eq!(letter_grade(0.0), "F");
847 }
848
849 #[test]
850 fn coverage_tier_boundaries() {
851 assert_eq!(CoverageTier::from_pct(0.0), CoverageTier::None);
852 assert_eq!(CoverageTier::from_pct(0.1), CoverageTier::Partial);
853 assert_eq!(CoverageTier::from_pct(69.9), CoverageTier::Partial);
854 assert_eq!(CoverageTier::from_pct(70.0), CoverageTier::High);
855 assert_eq!(CoverageTier::from_pct(100.0), CoverageTier::High);
856 }
857
858 #[test]
859 fn hotspot_score_threshold_is_50() {
860 assert!((HOTSPOT_SCORE_THRESHOLD - 50.0).abs() < f64::EPSILON);
861 }
862
863 #[test]
864 fn health_score_serializes_correctly() {
865 let score = HealthScore {
866 formula_version: HEALTH_SCORE_FORMULA_VERSION,
867 score: 78.5,
868 grade: "B",
869 penalties: HealthScorePenalties {
870 dead_files: Some(3.1),
871 dead_exports: Some(6.0),
872 complexity: 0.0,
873 p90_complexity: 0.0,
874 maintainability: None,
875 hotspots: None,
876 unused_deps: Some(5.0),
877 circular_deps: Some(4.0),
878 unit_size: None,
879 coupling: None,
880 duplication: None,
881 prop_drilling: None,
882 },
883 };
884 let json = serde_json::to_string(&score).expect("health score should serialize");
885 let parsed: serde_json::Value =
886 serde_json::from_str(&json).expect("health score JSON should parse");
887 assert_eq!(parsed["formula_version"], HEALTH_SCORE_FORMULA_VERSION);
888 assert_eq!(parsed["score"], 78.5);
889 assert_eq!(parsed["grade"], "B");
890 assert_eq!(parsed["penalties"]["dead_files"], 3.1);
891 assert!(!json.contains("maintainability"));
892 assert!(!json.contains("hotspots"));
893 assert!(!json.contains("duplication"));
894 }
895
896 #[test]
897 fn styling_health_serializes_correctly() {
898 let styling = StylingHealth {
899 formula_version: STYLING_HEALTH_FORMULA_VERSION,
900 score: 72.0,
901 grade: "B",
902 penalties: StylingHealthPenalties {
903 duplication: 12.0,
904 dead_surface: 8.0,
905 broken_references: 4.0,
906 token_erosion: 2.0,
907 structural: 2.0,
908 },
909 confidence: StylingHealthConfidence::High,
910 confidence_reason: None,
911 };
912 let json = serde_json::to_string(&styling).expect("styling health should serialize");
913 let parsed: serde_json::Value =
914 serde_json::from_str(&json).expect("styling health JSON should parse");
915 assert_eq!(parsed["formula_version"], STYLING_HEALTH_FORMULA_VERSION);
916 assert_eq!(parsed["score"], 72.0);
917 assert_eq!(parsed["grade"], "B");
918 assert_eq!(parsed["penalties"]["duplication"], 12.0);
919 assert_eq!(parsed["penalties"]["dead_surface"], 8.0);
920 assert_eq!(parsed["penalties"]["broken_references"], 4.0);
921 assert_eq!(parsed["penalties"]["token_erosion"], 2.0);
922 assert_eq!(parsed["penalties"]["structural"], 2.0);
923 assert_eq!(parsed["confidence"], "high");
925 assert!(parsed.get("confidence_reason").is_none());
926 }
927
928 #[test]
929 fn styling_health_low_confidence_serializes_reason() {
930 let styling = StylingHealth {
931 formula_version: STYLING_HEALTH_FORMULA_VERSION,
932 score: 89.0,
933 grade: "A",
934 penalties: StylingHealthPenalties {
935 duplication: 0.0,
936 dead_surface: 0.0,
937 broken_references: 0.0,
938 token_erosion: 0.0,
939 structural: 0.0,
940 },
941 confidence: StylingHealthConfidence::Low,
942 confidence_reason: Some("graded from only 24 declarations across 2 stylesheets".into()),
943 };
944 let json = serde_json::to_string(&styling).expect("styling health should serialize");
945 let parsed: serde_json::Value =
946 serde_json::from_str(&json).expect("styling health JSON should parse");
947 assert_eq!(parsed["confidence"], "low");
948 assert_eq!(
949 parsed["confidence_reason"],
950 "graded from only 24 declarations across 2 stylesheets"
951 );
952 }
953
954 #[test]
955 fn coverage_model_serializes_as_snake_case() {
956 let json = serde_json::to_string(&CoverageModel::StaticBinary)
957 .expect("coverage model should serialize");
958 assert_eq!(json, r#""static_binary""#);
959
960 let json = serde_json::to_string(&CoverageModel::StaticEstimated)
961 .expect("coverage model should serialize");
962 assert_eq!(json, r#""static_estimated""#);
963
964 let json = serde_json::to_string(&CoverageModel::Istanbul)
965 .expect("coverage model should serialize");
966 assert_eq!(json, r#""istanbul""#);
967 }
968
969 #[test]
970 fn finding_severity_serializes_as_snake_case() {
971 assert_eq!(
972 serde_json::to_string(&FindingSeverity::Moderate)
973 .expect("finding severity should serialize"),
974 r#""moderate""#,
975 );
976 assert_eq!(
977 serde_json::to_string(&FindingSeverity::High)
978 .expect("finding severity should serialize"),
979 r#""high""#,
980 );
981 assert_eq!(
982 serde_json::to_string(&FindingSeverity::Critical)
983 .expect("finding severity should serialize"),
984 r#""critical""#,
985 );
986 }
987
988 #[test]
989 fn finding_severity_ordering() {
990 assert!(FindingSeverity::Moderate < FindingSeverity::High);
991 assert!(FindingSeverity::High < FindingSeverity::Critical);
992 }
993
994 #[test]
995 fn compute_severity_moderate_when_below_high_thresholds() {
996 let severity = compute_finding_severity(20, 25, None, 25, 40, 30, 50);
997 assert_eq!(severity, FindingSeverity::Moderate);
998 }
999
1000 #[test]
1001 fn compute_severity_high_from_cognitive() {
1002 let severity = compute_finding_severity(25, 20, None, 25, 40, 30, 50);
1003 assert_eq!(severity, FindingSeverity::High);
1004 }
1005
1006 #[test]
1007 fn compute_severity_high_from_cyclomatic() {
1008 let severity = compute_finding_severity(20, 30, None, 25, 40, 30, 50);
1009 assert_eq!(severity, FindingSeverity::High);
1010 }
1011
1012 #[test]
1013 fn compute_severity_critical_from_cognitive() {
1014 let severity = compute_finding_severity(40, 20, None, 25, 40, 30, 50);
1015 assert_eq!(severity, FindingSeverity::Critical);
1016 }
1017
1018 #[test]
1019 fn compute_severity_critical_from_cyclomatic() {
1020 let severity = compute_finding_severity(20, 50, None, 25, 40, 30, 50);
1021 assert_eq!(severity, FindingSeverity::Critical);
1022 }
1023
1024 #[test]
1025 fn compute_severity_uses_highest_across_dimensions() {
1026 let severity = compute_finding_severity(45, 20, None, 25, 40, 30, 50);
1027 assert_eq!(severity, FindingSeverity::Critical);
1028 }
1029
1030 #[test]
1031 fn compute_severity_at_exact_boundaries() {
1032 let severity = compute_finding_severity(25, 30, None, 25, 40, 30, 50);
1033 assert_eq!(severity, FindingSeverity::High);
1034
1035 let severity = compute_finding_severity(24, 29, None, 25, 40, 30, 50);
1036 assert_eq!(severity, FindingSeverity::Moderate);
1037
1038 let severity = compute_finding_severity(40, 50, None, 25, 40, 30, 50);
1039 assert_eq!(severity, FindingSeverity::Critical);
1040 }
1041
1042 #[test]
1043 fn compute_severity_crap_contributes_high() {
1044 let severity = compute_finding_severity(10, 10, Some(60.0), 25, 40, 30, 50);
1045 assert_eq!(severity, FindingSeverity::High);
1046 }
1047
1048 #[test]
1049 fn compute_severity_crap_contributes_critical() {
1050 let severity = compute_finding_severity(10, 10, Some(120.0), 25, 40, 30, 50);
1051 assert_eq!(severity, FindingSeverity::Critical);
1052 }
1053
1054 #[test]
1055 fn compute_severity_crap_moderate_under_high() {
1056 let severity = compute_finding_severity(10, 10, Some(30.0), 25, 40, 30, 50);
1057 assert_eq!(severity, FindingSeverity::Moderate);
1058 }
1059
1060 #[test]
1061 fn exceeded_threshold_from_bools() {
1062 assert!(matches!(
1063 ExceededThreshold::from_bools(true, false, false),
1064 ExceededThreshold::Cyclomatic
1065 ));
1066 assert!(matches!(
1067 ExceededThreshold::from_bools(true, true, true),
1068 ExceededThreshold::All
1069 ));
1070 assert!(matches!(
1071 ExceededThreshold::from_bools(false, false, true),
1072 ExceededThreshold::Crap
1073 ));
1074 assert!(matches!(
1075 ExceededThreshold::from_bools(true, false, true),
1076 ExceededThreshold::CyclomaticCrap
1077 ));
1078 }
1079
1080 #[test]
1081 fn exceeded_threshold_includes_helpers() {
1082 let all = ExceededThreshold::All;
1083 assert!(all.includes_cyclomatic());
1084 assert!(all.includes_cognitive());
1085 assert!(all.includes_crap());
1086
1087 let crap_only = ExceededThreshold::Crap;
1088 assert!(!crap_only.includes_cyclomatic());
1089 assert!(!crap_only.includes_cognitive());
1090 assert!(crap_only.includes_crap());
1091
1092 assert!(ExceededThreshold::CyclomaticCrap.includes_crap());
1093 assert!(ExceededThreshold::CognitiveCrap.includes_crap());
1094 assert!(!ExceededThreshold::Both.includes_crap());
1095 assert!(!ExceededThreshold::Cyclomatic.includes_crap());
1096 assert!(!ExceededThreshold::Cognitive.includes_crap());
1097 }
1098
1099 #[test]
1100 fn coverage_source_consistency_omits_empty_sources() {
1101 let sources = Vec::new();
1102 assert_eq!(summarize_coverage_source_consistency(sources), None);
1103 }
1104
1105 #[test]
1106 fn coverage_source_consistency_reports_uniform_sources() {
1107 assert_eq!(
1108 summarize_coverage_source_consistency([
1109 CoverageSource::Estimated,
1110 CoverageSource::Estimated,
1111 ]),
1112 Some(CoverageSourceConsistency::Uniform)
1113 );
1114 }
1115
1116 #[test]
1117 fn coverage_source_consistency_reports_mixed_sources() {
1118 assert_eq!(
1119 summarize_coverage_source_consistency([
1120 CoverageSource::Istanbul,
1121 CoverageSource::Estimated,
1122 ]),
1123 Some(CoverageSourceConsistency::Mixed)
1124 );
1125 }
1126}