Skip to main content

fallow_output/
health_scores.rs

1//! Score types, grade boundaries, file health metrics, and findings.
2
3use 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
17/// Minimum lines of code for full complexity density weight in the MI formula.
18pub const MI_DENSITY_MIN_LINES: f64 = 50.0;
19
20pub const HEALTH_SCORE_FORMULA_VERSION: u32 = 2;
21
22/// `skip_serializing_if` predicate: drop a `u16` field from JSON when zero, so
23/// the React descriptive counts never bloat non-React complexity findings.
24#[expect(
25    clippy::trivially_copy_pass_by_ref,
26    reason = "serde skip_serializing_if requires a by-reference predicate"
27)]
28fn is_zero_u16(value: &u16) -> bool {
29    *value == 0
30}
31
32#[derive(Debug, Clone, serde::Serialize)]
33#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
34pub struct HealthScore {
35    pub formula_version: u32,
36    pub score: f64,
37    pub grade: &'static str,
38    pub penalties: HealthScorePenalties,
39}
40
41/// Per-component penalty breakdown for the health score.
42#[derive(Debug, Clone, serde::Serialize)]
43#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
44pub struct HealthScorePenalties {
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub dead_files: Option<f64>,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub dead_exports: Option<f64>,
49    pub complexity: f64,
50    pub p90_complexity: f64,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub maintainability: Option<f64>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub hotspots: Option<f64>,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub unused_deps: Option<f64>,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub circular_deps: Option<f64>,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub unit_size: Option<f64>,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub coupling: Option<f64>,
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub duplication: Option<f64>,
65    /// Small capped penalty for prop-drilling chains. `None` unless the opt-in
66    /// `prop-drilling` rule is enabled; sized like the coupling penalty (~5pt cap).
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub prop_drilling: Option<f64>,
69}
70
71/// Map a numeric score (0-100) to a letter grade.
72#[must_use]
73#[expect(
74    clippy::cast_possible_truncation,
75    reason = "score is 0-100, fits in u32"
76)]
77pub const fn letter_grade(score: f64) -> &'static str {
78    let s = score as u32;
79    if s >= 85 {
80        "A"
81    } else if s >= 70 {
82        "B"
83    } else if s >= 55 {
84        "C"
85    } else if s >= 40 {
86        "D"
87    } else {
88        "F"
89    }
90}
91
92/// Coverage tier classification for CRAP findings.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
94#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
95#[serde(rename_all = "snake_case")]
96pub enum CoverageTier {
97    None,
98    Partial,
99    High,
100}
101
102/// Coverage percentage at or above which a function is classified as `High`.
103const HIGH_COVERAGE_WATERMARK: f64 = 70.0;
104
105impl CoverageTier {
106    /// Bucket a numeric coverage percentage `[0, 100]` into a tier.
107    #[must_use]
108    pub fn from_pct(pct: f64) -> Self {
109        if pct <= 0.0 {
110            Self::None
111        } else if pct >= HIGH_COVERAGE_WATERMARK {
112            Self::High
113        } else {
114            Self::Partial
115        }
116    }
117}
118
119/// Provenance of a CRAP finding's coverage signal.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
121#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
122#[serde(rename_all = "snake_case")]
123pub enum CoverageSource {
124    Istanbul,
125    Estimated,
126    EstimatedComponentInherited,
127}
128
129/// Whether CRAP findings in the report used one coverage-source kind or a mix.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
131#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
132#[serde(rename_all = "snake_case")]
133pub enum CoverageSourceConsistency {
134    Uniform,
135    Mixed,
136}
137
138/// Summarise the coverage-source provenance attached to CRAP findings.
139#[must_use]
140pub fn summarize_coverage_source_consistency(
141    sources: impl IntoIterator<Item = CoverageSource>,
142) -> Option<CoverageSourceConsistency> {
143    let mut first = None;
144    for source in sources {
145        match first {
146            None => first = Some(source),
147            Some(existing) if existing != source => {
148                return Some(CoverageSourceConsistency::Mixed);
149            }
150            Some(_) => {}
151        }
152    }
153    first.map(|_| CoverageSourceConsistency::Uniform)
154}
155
156/// Per-component React hook profile derived from the cached `hook_uses` IR at
157/// the health layer. Descriptive context that refines the bare
158/// [`ComplexityViolation::react_hook_count`] headline with a per-kind breakdown
159/// and the maximum `useEffect` dependency-array arity.
160///
161/// Attached only when at least one component-scope hook was attributed to the
162/// function, so non-React findings stay byte-identical on the wire. The
163/// per-kind counts cover hooks recorded by the React visitor (calls inside an
164/// identified component); a `use*` call inside a plain helper function is
165/// counted in `react_hook_count` but NOT here, so the breakdown can sum to LESS
166/// than `react_hook_count`. `react_hook_count` remains the headline total; this
167/// is an additive refinement.
168#[derive(Debug, Clone, serde::Serialize)]
169#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
170pub struct ReactHookProfile {
171    /// `useState` call count attributed to this component.
172    pub state: u16,
173    /// `useEffect` call count attributed to this component.
174    pub effect: u16,
175    /// `useMemo` call count attributed to this component.
176    pub memo: u16,
177    /// `useCallback` call count attributed to this component.
178    pub callback: u16,
179    /// Custom `use*` hook call count attributed to this component.
180    pub custom: u16,
181    /// Largest `useEffect` dependency-array arity over the attributed effects
182    /// that carry a literal deps array. `None` when no attributed `useEffect`
183    /// had a literal array (absent or non-literal deps; ADR-001 syntactic-only,
184    /// so absence does NOT mean "no coupling").
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub max_effect_dep_arity: Option<u32>,
187}
188
189impl ReactHookProfile {
190    /// Total component-scope hooks attributed (state + effect + memo + callback
191    /// + custom). Used to gate whether the profile is surfaced at all.
192    #[must_use]
193    pub fn total(&self) -> u16 {
194        self.state
195            .saturating_add(self.effect)
196            .saturating_add(self.memo)
197            .saturating_add(self.callback)
198            .saturating_add(self.custom)
199    }
200
201    /// `true` when no hook was attributed, so the profile carries no signal.
202    #[must_use]
203    pub fn is_empty(&self) -> bool {
204        self.total() == 0
205    }
206}
207
208/// Inner complexity-violation payload.
209#[derive(Debug, Clone, serde::Serialize)]
210#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
211pub struct ComplexityViolation {
212    #[serde(serialize_with = "fallow_types::serde_path::serialize")]
213    pub path: std::path::PathBuf,
214    pub name: String,
215    pub line: u32,
216    pub col: u32,
217    pub cyclomatic: u16,
218    pub cognitive: u16,
219    pub line_count: u32,
220    pub param_count: u8,
221    /// Number of React hook calls in this function's body (`useState` /
222    /// `useEffect` / `useMemo` / `useCallback` / custom `use*`). Descriptive
223    /// hotspot context for React components; omitted when zero (non-React).
224    #[serde(default, skip_serializing_if = "is_zero_u16")]
225    pub react_hook_count: u16,
226    /// Deepest JSX element nesting reached in this function's body. Descriptive
227    /// hotspot context; omitted when zero (renders no JSX).
228    #[serde(default, skip_serializing_if = "is_zero_u16")]
229    pub react_jsx_max_depth: u16,
230    /// Number of props destructured from this component's first parameter.
231    /// Descriptive hotspot context; omitted when zero.
232    #[serde(default, skip_serializing_if = "is_zero_u16")]
233    pub react_prop_count: u16,
234    /// Per-kind React hook breakdown (state/effect/memo/callback/custom) plus
235    /// the max `useEffect` dependency-array arity, derived from the cached
236    /// `hook_uses` IR at the health layer. Descriptive refinement of
237    /// `react_hook_count`; present only when at least one component-scope hook
238    /// was attributed, so non-React findings stay byte-identical.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub react_hook_profile: Option<ReactHookProfile>,
241    pub exceeded: ExceededThreshold,
242    pub severity: FindingSeverity,
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub crap: Option<f64>,
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub coverage_pct: Option<f64>,
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub coverage_tier: Option<CoverageTier>,
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub coverage_source: Option<CoverageSource>,
251    #[serde(
252        default,
253        serialize_with = "fallow_types::serde_path::serialize_option",
254        skip_serializing_if = "Option::is_none"
255    )]
256    pub inherited_from: Option<std::path::PathBuf>,
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub component_rollup: Option<ComponentRollup>,
259    /// Per-decision-point complexity breakdown explaining WHICH constructs drove
260    /// the cyclomatic and cognitive scores. Populated only when the caller opts
261    /// in via `health --complexity-breakdown`; empty (and omitted from JSON)
262    /// otherwise so default and CI output stay lean.
263    #[serde(default, skip_serializing_if = "Vec::is_empty")]
264    pub contributions: Vec<fallow_types::extract::ComplexityContribution>,
265    /// Resolved thresholds used for this finding when a config override changed
266    /// at least one ceiling. Omitted for findings using global thresholds.
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub effective_thresholds: Option<HealthEffectiveThresholds>,
269    /// Source of the effective thresholds. Omitted when thresholds are global.
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub threshold_source: Option<ThresholdSource>,
272}
273
274/// Resolved thresholds used to evaluate a health finding.
275#[derive(Debug, Clone, Copy, serde::Serialize)]
276#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
277#[allow(
278    clippy::struct_field_names,
279    reason = "target-dependent clippy lint; wire fields mirror max_* config keys"
280)]
281pub struct HealthEffectiveThresholds {
282    pub max_cyclomatic: u16,
283    pub max_cognitive: u16,
284    pub max_crap: f64,
285}
286
287/// Threshold values configured by a single override entry.
288#[derive(Debug, Clone, Copy, serde::Serialize)]
289#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
290#[allow(
291    clippy::struct_field_names,
292    reason = "target-dependent clippy lint; wire fields mirror max_* config keys"
293)]
294pub struct HealthConfiguredThresholds {
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub max_cyclomatic: Option<u16>,
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub max_cognitive: Option<u16>,
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub max_crap: Option<f64>,
301}
302
303/// Source for a finding's effective thresholds.
304#[derive(Debug, Clone, Copy, serde::Serialize)]
305#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
306#[serde(rename_all = "snake_case")]
307pub enum ThresholdSource {
308    Override,
309}
310
311/// Lifecycle state for a configured threshold override.
312#[derive(Debug, Clone, Copy, serde::Serialize)]
313#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
314#[serde(rename_all = "snake_case")]
315pub enum ThresholdOverrideStatus {
316    Active,
317    Stale,
318    NoMatch,
319}
320
321/// Current complexity metrics for a matched threshold override entry.
322#[derive(Debug, Clone, Copy, serde::Serialize)]
323#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
324pub struct ThresholdOverrideMetrics {
325    pub cyclomatic: u16,
326    pub cognitive: u16,
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub crap: Option<f64>,
329}
330
331/// Report entry describing whether a threshold override is active, stale, or
332/// no longer matching any analyzed file or function.
333#[derive(Debug, Clone, serde::Serialize)]
334#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
335pub struct ThresholdOverrideState {
336    pub status: ThresholdOverrideStatus,
337    pub override_index: usize,
338    #[serde(
339        default,
340        serialize_with = "fallow_types::serde_path::serialize_option",
341        skip_serializing_if = "Option::is_none"
342    )]
343    pub path: Option<std::path::PathBuf>,
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub function: Option<String>,
346    pub configured_thresholds: HealthConfiguredThresholds,
347    pub effective_thresholds: HealthEffectiveThresholds,
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub metrics: Option<ThresholdOverrideMetrics>,
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub reason: Option<String>,
352}
353
354#[derive(Debug, Clone, serde::Serialize)]
355#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
356pub struct ComponentRollup {
357    pub component: String,
358    pub class_worst_function: String,
359    pub class_cyclomatic: u16,
360    pub class_cognitive: u16,
361    #[serde(serialize_with = "fallow_types::serde_path::serialize")]
362    pub template_path: std::path::PathBuf,
363    pub template_cyclomatic: u16,
364    pub template_cognitive: u16,
365}
366
367/// Which complexity threshold was exceeded.
368#[derive(Debug, Clone, Copy, serde::Serialize)]
369#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
370#[serde(rename_all = "snake_case")]
371pub enum ExceededThreshold {
372    /// Only cyclomatic exceeded.
373    Cyclomatic,
374    /// Only cognitive exceeded.
375    Cognitive,
376    /// Both cyclomatic and cognitive exceeded (may or may not also exceed CRAP).
377    Both,
378    /// Only CRAP exceeded (cyclomatic and cognitive are under threshold).
379    Crap,
380    /// Cyclomatic and CRAP exceeded.
381    CyclomaticCrap,
382    /// Cognitive and CRAP exceeded.
383    CognitiveCrap,
384    /// Cyclomatic, cognitive, and CRAP all exceeded.
385    All,
386}
387
388impl ExceededThreshold {
389    /// Classify a finding from which individual thresholds were exceeded.
390    ///
391    /// Panics if all three bools are false; callers are expected to only
392    /// construct an `ExceededThreshold` for findings that exceeded at least
393    /// one threshold.
394    #[must_use]
395    pub fn from_bools(cyclomatic: bool, cognitive: bool, crap: bool) -> Self {
396        match (cyclomatic, cognitive, crap) {
397            (true, true, true) => Self::All,
398            (true, true, false) => Self::Both,
399            (true, false, true) => Self::CyclomaticCrap,
400            (false, true, true) => Self::CognitiveCrap,
401            (true, false, false) => Self::Cyclomatic,
402            (false, true, false) => Self::Cognitive,
403            (false, false, true) => Self::Crap,
404            (false, false, false) => {
405                unreachable!("ExceededThreshold requires at least one threshold exceeded")
406            }
407        }
408    }
409
410    /// True when the cyclomatic threshold contributed to the finding.
411    #[must_use]
412    pub const fn includes_cyclomatic(self) -> bool {
413        matches!(
414            self,
415            Self::Cyclomatic | Self::Both | Self::CyclomaticCrap | Self::All
416        )
417    }
418
419    /// True when the cognitive threshold contributed to the finding.
420    #[must_use]
421    pub const fn includes_cognitive(self) -> bool {
422        matches!(
423            self,
424            Self::Cognitive | Self::Both | Self::CognitiveCrap | Self::All
425        )
426    }
427
428    /// True when the CRAP threshold contributed to the finding.
429    #[must_use]
430    pub const fn includes_crap(self) -> bool {
431        matches!(
432            self,
433            Self::Crap | Self::CyclomaticCrap | Self::CognitiveCrap | Self::All
434        )
435    }
436}
437
438/// Severity tier indicating how far a function exceeds complexity thresholds.
439///
440/// Determined by the highest tier reached across both cognitive and cyclomatic
441/// scores. Default thresholds: cognitive 25/40, cyclomatic 30/50.
442#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
443#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
444#[serde(rename_all = "snake_case")]
445pub enum FindingSeverity {
446    /// Above threshold but manageable (cognitive < 25 or cyclomatic < 30).
447    Moderate,
448    /// Recommended for extraction (cognitive 25-39 or cyclomatic 30-49).
449    High,
450    /// Immediate extraction candidate (cognitive >= 40 or cyclomatic >= 50).
451    Critical,
452}
453
454/// CRAP score threshold for "high" severity. CC=7 untested -> 56, CC=10 -> 110.
455pub const DEFAULT_CRAP_HIGH: f64 = 50.0;
456
457/// CRAP score threshold for "critical" severity. CC=10 untested gives 110,
458/// CC=12 untested gives 156; 100 lands between the two and flags genuinely
459/// dangerous combinations of high complexity and low coverage.
460pub const DEFAULT_CRAP_CRITICAL: f64 = 100.0;
461
462/// Compute the severity tier for a complexity finding.
463///
464/// Uses the highest tier reached across cognitive, cyclomatic, and CRAP
465/// scores. Pass `None` for `crap` to skip the CRAP contribution (used when
466/// the finding was triggered by complexity thresholds only).
467#[expect(
468    clippy::too_many_arguments,
469    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"
470)]
471pub fn compute_finding_severity(
472    cognitive: u16,
473    cyclomatic: u16,
474    crap: Option<f64>,
475    cognitive_high: u16,
476    cognitive_critical: u16,
477    cyclomatic_high: u16,
478    cyclomatic_critical: u16,
479) -> FindingSeverity {
480    let cog = if cognitive >= cognitive_critical {
481        FindingSeverity::Critical
482    } else if cognitive >= cognitive_high {
483        FindingSeverity::High
484    } else {
485        FindingSeverity::Moderate
486    };
487
488    let cyc = if cyclomatic >= cyclomatic_critical {
489        FindingSeverity::Critical
490    } else if cyclomatic >= cyclomatic_high {
491        FindingSeverity::High
492    } else {
493        FindingSeverity::Moderate
494    };
495
496    let crap_sev = crap.map_or(FindingSeverity::Moderate, |c| {
497        if c >= DEFAULT_CRAP_CRITICAL {
498            FindingSeverity::Critical
499        } else if c >= DEFAULT_CRAP_HIGH {
500            FindingSeverity::High
501        } else {
502            FindingSeverity::Moderate
503        }
504    });
505
506    cog.max(cyc).max(crap_sev)
507}
508
509/// A function exceeding the very-high-risk size threshold (>60 LOC).
510#[derive(Debug, Clone, serde::Serialize)]
511#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
512pub struct LargeFunctionEntry {
513    #[serde(serialize_with = "fallow_types::serde_path::serialize")]
514    pub path: std::path::PathBuf,
515    pub name: String,
516    pub line: u32,
517    pub line_count: u32,
518}
519
520/// Summary statistics for the health report.
521#[derive(Debug, Clone, serde::Serialize)]
522#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
523pub struct HealthSummary {
524    pub files_analyzed: usize,
525    pub functions_analyzed: usize,
526    pub functions_above_threshold: usize,
527    pub max_cyclomatic_threshold: u16,
528    pub max_cognitive_threshold: u16,
529    pub max_crap_threshold: f64,
530    #[serde(default, skip_serializing_if = "Option::is_none")]
531    pub files_scored: Option<usize>,
532    #[serde(default, skip_serializing_if = "Option::is_none")]
533    pub average_maintainability: Option<f64>,
534    #[serde(default, skip_serializing_if = "Option::is_none")]
535    pub coverage_model: Option<CoverageModel>,
536    #[serde(default, skip_serializing_if = "Option::is_none")]
537    pub coverage_source_consistency: Option<CoverageSourceConsistency>,
538    #[serde(default, skip_serializing_if = "Option::is_none")]
539    pub istanbul_matched: Option<usize>,
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    pub istanbul_total: Option<usize>,
542    pub severity_critical_count: usize,
543    pub severity_high_count: usize,
544    pub severity_moderate_count: usize,
545}
546
547impl Default for HealthSummary {
548    fn default() -> Self {
549        Self {
550            files_analyzed: 0,
551            functions_analyzed: 0,
552            functions_above_threshold: 0,
553            max_cyclomatic_threshold: 20,
554            max_cognitive_threshold: 15,
555            max_crap_threshold: 30.0,
556            files_scored: None,
557            average_maintainability: None,
558            coverage_model: None,
559            coverage_source_consistency: None,
560            istanbul_matched: None,
561            istanbul_total: None,
562            severity_critical_count: 0,
563            severity_high_count: 0,
564            severity_moderate_count: 0,
565        }
566    }
567}
568
569/// Per-file health score combining complexity, coupling, and dead code metrics.
570#[derive(Debug, Clone, serde::Serialize)]
571#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
572pub struct FileHealthScore {
573    #[serde(serialize_with = "fallow_types::serde_path::serialize")]
574    pub path: std::path::PathBuf,
575    pub fan_in: usize,
576    pub fan_out: usize,
577    pub dead_code_ratio: f64,
578    pub complexity_density: f64,
579    pub maintainability_index: f64,
580    pub total_cyclomatic: u32,
581    pub total_cognitive: u32,
582    pub function_count: usize,
583    pub lines: u32,
584    pub crap_max: f64,
585    pub crap_above_threshold: usize,
586}
587
588/// A hotspot: a file that is both complex and frequently changing.
589#[derive(Debug, Clone, serde::Serialize)]
590#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
591pub struct HotspotEntry {
592    #[serde(serialize_with = "fallow_types::serde_path::serialize")]
593    pub path: std::path::PathBuf,
594    pub score: f64,
595    pub commits: u32,
596    pub weighted_commits: f64,
597    pub lines_added: u32,
598    pub lines_deleted: u32,
599    pub complexity_density: f64,
600    pub fan_in: usize,
601    pub trend: fallow_types::churn::ChurnTrend,
602    #[serde(default, skip_serializing_if = "Option::is_none")]
603    pub ownership: Option<OwnershipMetrics>,
604    #[serde(skip_serializing_if = "std::ops::Not::not")]
605    #[cfg_attr(feature = "schema", schemars(default))]
606    pub is_test_path: bool,
607}
608
609#[derive(Debug, Clone, serde::Serialize)]
610#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
611pub struct ContributorEntry {
612    pub identifier: String,
613    pub format: ContributorIdentifierFormat,
614    pub share: f64,
615    pub stale_days: u64,
616    pub commits: u32,
617}
618
619#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
620#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
621#[serde(rename_all = "kebab-case")]
622pub enum ContributorIdentifierFormat {
623    Raw,
624    Handle,
625    Anonymized,
626    Hash,
627}
628
629#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
630#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
631#[serde(rename_all = "snake_case")]
632pub enum OwnershipState {
633    Active,
634    Unowned,
635    DeclaredInactive,
636    Drifting,
637}
638
639#[derive(Debug, Clone, serde::Serialize)]
640#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
641pub struct OwnershipMetrics {
642    pub bus_factor: u32,
643
644    pub contributor_count: u32,
645
646    pub top_contributor: ContributorEntry,
647
648    #[serde(default, skip_serializing_if = "Vec::is_empty")]
649    #[cfg_attr(feature = "schema", schemars(default))]
650    pub recent_contributors: Vec<ContributorEntry>,
651
652    #[serde(default, skip_serializing_if = "Vec::is_empty")]
653    #[cfg_attr(feature = "schema", schemars(default))]
654    pub suggested_reviewers: Vec<ContributorEntry>,
655
656    #[serde(default, skip_serializing_if = "Option::is_none")]
657    pub declared_owner: Option<String>,
658
659    pub unowned: Option<bool>,
660
661    pub ownership_state: OwnershipState,
662
663    pub drift: bool,
664
665    #[serde(default, skip_serializing_if = "Option::is_none")]
666    pub drift_reason: Option<String>,
667}
668
669#[derive(Debug, Clone, serde::Serialize)]
670#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
671pub struct HotspotSummary {
672    pub since: String,
673    pub min_commits: u32,
674    pub files_analyzed: usize,
675    pub files_excluded: usize,
676    pub shallow_clone: bool,
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682
683    #[test]
684    fn exceeded_threshold_serializes_as_snake_case() {
685        let json = serde_json::to_string(&ExceededThreshold::Both)
686            .expect("threshold variant should serialize");
687        assert_eq!(json, r#""both""#);
688
689        let json = serde_json::to_string(&ExceededThreshold::Cyclomatic)
690            .expect("threshold variant should serialize");
691        assert_eq!(json, r#""cyclomatic""#);
692    }
693
694    #[test]
695    fn exceeded_threshold_all_variants_serialize() {
696        for (variant, expected) in [
697            (ExceededThreshold::Cyclomatic, r#""cyclomatic""#),
698            (ExceededThreshold::Cognitive, r#""cognitive""#),
699            (ExceededThreshold::Both, r#""both""#),
700            (ExceededThreshold::Crap, r#""crap""#),
701            (ExceededThreshold::CyclomaticCrap, r#""cyclomatic_crap""#),
702            (ExceededThreshold::CognitiveCrap, r#""cognitive_crap""#),
703            (ExceededThreshold::All, r#""all""#),
704        ] {
705            let json = serde_json::to_string(&variant).expect("threshold variant should serialize");
706            assert_eq!(json, expected, "wire form for {variant:?} should be stable");
707        }
708    }
709
710    #[test]
711    fn letter_grade_boundaries() {
712        assert_eq!(letter_grade(100.0), "A");
713        assert_eq!(letter_grade(85.0), "A");
714        assert_eq!(letter_grade(84.9), "B");
715        assert_eq!(letter_grade(70.0), "B");
716        assert_eq!(letter_grade(69.9), "C");
717        assert_eq!(letter_grade(55.0), "C");
718        assert_eq!(letter_grade(54.9), "D");
719        assert_eq!(letter_grade(40.0), "D");
720        assert_eq!(letter_grade(39.9), "F");
721        assert_eq!(letter_grade(0.0), "F");
722    }
723
724    #[test]
725    fn coverage_tier_boundaries() {
726        assert_eq!(CoverageTier::from_pct(0.0), CoverageTier::None);
727        assert_eq!(CoverageTier::from_pct(0.1), CoverageTier::Partial);
728        assert_eq!(CoverageTier::from_pct(69.9), CoverageTier::Partial);
729        assert_eq!(CoverageTier::from_pct(70.0), CoverageTier::High);
730        assert_eq!(CoverageTier::from_pct(100.0), CoverageTier::High);
731    }
732
733    #[test]
734    fn hotspot_score_threshold_is_50() {
735        assert!((HOTSPOT_SCORE_THRESHOLD - 50.0).abs() < f64::EPSILON);
736    }
737
738    #[test]
739    fn health_score_serializes_correctly() {
740        let score = HealthScore {
741            formula_version: HEALTH_SCORE_FORMULA_VERSION,
742            score: 78.5,
743            grade: "B",
744            penalties: HealthScorePenalties {
745                dead_files: Some(3.1),
746                dead_exports: Some(6.0),
747                complexity: 0.0,
748                p90_complexity: 0.0,
749                maintainability: None,
750                hotspots: None,
751                unused_deps: Some(5.0),
752                circular_deps: Some(4.0),
753                unit_size: None,
754                coupling: None,
755                duplication: None,
756                prop_drilling: None,
757            },
758        };
759        let json = serde_json::to_string(&score).expect("health score should serialize");
760        let parsed: serde_json::Value =
761            serde_json::from_str(&json).expect("health score JSON should parse");
762        assert_eq!(parsed["formula_version"], HEALTH_SCORE_FORMULA_VERSION);
763        assert_eq!(parsed["score"], 78.5);
764        assert_eq!(parsed["grade"], "B");
765        assert_eq!(parsed["penalties"]["dead_files"], 3.1);
766        assert!(!json.contains("maintainability"));
767        assert!(!json.contains("hotspots"));
768        assert!(!json.contains("duplication"));
769    }
770
771    #[test]
772    fn coverage_model_serializes_as_snake_case() {
773        let json = serde_json::to_string(&CoverageModel::StaticBinary)
774            .expect("coverage model should serialize");
775        assert_eq!(json, r#""static_binary""#);
776
777        let json = serde_json::to_string(&CoverageModel::StaticEstimated)
778            .expect("coverage model should serialize");
779        assert_eq!(json, r#""static_estimated""#);
780
781        let json = serde_json::to_string(&CoverageModel::Istanbul)
782            .expect("coverage model should serialize");
783        assert_eq!(json, r#""istanbul""#);
784    }
785
786    #[test]
787    fn finding_severity_serializes_as_snake_case() {
788        assert_eq!(
789            serde_json::to_string(&FindingSeverity::Moderate)
790                .expect("finding severity should serialize"),
791            r#""moderate""#,
792        );
793        assert_eq!(
794            serde_json::to_string(&FindingSeverity::High)
795                .expect("finding severity should serialize"),
796            r#""high""#,
797        );
798        assert_eq!(
799            serde_json::to_string(&FindingSeverity::Critical)
800                .expect("finding severity should serialize"),
801            r#""critical""#,
802        );
803    }
804
805    #[test]
806    fn finding_severity_ordering() {
807        assert!(FindingSeverity::Moderate < FindingSeverity::High);
808        assert!(FindingSeverity::High < FindingSeverity::Critical);
809    }
810
811    #[test]
812    fn compute_severity_moderate_when_below_high_thresholds() {
813        let severity = compute_finding_severity(20, 25, None, 25, 40, 30, 50);
814        assert_eq!(severity, FindingSeverity::Moderate);
815    }
816
817    #[test]
818    fn compute_severity_high_from_cognitive() {
819        let severity = compute_finding_severity(25, 20, None, 25, 40, 30, 50);
820        assert_eq!(severity, FindingSeverity::High);
821    }
822
823    #[test]
824    fn compute_severity_high_from_cyclomatic() {
825        let severity = compute_finding_severity(20, 30, None, 25, 40, 30, 50);
826        assert_eq!(severity, FindingSeverity::High);
827    }
828
829    #[test]
830    fn compute_severity_critical_from_cognitive() {
831        let severity = compute_finding_severity(40, 20, None, 25, 40, 30, 50);
832        assert_eq!(severity, FindingSeverity::Critical);
833    }
834
835    #[test]
836    fn compute_severity_critical_from_cyclomatic() {
837        let severity = compute_finding_severity(20, 50, None, 25, 40, 30, 50);
838        assert_eq!(severity, FindingSeverity::Critical);
839    }
840
841    #[test]
842    fn compute_severity_uses_highest_across_dimensions() {
843        let severity = compute_finding_severity(45, 20, None, 25, 40, 30, 50);
844        assert_eq!(severity, FindingSeverity::Critical);
845    }
846
847    #[test]
848    fn compute_severity_at_exact_boundaries() {
849        let severity = compute_finding_severity(25, 30, None, 25, 40, 30, 50);
850        assert_eq!(severity, FindingSeverity::High);
851
852        let severity = compute_finding_severity(24, 29, None, 25, 40, 30, 50);
853        assert_eq!(severity, FindingSeverity::Moderate);
854
855        let severity = compute_finding_severity(40, 50, None, 25, 40, 30, 50);
856        assert_eq!(severity, FindingSeverity::Critical);
857    }
858
859    #[test]
860    fn compute_severity_crap_contributes_high() {
861        let severity = compute_finding_severity(10, 10, Some(60.0), 25, 40, 30, 50);
862        assert_eq!(severity, FindingSeverity::High);
863    }
864
865    #[test]
866    fn compute_severity_crap_contributes_critical() {
867        let severity = compute_finding_severity(10, 10, Some(120.0), 25, 40, 30, 50);
868        assert_eq!(severity, FindingSeverity::Critical);
869    }
870
871    #[test]
872    fn compute_severity_crap_moderate_under_high() {
873        let severity = compute_finding_severity(10, 10, Some(30.0), 25, 40, 30, 50);
874        assert_eq!(severity, FindingSeverity::Moderate);
875    }
876
877    #[test]
878    fn exceeded_threshold_from_bools() {
879        assert!(matches!(
880            ExceededThreshold::from_bools(true, false, false),
881            ExceededThreshold::Cyclomatic
882        ));
883        assert!(matches!(
884            ExceededThreshold::from_bools(true, true, true),
885            ExceededThreshold::All
886        ));
887        assert!(matches!(
888            ExceededThreshold::from_bools(false, false, true),
889            ExceededThreshold::Crap
890        ));
891        assert!(matches!(
892            ExceededThreshold::from_bools(true, false, true),
893            ExceededThreshold::CyclomaticCrap
894        ));
895    }
896
897    #[test]
898    fn exceeded_threshold_includes_helpers() {
899        let all = ExceededThreshold::All;
900        assert!(all.includes_cyclomatic());
901        assert!(all.includes_cognitive());
902        assert!(all.includes_crap());
903
904        let crap_only = ExceededThreshold::Crap;
905        assert!(!crap_only.includes_cyclomatic());
906        assert!(!crap_only.includes_cognitive());
907        assert!(crap_only.includes_crap());
908
909        assert!(ExceededThreshold::CyclomaticCrap.includes_crap());
910        assert!(ExceededThreshold::CognitiveCrap.includes_crap());
911        assert!(!ExceededThreshold::Both.includes_crap());
912        assert!(!ExceededThreshold::Cyclomatic.includes_crap());
913        assert!(!ExceededThreshold::Cognitive.includes_crap());
914    }
915
916    #[test]
917    fn coverage_source_consistency_omits_empty_sources() {
918        let sources = Vec::new();
919        assert_eq!(summarize_coverage_source_consistency(sources), None);
920    }
921
922    #[test]
923    fn coverage_source_consistency_reports_uniform_sources() {
924        assert_eq!(
925            summarize_coverage_source_consistency([
926                CoverageSource::Estimated,
927                CoverageSource::Estimated,
928            ]),
929            Some(CoverageSourceConsistency::Uniform)
930        );
931    }
932
933    #[test]
934    fn coverage_source_consistency_reports_mixed_sources() {
935        assert_eq!(
936            summarize_coverage_source_consistency([
937                CoverageSource::Istanbul,
938                CoverageSource::Estimated,
939            ]),
940            Some(CoverageSourceConsistency::Mixed)
941        );
942    }
943}