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