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/// Formula version for the styling-health score (the CSS / design-system axis).
23/// Bumped independently of [`HEALTH_SCORE_FORMULA_VERSION`] whenever the styling
24/// penalty rubric is recalibrated, so consumers can distinguish a score shift
25/// caused by a weight change from one caused by an actual codebase change. v2
26/// recalibrated `dead_surface` (size-stable declaration-share denominator) and
27/// `token_erosion` (gently saturating arbitrary-value term) from real-project
28/// evidence. v3 re-weighted the duplication family toward value DRIFT: it
29/// down-weighted the exact-block `duplication` scale (exact CSS duplication is the
30/// least-harmful pattern) and added a hardcoded-value-sprawl drift sub-term to
31/// `token_erosion` (distinct un-tokenized `box-shadow`/`border-radius`/`line-height`
32/// values). See `engine::health::styling_score` for the full rubric + calibration.
33pub const STYLING_HEALTH_FORMULA_VERSION: u32 = 3;
34
35/// `skip_serializing_if` predicate: drop a `u16` field from JSON when zero, so
36/// the React descriptive counts never bloat non-React complexity findings.
37#[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/// Per-component penalty breakdown for the health score.
55#[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    /// Small capped penalty for prop-drilling chains. `None` unless the opt-in
79    /// `prop-drilling` rule is enabled; sized like the coupling penalty (~5pt cap).
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub prop_drilling: Option<f64>,
82}
83
84/// Project-level styling-health score: a SECOND health axis computed purely from
85/// the structural CSS analytics (`CssAnalyticsReport`), orthogonal to the JS/TS
86/// code-health [`HealthScore`]. Surfaced only alongside the `--css` analytics, so
87/// a plain `fallow health` run is byte-unchanged. The code score and grade stay
88/// untouched: styling health is additive, never folded into the code score.
89///
90/// Like [`HealthScore`], the score starts at 100 and subtracts capped per-category
91/// penalties; the grade reuses the shared [`letter_grade`] thresholds verbatim
92/// (A>=85, B>=70, C>=55, D>=40, F<40), so the two axes are read on one scale.
93#[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    /// How much to trust the grade. `Low` in either of two cases, `High`
101    /// otherwise (see `confidence_reason` for which): (1) the analyzed CSS surface
102    /// is too thin for the declaration-normalized penalty rubric to be reliable
103    /// (the gradeable, non-atomic declaration count is below 50); or (2) the
104    /// project's CSS is predominantly flat compile-time-atomic CSS-in-JS
105    /// (StyleX/Panda), whose structure is not assessable, so the grade reflects
106    /// token hygiene only regardless of declaration count. This is descriptive
107    /// metadata that NEVER feeds the score: `score`/`grade`/`penalties` are
108    /// byte-identical whether confidence is high or low. Gate on this `confidence`
109    /// flag, which is the complete signal; do NOT reconstruct it from
110    /// `total_declarations`, since that summary count includes atomic declarations
111    /// the grade excludes (a large all-atomic project is `Low` despite a high
112    /// `total_declarations`).
113    pub confidence: StylingHealthConfidence,
114    /// Human-readable reason the grade is low-confidence: either the declaration
115    /// and stylesheet counts a thin grade was computed from, or that structure is
116    /// not assessable for compile-time-atomic CSS-in-JS. `None` when confidence is
117    /// `High`. Prose, not a stable machine field: gate on `confidence`, not on
118    /// this string.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub confidence_reason: Option<String>,
121}
122
123/// Trust level for a [`StylingHealth`] grade. TWO variants (not the three-tier
124/// `high`/`medium`/`low` of [`crate::Confidence`] / `FeatureFlagConfidence`) ON
125/// PURPOSE: styling confidence is binary (the grade is either reliable for the
126/// analyzed surface or it is not), not three distinct evidence tiers, so a
127/// never-emitted `Medium` would be dead surface. Serializes lowercase (`"high"` /
128/// `"low"`), matching the sibling confidence enums' vocabulary.
129#[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    /// The analyzed CSS surface is large enough, and structurally assessable
134    /// enough, for the grade to be reliable.
135    High,
136    /// The grade is indicative rather than authoritative, for one of two reasons
137    /// (named in `confidence_reason`): a thin authored-CSS surface (little to
138    /// measure), or predominantly flat compile-time-atomic CSS-in-JS
139    /// (StyleX/Panda) whose structure is not assessable. NOT a signal that
140    /// fallow's analysis failed.
141    Low,
142}
143
144/// Per-category penalty breakdown for the styling-health score. Each field is the
145/// number of points subtracted from a starting 100 for one CSS signal family,
146/// already capped at its category ceiling. A `0.0` field means "the signal was
147/// evaluated and clean"; the whole struct is only ever built when CSS analytics
148/// were produced, so there is no "missing pipeline" ambiguity to model with
149/// `Option` here (the parent `StylingHealth` is itself `Option` on the report).
150#[derive(Debug, Clone, serde::Serialize)]
151#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
152pub struct StylingHealthPenalties {
153    /// Copy-paste declaration blocks (`duplicate_declaration_blocks`), scaled by
154    /// total removable declarations. Capped at 20pt.
155    pub duplication: f64,
156    /// Dead styling surface, two independently-normalized terms summed and capped
157    /// at 20pt: (a) unused `@theme` tokens as a share of the total `@theme` token
158    /// population (size-independent, so a declaration-sparse Tailwind project is
159    /// not penalized for a few dead tokens); plus (b) the other dead entities
160    /// (unreferenced classes, unused `@property`/`@layer` at-rules, dead
161    /// `@font-face` families) as a share of `total_declarations`.
162    pub dead_surface: f64,
163    /// Broken references: markup classes one edit from a defined class
164    /// (`unresolved_class_references`) and animations referencing a `@keyframes`
165    /// defined nowhere (`undefined_keyframes`). Capped at 15pt.
166    pub broken_references: f64,
167    /// Design-token erosion: mixed `font-size` units (`font_size_unit_mix`),
168    /// Tailwind arbitrary-value bypasses (`tailwind_arbitrary_values`), and
169    /// distinct HARDCODED `box-shadow`/`border-radius`/`line-height` values above
170    /// per-axis baselines (the v3 value-sprawl drift sub-term; `var(--*)`-
171    /// referenced values are not counted). Capped at 10pt.
172    pub token_erosion: f64,
173    /// Structural smells from the summary aggregates: `!important` density and
174    /// deep style-rule nesting. Capped at 10pt.
175    pub structural: f64,
176}
177
178/// Map a numeric score (0-100) to a letter grade.
179#[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/// Coverage tier classification for CRAP findings.
200#[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
209/// Coverage percentage at or above which a function is classified as `High`.
210const HIGH_COVERAGE_WATERMARK: f64 = 70.0;
211
212impl CoverageTier {
213    /// Bucket a numeric coverage percentage `[0, 100]` into a tier.
214    #[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/// Provenance of a CRAP finding's coverage signal.
227#[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/// Whether CRAP findings in the report used one coverage-source kind or a mix.
237#[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/// Summarise the coverage-source provenance attached to CRAP findings.
246#[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/// Per-component React hook profile derived from the cached `hook_uses` IR at
264/// the health layer. Descriptive context that refines the bare
265/// [`ComplexityViolation::react_hook_count`] headline with a per-kind breakdown
266/// and the maximum `useEffect` dependency-array arity.
267///
268/// Attached only when at least one component-scope hook was attributed to the
269/// function, so non-React findings stay byte-identical on the wire. The
270/// per-kind counts cover hooks recorded by the React visitor (calls inside an
271/// identified component); a `use*` call inside a plain helper function is
272/// counted in `react_hook_count` but NOT here, so the breakdown can sum to LESS
273/// than `react_hook_count`. `react_hook_count` remains the headline total; this
274/// is an additive refinement.
275#[derive(Debug, Clone, serde::Serialize)]
276#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
277pub struct ReactHookProfile {
278    /// `useState` call count attributed to this component.
279    pub state: u16,
280    /// `useEffect` call count attributed to this component.
281    pub effect: u16,
282    /// `useMemo` call count attributed to this component.
283    pub memo: u16,
284    /// `useCallback` call count attributed to this component.
285    pub callback: u16,
286    /// Custom `use*` hook call count attributed to this component.
287    pub custom: u16,
288    /// Largest `useEffect` dependency-array arity over the attributed effects
289    /// that carry a literal deps array. `None` when no attributed `useEffect`
290    /// had a literal array (absent or non-literal deps; ADR-001 syntactic-only,
291    /// so absence does NOT mean "no coupling").
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub max_effect_dep_arity: Option<u32>,
294}
295
296impl ReactHookProfile {
297    /// Total component-scope hooks attributed (state + effect + memo + callback
298    /// + custom). Used to gate whether the profile is surfaced at all.
299    #[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    /// `true` when no hook was attributed, so the profile carries no signal.
309    #[must_use]
310    pub fn is_empty(&self) -> bool {
311        self.total() == 0
312    }
313}
314
315/// Inner complexity-violation payload.
316#[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    /// Number of React hook calls in this function's body (`useState` /
329    /// `useEffect` / `useMemo` / `useCallback` / custom `use*`). Descriptive
330    /// hotspot context for React components; omitted when zero (non-React).
331    #[serde(default, skip_serializing_if = "is_zero_u16")]
332    pub react_hook_count: u16,
333    /// Deepest JSX element nesting reached in this function's body. Descriptive
334    /// hotspot context; omitted when zero (renders no JSX).
335    #[serde(default, skip_serializing_if = "is_zero_u16")]
336    pub react_jsx_max_depth: u16,
337    /// Number of props destructured from this component's first parameter.
338    /// Descriptive hotspot context; omitted when zero.
339    #[serde(default, skip_serializing_if = "is_zero_u16")]
340    pub react_prop_count: u16,
341    /// Per-kind React hook breakdown (state/effect/memo/callback/custom) plus
342    /// the max `useEffect` dependency-array arity, derived from the cached
343    /// `hook_uses` IR at the health layer. Descriptive refinement of
344    /// `react_hook_count`; present only when at least one component-scope hook
345    /// was attributed, so non-React findings stay byte-identical.
346    #[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    /// Per-decision-point complexity breakdown explaining WHICH constructs drove
367    /// the cyclomatic and cognitive scores. Populated only when the caller opts
368    /// in via `health --complexity-breakdown`; empty (and omitted from JSON)
369    /// otherwise so default and CI output stay lean.
370    #[serde(default, skip_serializing_if = "Vec::is_empty")]
371    pub contributions: Vec<fallow_types::extract::ComplexityContribution>,
372    /// Resolved thresholds used for this finding when a config override changed
373    /// at least one ceiling. Omitted for findings using global thresholds.
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub effective_thresholds: Option<HealthEffectiveThresholds>,
376    /// Source of the effective thresholds. Omitted when thresholds are global.
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub threshold_source: Option<ThresholdSource>,
379}
380
381/// Resolved thresholds used to evaluate a health finding.
382#[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/// Threshold values configured by a single override entry.
395#[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/// Source for a finding's effective thresholds.
411#[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/// Lifecycle state for a configured threshold override.
419#[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/// Current complexity metrics for a matched threshold override entry.
429#[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/// Report entry describing whether a threshold override is active, stale, or
439/// no longer matching any analyzed file or function.
440#[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/// Which complexity threshold was exceeded.
475#[derive(Debug, Clone, Copy, serde::Serialize)]
476#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
477#[serde(rename_all = "snake_case")]
478pub enum ExceededThreshold {
479    /// Only cyclomatic exceeded.
480    Cyclomatic,
481    /// Only cognitive exceeded.
482    Cognitive,
483    /// Both cyclomatic and cognitive exceeded (may or may not also exceed CRAP).
484    Both,
485    /// Only CRAP exceeded (cyclomatic and cognitive are under threshold).
486    Crap,
487    /// Cyclomatic and CRAP exceeded.
488    CyclomaticCrap,
489    /// Cognitive and CRAP exceeded.
490    CognitiveCrap,
491    /// Cyclomatic, cognitive, and CRAP all exceeded.
492    All,
493}
494
495impl ExceededThreshold {
496    /// Classify a finding from which individual thresholds were exceeded.
497    ///
498    /// Panics if all three bools are false; callers are expected to only
499    /// construct an `ExceededThreshold` for findings that exceeded at least
500    /// one threshold.
501    #[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    /// True when the cyclomatic threshold contributed to the finding.
518    #[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    /// True when the cognitive threshold contributed to the finding.
527    #[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    /// True when the CRAP threshold contributed to the finding.
536    #[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/// Severity tier indicating how far a function exceeds complexity thresholds.
546///
547/// Determined by the highest tier reached across both cognitive and cyclomatic
548/// scores. Default thresholds: cognitive 25/40, cyclomatic 30/50.
549#[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    /// Above threshold but manageable (cognitive < 25 or cyclomatic < 30).
554    Moderate,
555    /// Recommended for extraction (cognitive 25-39 or cyclomatic 30-49).
556    High,
557    /// Immediate extraction candidate (cognitive >= 40 or cyclomatic >= 50).
558    Critical,
559}
560
561/// CRAP score threshold for "high" severity. CC=7 untested -> 56, CC=10 -> 110.
562pub const DEFAULT_CRAP_HIGH: f64 = 50.0;
563
564/// CRAP score threshold for "critical" severity. CC=10 untested gives 110,
565/// CC=12 untested gives 156; 100 lands between the two and flags genuinely
566/// dangerous combinations of high complexity and low coverage.
567pub const DEFAULT_CRAP_CRITICAL: f64 = 100.0;
568
569/// Compute the severity tier for a complexity finding.
570///
571/// Uses the highest tier reached across cognitive, cyclomatic, and CRAP
572/// scores. Pass `None` for `crap` to skip the CRAP contribution (used when
573/// the finding was triggered by complexity thresholds only).
574#[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/// A function exceeding the very-high-risk size threshold (>60 LOC).
617#[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/// Summary statistics for the health report.
628#[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/// Per-file health score combining complexity, coupling, and dead code metrics.
677#[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/// A hotspot: a file that is both complex and frequently changing.
696#[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        // `high` confidence omits the reason; the enum serializes lowercase.
906        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}