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
381#[derive(Debug, Clone, Copy, serde::Serialize)]
383#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
384#[allow(
385 clippy::struct_field_names,
386 reason = "target-dependent clippy lint; wire fields mirror max_* config keys"
387)]
388pub struct HealthEffectiveThresholds {
389 pub max_cyclomatic: u16,
390 pub max_cognitive: u16,
391 pub max_crap: f64,
392}
393
394#[derive(Debug, Clone, Copy, serde::Serialize)]
396#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
397#[allow(
398 clippy::struct_field_names,
399 reason = "target-dependent clippy lint; wire fields mirror max_* config keys"
400)]
401pub struct HealthConfiguredThresholds {
402 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub max_cyclomatic: Option<u16>,
404 #[serde(default, skip_serializing_if = "Option::is_none")]
405 pub max_cognitive: Option<u16>,
406 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub max_crap: Option<f64>,
408}
409
410#[derive(Debug, Clone, Copy, serde::Serialize)]
412#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
413#[serde(rename_all = "snake_case")]
414pub enum ThresholdSource {
415 Override,
416}
417
418#[derive(Debug, Clone, Copy, serde::Serialize)]
420#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
421#[serde(rename_all = "snake_case")]
422pub enum ThresholdOverrideStatus {
423 Active,
424 Stale,
425 NoMatch,
426}
427
428#[derive(Debug, Clone, Copy, serde::Serialize)]
430#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
431pub struct ThresholdOverrideMetrics {
432 pub cyclomatic: u16,
433 pub cognitive: u16,
434 #[serde(default, skip_serializing_if = "Option::is_none")]
435 pub crap: Option<f64>,
436}
437
438#[derive(Debug, Clone, serde::Serialize)]
441#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
442pub struct ThresholdOverrideState {
443 pub status: ThresholdOverrideStatus,
444 pub override_index: usize,
445 #[serde(
446 default,
447 serialize_with = "fallow_types::serde_path::serialize_option",
448 skip_serializing_if = "Option::is_none"
449 )]
450 pub path: Option<std::path::PathBuf>,
451 #[serde(default, skip_serializing_if = "Option::is_none")]
452 pub function: Option<String>,
453 pub configured_thresholds: HealthConfiguredThresholds,
454 pub effective_thresholds: HealthEffectiveThresholds,
455 #[serde(default, skip_serializing_if = "Option::is_none")]
456 pub metrics: Option<ThresholdOverrideMetrics>,
457 #[serde(default, skip_serializing_if = "Option::is_none")]
458 pub reason: Option<String>,
459}
460
461#[derive(Debug, Clone, serde::Serialize)]
462#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
463pub struct ComponentRollup {
464 pub component: String,
465 pub class_worst_function: String,
466 pub class_cyclomatic: u16,
467 pub class_cognitive: u16,
468 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
469 pub template_path: std::path::PathBuf,
470 pub template_cyclomatic: u16,
471 pub template_cognitive: u16,
472}
473
474#[derive(Debug, Clone, Copy, serde::Serialize)]
476#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
477#[serde(rename_all = "snake_case")]
478pub enum ExceededThreshold {
479 Cyclomatic,
481 Cognitive,
483 Both,
485 Crap,
487 CyclomaticCrap,
489 CognitiveCrap,
491 All,
493}
494
495impl ExceededThreshold {
496 #[must_use]
502 pub fn from_bools(cyclomatic: bool, cognitive: bool, crap: bool) -> Self {
503 match (cyclomatic, cognitive, crap) {
504 (true, true, true) => Self::All,
505 (true, true, false) => Self::Both,
506 (true, false, true) => Self::CyclomaticCrap,
507 (false, true, true) => Self::CognitiveCrap,
508 (true, false, false) => Self::Cyclomatic,
509 (false, true, false) => Self::Cognitive,
510 (false, false, true) => Self::Crap,
511 (false, false, false) => {
512 unreachable!("ExceededThreshold requires at least one threshold exceeded")
513 }
514 }
515 }
516
517 #[must_use]
519 pub const fn includes_cyclomatic(self) -> bool {
520 matches!(
521 self,
522 Self::Cyclomatic | Self::Both | Self::CyclomaticCrap | Self::All
523 )
524 }
525
526 #[must_use]
528 pub const fn includes_cognitive(self) -> bool {
529 matches!(
530 self,
531 Self::Cognitive | Self::Both | Self::CognitiveCrap | Self::All
532 )
533 }
534
535 #[must_use]
537 pub const fn includes_crap(self) -> bool {
538 matches!(
539 self,
540 Self::Crap | Self::CyclomaticCrap | Self::CognitiveCrap | Self::All
541 )
542 }
543}
544
545#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
550#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
551#[serde(rename_all = "snake_case")]
552pub enum FindingSeverity {
553 Moderate,
555 High,
557 Critical,
559}
560
561pub const DEFAULT_CRAP_HIGH: f64 = 50.0;
563
564pub const DEFAULT_CRAP_CRITICAL: f64 = 100.0;
568
569#[expect(
575 clippy::too_many_arguments,
576 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"
577)]
578pub fn compute_finding_severity(
579 cognitive: u16,
580 cyclomatic: u16,
581 crap: Option<f64>,
582 cognitive_high: u16,
583 cognitive_critical: u16,
584 cyclomatic_high: u16,
585 cyclomatic_critical: u16,
586) -> FindingSeverity {
587 let cog = if cognitive >= cognitive_critical {
588 FindingSeverity::Critical
589 } else if cognitive >= cognitive_high {
590 FindingSeverity::High
591 } else {
592 FindingSeverity::Moderate
593 };
594
595 let cyc = if cyclomatic >= cyclomatic_critical {
596 FindingSeverity::Critical
597 } else if cyclomatic >= cyclomatic_high {
598 FindingSeverity::High
599 } else {
600 FindingSeverity::Moderate
601 };
602
603 let crap_sev = crap.map_or(FindingSeverity::Moderate, |c| {
604 if c >= DEFAULT_CRAP_CRITICAL {
605 FindingSeverity::Critical
606 } else if c >= DEFAULT_CRAP_HIGH {
607 FindingSeverity::High
608 } else {
609 FindingSeverity::Moderate
610 }
611 });
612
613 cog.max(cyc).max(crap_sev)
614}
615
616#[derive(Debug, Clone, serde::Serialize)]
618#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
619pub struct LargeFunctionEntry {
620 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
621 pub path: std::path::PathBuf,
622 pub name: String,
623 pub line: u32,
624 pub line_count: u32,
625}
626
627#[derive(Debug, Clone, serde::Serialize)]
629#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
630pub struct HealthSummary {
631 pub files_analyzed: usize,
632 pub functions_analyzed: usize,
633 pub functions_above_threshold: usize,
634 pub max_cyclomatic_threshold: u16,
635 pub max_cognitive_threshold: u16,
636 pub max_crap_threshold: f64,
637 #[serde(default, skip_serializing_if = "Option::is_none")]
638 pub files_scored: Option<usize>,
639 #[serde(default, skip_serializing_if = "Option::is_none")]
640 pub average_maintainability: Option<f64>,
641 #[serde(default, skip_serializing_if = "Option::is_none")]
642 pub coverage_model: Option<CoverageModel>,
643 #[serde(default, skip_serializing_if = "Option::is_none")]
644 pub coverage_source_consistency: Option<CoverageSourceConsistency>,
645 #[serde(default, skip_serializing_if = "Option::is_none")]
646 pub istanbul_matched: Option<usize>,
647 #[serde(default, skip_serializing_if = "Option::is_none")]
648 pub istanbul_total: Option<usize>,
649 pub severity_critical_count: usize,
650 pub severity_high_count: usize,
651 pub severity_moderate_count: usize,
652}
653
654impl Default for HealthSummary {
655 fn default() -> Self {
656 Self {
657 files_analyzed: 0,
658 functions_analyzed: 0,
659 functions_above_threshold: 0,
660 max_cyclomatic_threshold: 20,
661 max_cognitive_threshold: 15,
662 max_crap_threshold: 30.0,
663 files_scored: None,
664 average_maintainability: None,
665 coverage_model: None,
666 coverage_source_consistency: None,
667 istanbul_matched: None,
668 istanbul_total: None,
669 severity_critical_count: 0,
670 severity_high_count: 0,
671 severity_moderate_count: 0,
672 }
673 }
674}
675
676#[derive(Debug, Clone, serde::Serialize)]
678#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
679pub struct FileHealthScore {
680 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
681 pub path: std::path::PathBuf,
682 pub fan_in: usize,
683 pub fan_out: usize,
684 pub dead_code_ratio: f64,
685 pub complexity_density: f64,
686 pub maintainability_index: f64,
687 pub total_cyclomatic: u32,
688 pub total_cognitive: u32,
689 pub function_count: usize,
690 pub lines: u32,
691 pub crap_max: f64,
692 pub crap_above_threshold: usize,
693}
694
695#[derive(Debug, Clone, serde::Serialize)]
697#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
698pub struct HotspotEntry {
699 #[serde(serialize_with = "fallow_types::serde_path::serialize")]
700 pub path: std::path::PathBuf,
701 pub score: f64,
702 pub commits: u32,
703 pub weighted_commits: f64,
704 pub lines_added: u32,
705 pub lines_deleted: u32,
706 pub complexity_density: f64,
707 pub fan_in: usize,
708 pub trend: fallow_types::churn::ChurnTrend,
709 #[serde(default, skip_serializing_if = "Option::is_none")]
710 pub ownership: Option<OwnershipMetrics>,
711 #[serde(skip_serializing_if = "std::ops::Not::not")]
712 #[cfg_attr(feature = "schema", schemars(default))]
713 pub is_test_path: bool,
714}
715
716#[derive(Debug, Clone, serde::Serialize)]
717#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
718pub struct ContributorEntry {
719 pub identifier: String,
720 pub format: ContributorIdentifierFormat,
721 pub share: f64,
722 pub stale_days: u64,
723 pub commits: u32,
724}
725
726#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
727#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
728#[serde(rename_all = "kebab-case")]
729pub enum ContributorIdentifierFormat {
730 Raw,
731 Handle,
732 Anonymized,
733 Hash,
734}
735
736#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
737#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
738#[serde(rename_all = "snake_case")]
739pub enum OwnershipState {
740 Active,
741 Unowned,
742 DeclaredInactive,
743 Drifting,
744}
745
746#[derive(Debug, Clone, serde::Serialize)]
747#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
748pub struct OwnershipMetrics {
749 pub bus_factor: u32,
750
751 pub contributor_count: u32,
752
753 pub top_contributor: ContributorEntry,
754
755 #[serde(default, skip_serializing_if = "Vec::is_empty")]
756 #[cfg_attr(feature = "schema", schemars(default))]
757 pub recent_contributors: Vec<ContributorEntry>,
758
759 #[serde(default, skip_serializing_if = "Vec::is_empty")]
760 #[cfg_attr(feature = "schema", schemars(default))]
761 pub suggested_reviewers: Vec<ContributorEntry>,
762
763 #[serde(default, skip_serializing_if = "Option::is_none")]
764 pub declared_owner: Option<String>,
765
766 pub unowned: Option<bool>,
767
768 pub ownership_state: OwnershipState,
769
770 pub drift: bool,
771
772 #[serde(default, skip_serializing_if = "Option::is_none")]
773 pub drift_reason: Option<String>,
774}
775
776#[derive(Debug, Clone, serde::Serialize)]
777#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
778pub struct HotspotSummary {
779 pub since: String,
780 pub min_commits: u32,
781 pub files_analyzed: usize,
782 pub files_excluded: usize,
783 pub shallow_clone: bool,
784}
785
786#[cfg(test)]
787mod tests {
788 use super::*;
789
790 #[test]
791 fn exceeded_threshold_serializes_as_snake_case() {
792 let json = serde_json::to_string(&ExceededThreshold::Both)
793 .expect("threshold variant should serialize");
794 assert_eq!(json, r#""both""#);
795
796 let json = serde_json::to_string(&ExceededThreshold::Cyclomatic)
797 .expect("threshold variant should serialize");
798 assert_eq!(json, r#""cyclomatic""#);
799 }
800
801 #[test]
802 fn exceeded_threshold_all_variants_serialize() {
803 for (variant, expected) in [
804 (ExceededThreshold::Cyclomatic, r#""cyclomatic""#),
805 (ExceededThreshold::Cognitive, r#""cognitive""#),
806 (ExceededThreshold::Both, r#""both""#),
807 (ExceededThreshold::Crap, r#""crap""#),
808 (ExceededThreshold::CyclomaticCrap, r#""cyclomatic_crap""#),
809 (ExceededThreshold::CognitiveCrap, r#""cognitive_crap""#),
810 (ExceededThreshold::All, r#""all""#),
811 ] {
812 let json = serde_json::to_string(&variant).expect("threshold variant should serialize");
813 assert_eq!(json, expected, "wire form for {variant:?} should be stable");
814 }
815 }
816
817 #[test]
818 fn letter_grade_boundaries() {
819 assert_eq!(letter_grade(100.0), "A");
820 assert_eq!(letter_grade(85.0), "A");
821 assert_eq!(letter_grade(84.9), "B");
822 assert_eq!(letter_grade(70.0), "B");
823 assert_eq!(letter_grade(69.9), "C");
824 assert_eq!(letter_grade(55.0), "C");
825 assert_eq!(letter_grade(54.9), "D");
826 assert_eq!(letter_grade(40.0), "D");
827 assert_eq!(letter_grade(39.9), "F");
828 assert_eq!(letter_grade(0.0), "F");
829 }
830
831 #[test]
832 fn coverage_tier_boundaries() {
833 assert_eq!(CoverageTier::from_pct(0.0), CoverageTier::None);
834 assert_eq!(CoverageTier::from_pct(0.1), CoverageTier::Partial);
835 assert_eq!(CoverageTier::from_pct(69.9), CoverageTier::Partial);
836 assert_eq!(CoverageTier::from_pct(70.0), CoverageTier::High);
837 assert_eq!(CoverageTier::from_pct(100.0), CoverageTier::High);
838 }
839
840 #[test]
841 fn hotspot_score_threshold_is_50() {
842 assert!((HOTSPOT_SCORE_THRESHOLD - 50.0).abs() < f64::EPSILON);
843 }
844
845 #[test]
846 fn health_score_serializes_correctly() {
847 let score = HealthScore {
848 formula_version: HEALTH_SCORE_FORMULA_VERSION,
849 score: 78.5,
850 grade: "B",
851 penalties: HealthScorePenalties {
852 dead_files: Some(3.1),
853 dead_exports: Some(6.0),
854 complexity: 0.0,
855 p90_complexity: 0.0,
856 maintainability: None,
857 hotspots: None,
858 unused_deps: Some(5.0),
859 circular_deps: Some(4.0),
860 unit_size: None,
861 coupling: None,
862 duplication: None,
863 prop_drilling: None,
864 },
865 };
866 let json = serde_json::to_string(&score).expect("health score should serialize");
867 let parsed: serde_json::Value =
868 serde_json::from_str(&json).expect("health score JSON should parse");
869 assert_eq!(parsed["formula_version"], HEALTH_SCORE_FORMULA_VERSION);
870 assert_eq!(parsed["score"], 78.5);
871 assert_eq!(parsed["grade"], "B");
872 assert_eq!(parsed["penalties"]["dead_files"], 3.1);
873 assert!(!json.contains("maintainability"));
874 assert!(!json.contains("hotspots"));
875 assert!(!json.contains("duplication"));
876 }
877
878 #[test]
879 fn styling_health_serializes_correctly() {
880 let styling = StylingHealth {
881 formula_version: STYLING_HEALTH_FORMULA_VERSION,
882 score: 72.0,
883 grade: "B",
884 penalties: StylingHealthPenalties {
885 duplication: 12.0,
886 dead_surface: 8.0,
887 broken_references: 4.0,
888 token_erosion: 2.0,
889 structural: 2.0,
890 },
891 confidence: StylingHealthConfidence::High,
892 confidence_reason: None,
893 };
894 let json = serde_json::to_string(&styling).expect("styling health should serialize");
895 let parsed: serde_json::Value =
896 serde_json::from_str(&json).expect("styling health JSON should parse");
897 assert_eq!(parsed["formula_version"], STYLING_HEALTH_FORMULA_VERSION);
898 assert_eq!(parsed["score"], 72.0);
899 assert_eq!(parsed["grade"], "B");
900 assert_eq!(parsed["penalties"]["duplication"], 12.0);
901 assert_eq!(parsed["penalties"]["dead_surface"], 8.0);
902 assert_eq!(parsed["penalties"]["broken_references"], 4.0);
903 assert_eq!(parsed["penalties"]["token_erosion"], 2.0);
904 assert_eq!(parsed["penalties"]["structural"], 2.0);
905 assert_eq!(parsed["confidence"], "high");
907 assert!(parsed.get("confidence_reason").is_none());
908 }
909
910 #[test]
911 fn styling_health_low_confidence_serializes_reason() {
912 let styling = StylingHealth {
913 formula_version: STYLING_HEALTH_FORMULA_VERSION,
914 score: 89.0,
915 grade: "A",
916 penalties: StylingHealthPenalties {
917 duplication: 0.0,
918 dead_surface: 0.0,
919 broken_references: 0.0,
920 token_erosion: 0.0,
921 structural: 0.0,
922 },
923 confidence: StylingHealthConfidence::Low,
924 confidence_reason: Some("graded from only 24 declarations across 2 stylesheets".into()),
925 };
926 let json = serde_json::to_string(&styling).expect("styling health should serialize");
927 let parsed: serde_json::Value =
928 serde_json::from_str(&json).expect("styling health JSON should parse");
929 assert_eq!(parsed["confidence"], "low");
930 assert_eq!(
931 parsed["confidence_reason"],
932 "graded from only 24 declarations across 2 stylesheets"
933 );
934 }
935
936 #[test]
937 fn coverage_model_serializes_as_snake_case() {
938 let json = serde_json::to_string(&CoverageModel::StaticBinary)
939 .expect("coverage model should serialize");
940 assert_eq!(json, r#""static_binary""#);
941
942 let json = serde_json::to_string(&CoverageModel::StaticEstimated)
943 .expect("coverage model should serialize");
944 assert_eq!(json, r#""static_estimated""#);
945
946 let json = serde_json::to_string(&CoverageModel::Istanbul)
947 .expect("coverage model should serialize");
948 assert_eq!(json, r#""istanbul""#);
949 }
950
951 #[test]
952 fn finding_severity_serializes_as_snake_case() {
953 assert_eq!(
954 serde_json::to_string(&FindingSeverity::Moderate)
955 .expect("finding severity should serialize"),
956 r#""moderate""#,
957 );
958 assert_eq!(
959 serde_json::to_string(&FindingSeverity::High)
960 .expect("finding severity should serialize"),
961 r#""high""#,
962 );
963 assert_eq!(
964 serde_json::to_string(&FindingSeverity::Critical)
965 .expect("finding severity should serialize"),
966 r#""critical""#,
967 );
968 }
969
970 #[test]
971 fn finding_severity_ordering() {
972 assert!(FindingSeverity::Moderate < FindingSeverity::High);
973 assert!(FindingSeverity::High < FindingSeverity::Critical);
974 }
975
976 #[test]
977 fn compute_severity_moderate_when_below_high_thresholds() {
978 let severity = compute_finding_severity(20, 25, None, 25, 40, 30, 50);
979 assert_eq!(severity, FindingSeverity::Moderate);
980 }
981
982 #[test]
983 fn compute_severity_high_from_cognitive() {
984 let severity = compute_finding_severity(25, 20, None, 25, 40, 30, 50);
985 assert_eq!(severity, FindingSeverity::High);
986 }
987
988 #[test]
989 fn compute_severity_high_from_cyclomatic() {
990 let severity = compute_finding_severity(20, 30, None, 25, 40, 30, 50);
991 assert_eq!(severity, FindingSeverity::High);
992 }
993
994 #[test]
995 fn compute_severity_critical_from_cognitive() {
996 let severity = compute_finding_severity(40, 20, None, 25, 40, 30, 50);
997 assert_eq!(severity, FindingSeverity::Critical);
998 }
999
1000 #[test]
1001 fn compute_severity_critical_from_cyclomatic() {
1002 let severity = compute_finding_severity(20, 50, None, 25, 40, 30, 50);
1003 assert_eq!(severity, FindingSeverity::Critical);
1004 }
1005
1006 #[test]
1007 fn compute_severity_uses_highest_across_dimensions() {
1008 let severity = compute_finding_severity(45, 20, None, 25, 40, 30, 50);
1009 assert_eq!(severity, FindingSeverity::Critical);
1010 }
1011
1012 #[test]
1013 fn compute_severity_at_exact_boundaries() {
1014 let severity = compute_finding_severity(25, 30, None, 25, 40, 30, 50);
1015 assert_eq!(severity, FindingSeverity::High);
1016
1017 let severity = compute_finding_severity(24, 29, None, 25, 40, 30, 50);
1018 assert_eq!(severity, FindingSeverity::Moderate);
1019
1020 let severity = compute_finding_severity(40, 50, None, 25, 40, 30, 50);
1021 assert_eq!(severity, FindingSeverity::Critical);
1022 }
1023
1024 #[test]
1025 fn compute_severity_crap_contributes_high() {
1026 let severity = compute_finding_severity(10, 10, Some(60.0), 25, 40, 30, 50);
1027 assert_eq!(severity, FindingSeverity::High);
1028 }
1029
1030 #[test]
1031 fn compute_severity_crap_contributes_critical() {
1032 let severity = compute_finding_severity(10, 10, Some(120.0), 25, 40, 30, 50);
1033 assert_eq!(severity, FindingSeverity::Critical);
1034 }
1035
1036 #[test]
1037 fn compute_severity_crap_moderate_under_high() {
1038 let severity = compute_finding_severity(10, 10, Some(30.0), 25, 40, 30, 50);
1039 assert_eq!(severity, FindingSeverity::Moderate);
1040 }
1041
1042 #[test]
1043 fn exceeded_threshold_from_bools() {
1044 assert!(matches!(
1045 ExceededThreshold::from_bools(true, false, false),
1046 ExceededThreshold::Cyclomatic
1047 ));
1048 assert!(matches!(
1049 ExceededThreshold::from_bools(true, true, true),
1050 ExceededThreshold::All
1051 ));
1052 assert!(matches!(
1053 ExceededThreshold::from_bools(false, false, true),
1054 ExceededThreshold::Crap
1055 ));
1056 assert!(matches!(
1057 ExceededThreshold::from_bools(true, false, true),
1058 ExceededThreshold::CyclomaticCrap
1059 ));
1060 }
1061
1062 #[test]
1063 fn exceeded_threshold_includes_helpers() {
1064 let all = ExceededThreshold::All;
1065 assert!(all.includes_cyclomatic());
1066 assert!(all.includes_cognitive());
1067 assert!(all.includes_crap());
1068
1069 let crap_only = ExceededThreshold::Crap;
1070 assert!(!crap_only.includes_cyclomatic());
1071 assert!(!crap_only.includes_cognitive());
1072 assert!(crap_only.includes_crap());
1073
1074 assert!(ExceededThreshold::CyclomaticCrap.includes_crap());
1075 assert!(ExceededThreshold::CognitiveCrap.includes_crap());
1076 assert!(!ExceededThreshold::Both.includes_crap());
1077 assert!(!ExceededThreshold::Cyclomatic.includes_crap());
1078 assert!(!ExceededThreshold::Cognitive.includes_crap());
1079 }
1080
1081 #[test]
1082 fn coverage_source_consistency_omits_empty_sources() {
1083 let sources = Vec::new();
1084 assert_eq!(summarize_coverage_source_consistency(sources), None);
1085 }
1086
1087 #[test]
1088 fn coverage_source_consistency_reports_uniform_sources() {
1089 assert_eq!(
1090 summarize_coverage_source_consistency([
1091 CoverageSource::Estimated,
1092 CoverageSource::Estimated,
1093 ]),
1094 Some(CoverageSourceConsistency::Uniform)
1095 );
1096 }
1097
1098 #[test]
1099 fn coverage_source_consistency_reports_mixed_sources() {
1100 assert_eq!(
1101 summarize_coverage_source_consistency([
1102 CoverageSource::Istanbul,
1103 CoverageSource::Estimated,
1104 ]),
1105 Some(CoverageSourceConsistency::Mixed)
1106 );
1107 }
1108}