Skip to main content

fallow_output/
health_scores.rs

1//! Score types, grade boundaries, file health metrics, and findings.
2
3use crate::{CoverageInputFormat, CoverageModel};
4
5/// Minimum churn-times-complexity hotspot score for an entry to count toward
6/// the vital-signs `hotspot_count`; lower-scoring entries still appear in the
7/// hotspot list but do not feed the health-score hotspot penalty.
8pub const HOTSPOT_SCORE_THRESHOLD: f64 = 50.0;
9
10/// Cognitive complexity at or above which a function is flagged as an
11/// extraction candidate in refactor targets and cited as a contributing
12/// factor on file health scores.
13pub const COGNITIVE_EXTRACTION_THRESHOLD: u16 = 30;
14
15/// Default cognitive complexity threshold for "high" finding severity;
16/// crossing it upgrades a complexity finding from moderate to high.
17pub const DEFAULT_COGNITIVE_HIGH: u16 = 25;
18
19/// Default cognitive complexity threshold for "critical" finding severity.
20pub const DEFAULT_COGNITIVE_CRITICAL: u16 = 40;
21
22/// Default cyclomatic complexity threshold for "high" finding severity;
23/// crossing it upgrades a complexity finding from moderate to high.
24pub const DEFAULT_CYCLOMATIC_HIGH: u16 = 30;
25
26/// Default cyclomatic complexity threshold for "critical" finding severity.
27/// Also the cutoff for the v2 health score's critical-complexity density
28/// penalty (share of functions at or above this value).
29pub const DEFAULT_CYCLOMATIC_CRITICAL: u16 = 50;
30
31/// Minimum lines of code for full complexity density weight in the MI formula.
32pub const MI_DENSITY_MIN_LINES: f64 = 50.0;
33
34/// Formula version for the overall health score, serialized as
35/// [`HealthScore::formula_version`] so consumers can distinguish a score shift
36/// caused by a formula change from one caused by an actual codebase change.
37/// v2 replaced the size-dependent aggregators (average and p90 cyclomatic,
38/// raw hotspot and dependency counts) with scale-invariant densities
39/// (critical-complexity share, per-thousand-file dependency rates, top-1%
40/// hotspot share) so scores are comparable across repository sizes; older
41/// snapshots that lack the density fields fall back to the v1 aggregators.
42/// v3 counts only hotspots meeting [`HOTSPOT_SCORE_THRESHOLD`] against the
43/// top-1% file population, rather than penalizing every positive ranked entry.
44/// See `engine::vital_signs` for the full penalty formula.
45pub const HEALTH_SCORE_FORMULA_VERSION: u32 = 3;
46
47/// Formula version for the styling-health score (the CSS / design-system axis).
48/// Bumped independently of [`HEALTH_SCORE_FORMULA_VERSION`] whenever the styling
49/// penalty rubric is recalibrated, so consumers can distinguish a score shift
50/// caused by a weight change from one caused by an actual codebase change. v2
51/// recalibrated `dead_surface` (size-stable declaration-share denominator) and
52/// `token_erosion` (gently saturating arbitrary-value term) from real-project
53/// evidence. v3 re-weighted the duplication family toward value DRIFT: it
54/// down-weighted the exact-block `duplication` scale (exact CSS duplication is the
55/// least-harmful pattern) and added a hardcoded-value-sprawl drift sub-term to
56/// `token_erosion` (distinct un-tokenized `box-shadow`/`border-radius`/`line-height`
57/// values). See `engine::health::styling_score` for the full rubric + calibration.
58pub const STYLING_HEALTH_FORMULA_VERSION: u32 = 3;
59
60/// `skip_serializing_if` predicate: drop a `u16` field from JSON when zero, so
61/// the React descriptive counts never bloat non-React complexity findings.
62#[expect(
63    clippy::trivially_copy_pass_by_ref,
64    reason = "serde skip_serializing_if requires a by-reference predicate"
65)]
66fn is_zero_u16(value: &u16) -> bool {
67    *value == 0
68}
69
70/// `skip_serializing_if` predicate: drop a `usize` field from JSON when zero,
71/// so default-configuration file-score rows stay byte-identical.
72#[expect(
73    clippy::trivially_copy_pass_by_ref,
74    reason = "serde skip_serializing_if requires a by-reference predicate"
75)]
76fn is_zero_usize(value: &usize) -> bool {
77    *value == 0
78}
79
80/// Overall project health score: 100 minus capped per-category penalties.
81#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
82#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
83pub struct HealthScore {
84    /// Score formula version; see [`HEALTH_SCORE_FORMULA_VERSION`].
85    pub formula_version: u32,
86    /// Health score in `[0, 100]`; higher is healthier.
87    pub score: f64,
88    /// Letter grade from [`letter_grade`] (A>=85, B>=70, C>=55, D>=40, F<40).
89    #[serde(deserialize_with = "crate::static_str::deserialize")]
90    pub grade: crate::static_str::StaticStr,
91    /// Per-component penalty breakdown.
92    pub penalties: HealthScorePenalties,
93}
94
95/// Per-component penalty breakdown for the health score.
96#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
97#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
98pub struct HealthScorePenalties {
99    /// Points subtracted for unreachable files; absent when dead-code data
100    /// was not available.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub dead_files: Option<f64>,
103    /// Points subtracted for unused exports; absent when dead-code data was
104    /// not available.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub dead_exports: Option<f64>,
107    /// Points subtracted for overall complexity load.
108    pub complexity: f64,
109    /// Points subtracted for the complexity tail (v1: p90 cyclomatic; v2:
110    /// critical-complexity density).
111    pub p90_complexity: f64,
112    /// Points subtracted for low maintainability-index files; absent when
113    /// file scores were not computed.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub maintainability: Option<f64>,
116    /// Points subtracted for churn-times-complexity hotspots; absent without
117    /// git history.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub hotspots: Option<f64>,
120    /// Points subtracted for unused dependencies; absent when dead-code data
121    /// was not available.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub unused_deps: Option<f64>,
124    /// Points subtracted for circular dependency chains; absent when
125    /// dead-code data was not available.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub circular_deps: Option<f64>,
128    /// Penalty for oversized functions, computed against fixed calibration
129    /// (very-high-risk bin edge at 60 LOC). Deliberately independent of
130    /// `health.maxUnitSize`, which filters the large-functions findings list
131    /// only; raising that threshold empties the list without moving this
132    /// penalty. `health.ignore` removes files from the score entirely.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub unit_size: Option<f64>,
135    /// Points subtracted for fan-in coupling concentration; absent when the
136    /// module graph was not available.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub coupling: Option<f64>,
139    /// Points subtracted for duplicated code; absent when the duplication
140    /// pipeline did not run.
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub duplication: Option<f64>,
143    /// Small capped penalty for prop-drilling chains. `None` unless the opt-in
144    /// `prop-drilling` rule is enabled; sized like the coupling penalty (~5pt cap).
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub prop_drilling: Option<f64>,
147}
148
149/// Project-level styling-health score: a SECOND health axis computed purely from
150/// the structural CSS analytics (`CssAnalyticsReport`), orthogonal to the JS/TS
151/// code-health [`HealthScore`]. Surfaced only alongside the `--css` analytics, so
152/// a plain `fallow health` run is byte-unchanged. The code score and grade stay
153/// untouched: styling health is additive, never folded into the code score.
154///
155/// Like [`HealthScore`], the score starts at 100 and subtracts capped per-category
156/// penalties; the grade reuses the shared [`letter_grade`] thresholds verbatim
157/// (A>=85, B>=70, C>=55, D>=40, F<40), so the two axes are read on one scale.
158#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
159#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
160pub struct StylingHealth {
161    /// Styling formula version; see [`STYLING_HEALTH_FORMULA_VERSION`].
162    pub formula_version: u32,
163    /// Styling-health score in `[0, 100]`; higher is healthier.
164    pub score: f64,
165    /// Letter grade from the shared [`letter_grade`] thresholds.
166    #[serde(deserialize_with = "crate::static_str::deserialize")]
167    pub grade: crate::static_str::StaticStr,
168    /// Per-category penalty breakdown.
169    pub penalties: StylingHealthPenalties,
170    /// How much to trust the grade. `Low` in either of two cases, `High`
171    /// otherwise (see `confidence_reason` for which): (1) the analyzed CSS surface
172    /// is too thin for the declaration-normalized penalty rubric to be reliable
173    /// (the gradeable, non-atomic declaration count is below 50); or (2) the
174    /// project's CSS is predominantly flat compile-time-atomic CSS-in-JS
175    /// (StyleX/Panda), whose structure is not assessable, so the grade reflects
176    /// token hygiene only regardless of declaration count. This is descriptive
177    /// metadata that NEVER feeds the score: `score`/`grade`/`penalties` are
178    /// byte-identical whether confidence is high or low. Gate on this `confidence`
179    /// flag, which is the complete signal; do NOT reconstruct it from
180    /// `total_declarations`, since that summary count includes atomic declarations
181    /// the grade excludes (a large all-atomic project is `Low` despite a high
182    /// `total_declarations`).
183    pub confidence: StylingHealthConfidence,
184    /// Human-readable reason the grade is low-confidence: either the declaration
185    /// and stylesheet counts a thin grade was computed from, or that structure is
186    /// not assessable for compile-time-atomic CSS-in-JS. `None` when confidence is
187    /// `High`. Prose, not a stable machine field: gate on `confidence`, not on
188    /// this string.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub confidence_reason: Option<String>,
191}
192
193/// Trust level for a [`StylingHealth`] grade. TWO variants (not the three-tier
194/// `high`/`medium`/`low` of [`crate::Confidence`] / `FeatureFlagConfidence`) ON
195/// PURPOSE: styling confidence is binary (the grade is either reliable for the
196/// analyzed surface or it is not), not three distinct evidence tiers, so a
197/// never-emitted `Medium` would be dead surface. Serializes lowercase (`"high"` /
198/// `"low"`), matching the sibling confidence enums' vocabulary.
199#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
200#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
201#[serde(rename_all = "lowercase")]
202pub enum StylingHealthConfidence {
203    /// The analyzed CSS surface is large enough, and structurally assessable
204    /// enough, for the grade to be reliable.
205    High,
206    /// The grade is indicative rather than authoritative, for one of two reasons
207    /// (named in `confidence_reason`): a thin authored-CSS surface (little to
208    /// measure), or predominantly flat compile-time-atomic CSS-in-JS
209    /// (StyleX/Panda) whose structure is not assessable. NOT a signal that
210    /// fallow's analysis failed.
211    Low,
212}
213
214/// Per-category penalty breakdown for the styling-health score. Each field is the
215/// number of points subtracted from a starting 100 for one CSS signal family,
216/// already capped at its category ceiling. A `0.0` field means "the signal was
217/// evaluated and clean"; the whole struct is only ever built when CSS analytics
218/// were produced, so there is no "missing pipeline" ambiguity to model with
219/// `Option` here (the parent `StylingHealth` is itself `Option` on the report).
220#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
221#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
222pub struct StylingHealthPenalties {
223    /// Copy-paste declaration blocks (`duplicate_declaration_blocks`), scaled by
224    /// total removable declarations. Capped at 20pt.
225    pub duplication: f64,
226    /// Dead styling surface, two independently-normalized terms summed and capped
227    /// at 20pt: (a) unused `@theme` tokens as a share of the total `@theme` token
228    /// population (size-independent, so a declaration-sparse Tailwind project is
229    /// not penalized for a few dead tokens); plus (b) the other dead entities
230    /// (unreferenced classes, unused `@property`/`@layer` at-rules, dead
231    /// `@font-face` families) as a share of `total_declarations`.
232    pub dead_surface: f64,
233    /// Broken references: markup classes one edit from a defined class
234    /// (`unresolved_class_references`) and animations referencing a `@keyframes`
235    /// defined nowhere (`undefined_keyframes`). Capped at 15pt.
236    pub broken_references: f64,
237    /// Design-token erosion: mixed `font-size` units (`font_size_unit_mix`),
238    /// Tailwind arbitrary-value bypasses (`tailwind_arbitrary_values`), and
239    /// distinct HARDCODED `box-shadow`/`border-radius`/`line-height` values above
240    /// per-axis baselines (the v3 value-sprawl drift sub-term; `var(--*)`-
241    /// referenced values are not counted). Capped at 10pt.
242    pub token_erosion: f64,
243    /// Structural smells from the summary aggregates: `!important` density and
244    /// deep style-rule nesting. Capped at 10pt.
245    pub structural: f64,
246}
247
248/// Map a numeric score (0-100) to a letter grade.
249#[must_use]
250#[expect(
251    clippy::cast_possible_truncation,
252    clippy::cast_sign_loss,
253    reason = "score is 0-100, fits in u32"
254)]
255pub const fn letter_grade(score: f64) -> &'static str {
256    let s = score as u32;
257    if s >= 85 {
258        "A"
259    } else if s >= 70 {
260        "B"
261    } else if s >= 55 {
262        "C"
263    } else if s >= 40 {
264        "D"
265    } else {
266        "F"
267    }
268}
269
270/// Coverage tier classification for CRAP findings.
271#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
272#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
273#[serde(rename_all = "snake_case")]
274pub enum CoverageTier {
275    /// No test coverage.
276    None,
277    /// Some coverage below the high watermark.
278    Partial,
279    /// Coverage at or above the high watermark (70%).
280    High,
281}
282
283/// Coverage percentage at or above which a function is classified as `High`.
284const HIGH_COVERAGE_WATERMARK: f64 = 70.0;
285
286impl CoverageTier {
287    /// Bucket a numeric coverage percentage `[0, 100]` into a tier.
288    #[must_use]
289    pub fn from_pct(pct: f64) -> Self {
290        if pct <= 0.0 {
291            Self::None
292        } else if pct >= HIGH_COVERAGE_WATERMARK {
293            Self::High
294        } else {
295            Self::Partial
296        }
297    }
298}
299
300/// Provenance of a CRAP finding's coverage signal.
301#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
302#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
303#[serde(rename_all = "snake_case")]
304pub enum CoverageSource {
305    /// Measured from a coverage map: Istanbul JSON, or raw V8 coverage that
306    /// fallow converts to the same model. `summary.coverage_input_format`
307    /// names which one.
308    Istanbul,
309    /// Estimated from static test reachability.
310    Estimated,
311    /// Estimated coverage inherited from the enclosing component.
312    EstimatedComponentInherited,
313}
314
315/// Whether CRAP findings in the report used one coverage-source kind or a mix.
316#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
317#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
318#[serde(rename_all = "snake_case")]
319pub enum CoverageSourceConsistency {
320    /// Every CRAP finding used the same coverage source.
321    Uniform,
322    /// CRAP findings mix coverage sources.
323    Mixed,
324}
325
326/// Summarise the coverage-source provenance attached to CRAP findings.
327#[must_use]
328pub fn summarize_coverage_source_consistency(
329    sources: impl IntoIterator<Item = CoverageSource>,
330) -> Option<CoverageSourceConsistency> {
331    let mut first = None;
332    for source in sources {
333        match first {
334            None => first = Some(source),
335            Some(existing) if existing != source => {
336                return Some(CoverageSourceConsistency::Mixed);
337            }
338            Some(_) => {}
339        }
340    }
341    first.map(|_| CoverageSourceConsistency::Uniform)
342}
343
344/// Per-component React hook profile derived from the cached `hook_uses` IR at
345/// the health layer. Descriptive context that refines the bare
346/// [`ComplexityViolation::react_hook_count`] headline with a per-kind breakdown
347/// and the maximum `useEffect` dependency-array arity.
348///
349/// Attached only when at least one component-scope hook was attributed to the
350/// function, so non-React findings stay byte-identical on the wire. The
351/// per-kind counts cover hooks recorded by the React visitor (calls inside an
352/// identified component); a `use*` call inside a plain helper function is
353/// counted in `react_hook_count` but NOT here, so the breakdown can sum to LESS
354/// than `react_hook_count`. `react_hook_count` remains the headline total; this
355/// is an additive refinement.
356#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
357#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
358pub struct ReactHookProfile {
359    /// `useState` call count attributed to this component.
360    pub state: u16,
361    /// `useEffect` call count attributed to this component.
362    pub effect: u16,
363    /// `useMemo` call count attributed to this component.
364    pub memo: u16,
365    /// `useCallback` call count attributed to this component.
366    pub callback: u16,
367    /// Custom `use*` hook call count attributed to this component.
368    pub custom: u16,
369    /// Largest `useEffect` dependency-array arity over the attributed effects
370    /// that carry a literal deps array. `None` when no attributed `useEffect`
371    /// had a literal array (absent or non-literal deps; ADR-001 syntactic-only,
372    /// so absence does NOT mean "no coupling").
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub max_effect_dep_arity: Option<u32>,
375}
376
377impl ReactHookProfile {
378    /// Total component-scope hooks attributed (state + effect + memo + callback
379    /// + custom). Used to gate whether the profile is surfaced at all.
380    #[must_use]
381    pub fn total(&self) -> u16 {
382        self.state
383            .saturating_add(self.effect)
384            .saturating_add(self.memo)
385            .saturating_add(self.callback)
386            .saturating_add(self.custom)
387    }
388
389    /// `true` when no hook was attributed, so the profile carries no signal.
390    #[must_use]
391    pub fn is_empty(&self) -> bool {
392        self.total() == 0
393    }
394}
395
396/// Inner complexity-violation payload.
397#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
398#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
399pub struct ComplexityViolation {
400    /// File path relative to the project root.
401    #[serde(serialize_with = "fallow_types::serde_path::serialize")]
402    pub path: std::path::PathBuf,
403    /// Function name, or a synthesized name for anonymous functions.
404    pub name: String,
405    /// 1-based line the function starts on.
406    pub line: u32,
407    /// 1-based column the function starts on.
408    pub col: u32,
409    /// Cyclomatic complexity of the function, as the cyclomatic gate and the
410    /// CRAP score read it. Under the `literal-membership` value of
411    /// `health.cyclomaticPolicy`, this is the value after literal membership
412    /// chains count as one predicate.
413    pub cyclomatic: u16,
414    /// Standard `McCabe` cyclomatic complexity, present only when the
415    /// `health.cyclomaticPolicy` value gave `cyclomatic` a different value.
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub standard_cyclomatic: Option<u16>,
418    /// Cognitive complexity of the function.
419    pub cognitive: u16,
420    /// Lines of code in the function body.
421    pub line_count: u32,
422    /// Number of declared parameters.
423    pub param_count: u8,
424    /// Number of React hook calls in this function's body (`useState` /
425    /// `useEffect` / `useMemo` / `useCallback` / custom `use*`). Descriptive
426    /// hotspot context for React components; omitted when zero (non-React).
427    #[serde(default, skip_serializing_if = "is_zero_u16")]
428    pub react_hook_count: u16,
429    /// Deepest JSX element nesting reached in this function's body. Descriptive
430    /// hotspot context; omitted when zero (renders no JSX).
431    #[serde(default, skip_serializing_if = "is_zero_u16")]
432    pub react_jsx_max_depth: u16,
433    /// Number of props destructured from this component's first parameter.
434    /// Descriptive hotspot context; omitted when zero.
435    #[serde(default, skip_serializing_if = "is_zero_u16")]
436    pub react_prop_count: u16,
437    /// Per-kind React hook breakdown (state/effect/memo/callback/custom) plus
438    /// the max `useEffect` dependency-array arity, derived from the cached
439    /// `hook_uses` IR at the health layer. Descriptive refinement of
440    /// `react_hook_count`; present only when at least one component-scope hook
441    /// was attributed, so non-React findings stay byte-identical.
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub react_hook_profile: Option<ReactHookProfile>,
444    /// Which metric crossed its threshold.
445    pub exceeded: ExceededThreshold,
446    /// Finding severity derived from how far thresholds were crossed.
447    pub severity: FindingSeverity,
448    /// Gate severity after the `complexity-*` rules and their
449    /// `overrides[].rules` entries: `error` fails the run, `warn` does not.
450    /// The most severe rule of the kinds in `exceeded` wins. It is separate
451    /// from the band in `severity`, which ranks the finding and does not gate
452    /// it. Under `audit --gate new-only`, a finding that exceeds only CRAP
453    /// from estimated coverage has `warn`. Absent in reports from older
454    /// versions.
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    pub effective_severity: Option<fallow_types::output_dead_code::EffectiveSeverity>,
457    /// CRAP score (change risk anti-pattern), when coverage data exists.
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub crap: Option<f64>,
460    /// Mutation evidence of the function from a saved `fallow mutation`
461    /// report (`fallow health --mutation-report`). A separate value: it never
462    /// changes `crap` or the health score. Absent without the flag, and when
463    /// no function result of the report matches the finding.
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    pub mutation: Option<crate::HealthFindingMutation>,
466    /// Test coverage percentage (0-100) backing the CRAP score.
467    #[serde(default, skip_serializing_if = "Option::is_none")]
468    pub coverage_pct: Option<f64>,
469    /// Coverage tier bucket.
470    ///
471    /// Derived from `coverage_pct` when coverage was measured. When
472    /// `coverage_source` is estimated, `coverage_pct` is absent and the tier
473    /// describes the static estimate behind the CRAP score rather than an
474    /// observation, so read the two fields together.
475    #[serde(default, skip_serializing_if = "Option::is_none")]
476    pub coverage_tier: Option<CoverageTier>,
477    /// Provenance of the coverage signal.
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub coverage_source: Option<CoverageSource>,
480    /// Component file the inherited coverage estimate came from, for
481    /// component-inherited coverage.
482    #[serde(
483        default,
484        serialize_with = "fallow_types::serde_path::serialize_option",
485        skip_serializing_if = "Option::is_none"
486    )]
487    pub inherited_from: Option<std::path::PathBuf>,
488    /// Aggregate of the enclosing component's findings, when rolled up.
489    #[serde(default, skip_serializing_if = "Option::is_none")]
490    pub component_rollup: Option<ComponentRollup>,
491    /// Per-decision-point complexity breakdown explaining WHICH constructs drove
492    /// the cyclomatic and cognitive scores. Populated only when the caller opts
493    /// in via `health --complexity-breakdown`; empty (and omitted from JSON)
494    /// otherwise so default and CI output stay lean.
495    #[serde(default, skip_serializing_if = "Vec::is_empty")]
496    pub contributions: Vec<fallow_types::extract::ComplexityContribution>,
497    /// Resolved thresholds used for this finding when a config override changed
498    /// at least one ceiling. Omitted for findings using global thresholds.
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub effective_thresholds: Option<HealthEffectiveThresholds>,
501    /// Source of the effective thresholds. Omitted when thresholds are global.
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    pub threshold_source: Option<ThresholdSource>,
504}
505
506/// An empty cyclomatic finding in the moderate band. Builders and tests start
507/// from it and set the fields they need.
508impl Default for ComplexityViolation {
509    fn default() -> Self {
510        Self {
511            path: std::path::PathBuf::new(),
512            name: String::new(),
513            line: 0,
514            col: 0,
515            cyclomatic: 0,
516            standard_cyclomatic: None,
517            cognitive: 0,
518            line_count: 0,
519            param_count: 0,
520            react_hook_count: 0,
521            react_jsx_max_depth: 0,
522            react_prop_count: 0,
523            react_hook_profile: None,
524            exceeded: ExceededThreshold::Cyclomatic,
525            severity: FindingSeverity::Moderate,
526            effective_severity: None,
527            crap: None,
528            mutation: None,
529            coverage_pct: None,
530            coverage_tier: None,
531            coverage_source: None,
532            inherited_from: None,
533            component_rollup: None,
534            contributions: Vec::new(),
535            effective_thresholds: None,
536            threshold_source: None,
537        }
538    }
539}
540
541impl ComplexityViolation {
542    /// Whether the finding fails the run.
543    ///
544    /// A `warn` gate severity does not block. A finding without the field, for
545    /// example from an older saved report, blocks as before.
546    #[must_use]
547    pub fn blocks(&self) -> bool {
548        self.effective_severity != Some(fallow_types::output_dead_code::EffectiveSeverity::Warn)
549    }
550
551    /// Ceilings this finding was actually evaluated against: the per-file
552    /// `thresholdOverrides` result when an override matched, otherwise the
553    /// run's global summary ceilings.
554    ///
555    /// Every renderer that prints or compares a threshold must go through this
556    /// so a finding is never described against a ceiling it was not measured
557    /// with.
558    #[must_use]
559    pub fn resolved_thresholds(&self, summary: &HealthSummary) -> HealthEffectiveThresholds {
560        self.effective_thresholds
561            .unwrap_or(HealthEffectiveThresholds {
562                max_cyclomatic: summary.max_cyclomatic_threshold,
563                max_cognitive: summary.max_cognitive_threshold,
564                max_crap: summary.max_crap_threshold,
565                max_unit_size: summary.max_unit_size_threshold,
566            })
567    }
568}
569
570/// Default unit-size ceiling (`health.maxUnitSize`): functions over 60 lines of
571/// code are reported as oversized. Mirrors the config crate's default so
572/// renderers can fill an effective-thresholds fallback without a config handle.
573pub const DEFAULT_MAX_UNIT_SIZE: u32 = 60;
574
575/// Resolved thresholds used to evaluate a health finding.
576#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
577#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
578#[allow(
579    clippy::struct_field_names,
580    reason = "target-dependent clippy lint; wire fields mirror max_* config keys"
581)]
582pub struct HealthEffectiveThresholds {
583    /// Effective cyclomatic-complexity ceiling for the matched file.
584    pub max_cyclomatic: u16,
585    /// Effective cognitive-complexity ceiling for the matched file.
586    pub max_cognitive: u16,
587    /// Effective CRAP-score ceiling for the matched file.
588    pub max_crap: f64,
589    /// Effective unit-size ceiling (maximum function length in lines) for the
590    /// matched file, after applying any `thresholdOverrides` on top of the
591    /// global `health.maxUnitSize` default.
592    pub max_unit_size: u32,
593}
594
595/// Threshold values configured by a single override entry.
596#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
597#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
598#[allow(
599    clippy::struct_field_names,
600    reason = "target-dependent clippy lint; wire fields mirror max_* config keys"
601)]
602pub struct HealthConfiguredThresholds {
603    /// Cyclomatic ceiling set by the override, when it sets one.
604    #[serde(default, skip_serializing_if = "Option::is_none")]
605    pub max_cyclomatic: Option<u16>,
606    /// Cognitive ceiling set by the override, when it sets one.
607    #[serde(default, skip_serializing_if = "Option::is_none")]
608    pub max_cognitive: Option<u16>,
609    /// CRAP ceiling set by the override, when it sets one.
610    #[serde(default, skip_serializing_if = "Option::is_none")]
611    pub max_crap: Option<f64>,
612    /// Unit-size ceiling set by the override, when it sets one.
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub max_unit_size: Option<u32>,
615}
616
617/// Source for a finding's effective thresholds.
618#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
619#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
620#[serde(rename_all = "snake_case")]
621pub enum ThresholdSource {
622    /// A `thresholdOverrides` config entry changed at least one ceiling.
623    Override,
624}
625
626/// Lifecycle state for a configured threshold override.
627#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
628#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
629#[serde(rename_all = "snake_case")]
630pub enum ThresholdOverrideStatus {
631    /// The override matches a finding that still needs the raised ceiling.
632    Active,
633    /// The override is not what keeps the matched unit quiet, so it can go:
634    /// either the unit passes the global thresholds on its own, or an inline
635    /// suppression already covers it.
636    Stale,
637    /// The override raises the ceiling for this dimension but the matched code
638    /// still exceeds the raised value, so the finding survives the override.
639    /// Without this state the row was dropped entirely and a user saw no
640    /// feedback at all on an override that was in force (issue #2163).
641    Insufficient,
642    /// The override matches no analyzed file or function.
643    NoMatch,
644}
645
646/// Which threshold dimension a `thresholdOverrides` state row describes.
647///
648/// One configured override produces one row per dimension it participates in,
649/// because the complexity ceilings and the CRAP ceiling are evaluated
650/// independently: raising `maxCyclomatic` says nothing about whether the unit
651/// still breaches `maxCrap`.
652#[derive(
653    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
654)]
655#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
656#[serde(rename_all = "snake_case")]
657pub enum ThresholdOverrideDimension {
658    /// The structural ceilings: `maxCyclomatic`, `maxCognitive` and
659    /// `maxUnitSize`.
660    Complexity,
661    /// The `maxCrap` ceiling, and only that ceiling.
662    Crap,
663}
664
665/// Current complexity metrics for a matched threshold override entry.
666#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
667#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
668pub struct ThresholdOverrideMetrics {
669    /// Current cyclomatic complexity of the matched function.
670    pub cyclomatic: u16,
671    /// Current cognitive complexity of the matched function.
672    pub cognitive: u16,
673    /// Current CRAP score, when coverage data exists.
674    #[serde(default, skip_serializing_if = "Option::is_none")]
675    pub crap: Option<f64>,
676    /// Measured line count of the matched unit. Present on complexity rows,
677    /// where `maxUnitSize` participates in the dimension; absent on CRAP rows
678    /// and `<component>` rollup rows, which are never scored on unit size.
679    #[serde(default, skip_serializing_if = "Option::is_none")]
680    pub line_count: Option<u32>,
681}
682
683/// Report entry describing whether a threshold override is active, stale, or
684/// no longer matching any analyzed file or function.
685#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
686#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
687pub struct ThresholdOverrideState {
688    /// Lifecycle state of the override.
689    pub status: ThresholdOverrideStatus,
690    /// Index of the entry in the configured `thresholdOverrides` array.
691    /// Several rows can share one index when the override participates in more
692    /// than one dimension; group on this to count configured overrides.
693    pub override_index: usize,
694    /// Threshold dimension this row describes.
695    pub dimension: ThresholdOverrideDimension,
696    /// Dimensions the matched unit still breaches despite this override,
697    /// whether or not this override configures their ceilings. Non-empty means
698    /// raising the ceiling did not settle the matter: a complexity or CRAP
699    /// finding survived, or the unit is still longer than the resolved
700    /// `maxUnitSize`, which keeps it in the large-function list without
701    /// emitting a finding of its own.
702    #[serde(default, skip_serializing_if = "Vec::is_empty")]
703    pub outstanding: Vec<ThresholdOverrideDimension>,
704    /// Matched file path, when the override matched one.
705    #[serde(
706        default,
707        serialize_with = "fallow_types::serde_path::serialize_option",
708        skip_serializing_if = "Option::is_none"
709    )]
710    pub path: Option<std::path::PathBuf>,
711    /// Matched function name, for function-scoped overrides.
712    #[serde(default, skip_serializing_if = "Option::is_none")]
713    pub function: Option<String>,
714    /// 1-based line of the matched unit. Absent on `no_match` rows, which
715    /// describe an entry that matched nothing. Name alone is not an identity:
716    /// one file can hold several units sharing a name, so this pairs with
717    /// `col` to keep their rows distinct (issue #2163).
718    #[serde(default, skip_serializing_if = "Option::is_none")]
719    pub line: Option<u32>,
720    /// 0-based byte column of the matched unit. Absent on `no_match` rows.
721    #[serde(default, skip_serializing_if = "Option::is_none")]
722    pub col: Option<u32>,
723    /// Ceilings the override entry configures.
724    pub configured_thresholds: HealthConfiguredThresholds,
725    /// Ceilings in effect after applying the override to the defaults.
726    pub effective_thresholds: HealthEffectiveThresholds,
727    /// Current complexity metrics of the matched code, when matched.
728    #[serde(default, skip_serializing_if = "Option::is_none")]
729    pub metrics: Option<ThresholdOverrideMetrics>,
730    /// Human-readable explanation of the status.
731    #[serde(default, skip_serializing_if = "Option::is_none")]
732    pub reason: Option<String>,
733}
734
735impl ThresholdOverrideState {
736    /// Render the matched unit as `path:line:function`, given the path already
737    /// formatted for the target surface.
738    ///
739    /// Every renderer must go through this: two units sharing a name in one
740    /// file produce two rows, and without the position they print as the same
741    /// line (issue #2163).
742    #[must_use]
743    pub fn target_label(&self, display: &str) -> String {
744        let Some(name) = self.function.as_deref() else {
745            return display.to_owned();
746        };
747        self.line.map_or_else(
748            || format!("{display}:{name}"),
749            |line| format!("{display}:{line}:{name}"),
750        )
751    }
752}
753
754/// Component-level aggregate attached to a template complexity finding,
755/// pairing the template's scores with the worst class-side function.
756#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
757#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
758pub struct ComponentRollup {
759    /// Component name.
760    pub component: String,
761    /// Name of the worst-scoring function in the component class.
762    pub class_worst_function: String,
763    /// Cyclomatic complexity of that worst class function.
764    pub class_cyclomatic: u16,
765    /// Cognitive complexity of that worst class function.
766    pub class_cognitive: u16,
767    /// Template file path relative to the project root.
768    #[serde(serialize_with = "fallow_types::serde_path::serialize")]
769    pub template_path: std::path::PathBuf,
770    /// Cyclomatic complexity of the template.
771    pub template_cyclomatic: u16,
772    /// Cognitive complexity of the template.
773    pub template_cognitive: u16,
774}
775
776/// Which complexity threshold was exceeded.
777#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
778#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
779#[serde(rename_all = "snake_case")]
780pub enum ExceededThreshold {
781    /// Only cyclomatic exceeded.
782    Cyclomatic,
783    /// Only cognitive exceeded.
784    Cognitive,
785    /// Both cyclomatic and cognitive exceeded (may or may not also exceed CRAP).
786    Both,
787    /// Only CRAP exceeded (cyclomatic and cognitive are under threshold).
788    Crap,
789    /// Cyclomatic and CRAP exceeded.
790    CyclomaticCrap,
791    /// Cognitive and CRAP exceeded.
792    CognitiveCrap,
793    /// Cyclomatic, cognitive, and CRAP all exceeded.
794    All,
795}
796
797impl ExceededThreshold {
798    /// Classify a finding from which individual thresholds were exceeded.
799    ///
800    /// Panics if all three bools are false; callers are expected to only
801    /// construct an `ExceededThreshold` for findings that exceeded at least
802    /// one threshold.
803    #[must_use]
804    pub fn from_bools(cyclomatic: bool, cognitive: bool, crap: bool) -> Self {
805        match (cyclomatic, cognitive, crap) {
806            (true, true, true) => Self::All,
807            (true, true, false) => Self::Both,
808            (true, false, true) => Self::CyclomaticCrap,
809            (false, true, true) => Self::CognitiveCrap,
810            (true, false, false) => Self::Cyclomatic,
811            (false, true, false) => Self::Cognitive,
812            (false, false, true) => Self::Crap,
813            (false, false, false) => {
814                unreachable!("ExceededThreshold requires at least one threshold exceeded")
815            }
816        }
817    }
818
819    /// True when the cyclomatic threshold contributed to the finding.
820    #[must_use]
821    pub const fn includes_cyclomatic(self) -> bool {
822        matches!(
823            self,
824            Self::Cyclomatic | Self::Both | Self::CyclomaticCrap | Self::All
825        )
826    }
827
828    /// True when the cognitive threshold contributed to the finding.
829    #[must_use]
830    pub const fn includes_cognitive(self) -> bool {
831        matches!(
832            self,
833            Self::Cognitive | Self::Both | Self::CognitiveCrap | Self::All
834        )
835    }
836
837    /// True when the CRAP threshold contributed to the finding.
838    #[must_use]
839    pub const fn includes_crap(self) -> bool {
840        matches!(
841            self,
842            Self::Crap | Self::CyclomaticCrap | Self::CognitiveCrap | Self::All
843        )
844    }
845}
846
847/// Severity tier indicating how far a function exceeds complexity thresholds.
848///
849/// Determined by the highest tier reached across both cognitive and cyclomatic
850/// scores. Default thresholds: cognitive 25/40, cyclomatic 30/50.
851#[derive(
852    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
853)]
854#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
855#[serde(rename_all = "snake_case")]
856pub enum FindingSeverity {
857    /// Above threshold but manageable (cognitive < 25 or cyclomatic < 30).
858    Moderate,
859    /// Recommended for extraction (cognitive 25-39 or cyclomatic 30-49).
860    High,
861    /// Immediate extraction candidate (cognitive >= 40 or cyclomatic >= 50).
862    Critical,
863}
864
865/// CRAP score threshold for "high" severity. CC=7 untested -> 56, CC=10 -> 110.
866pub const DEFAULT_CRAP_HIGH: f64 = 50.0;
867
868/// CRAP score threshold for "critical" severity. CC=10 untested gives 110,
869/// CC=12 untested gives 156; 100 lands between the two and flags genuinely
870/// dangerous combinations of high complexity and low coverage.
871pub const DEFAULT_CRAP_CRITICAL: f64 = 100.0;
872
873/// Compute the severity tier for a complexity finding.
874///
875/// Uses the highest tier reached across cognitive, cyclomatic, and CRAP
876/// scores. Pass `None` for `crap` to skip the CRAP contribution (used when
877/// the finding was triggered by complexity thresholds only).
878#[expect(
879    clippy::too_many_arguments,
880    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"
881)]
882pub fn compute_finding_severity(
883    cognitive: u16,
884    cyclomatic: u16,
885    crap: Option<f64>,
886    cognitive_high: u16,
887    cognitive_critical: u16,
888    cyclomatic_high: u16,
889    cyclomatic_critical: u16,
890) -> FindingSeverity {
891    let cog = if cognitive >= cognitive_critical {
892        FindingSeverity::Critical
893    } else if cognitive >= cognitive_high {
894        FindingSeverity::High
895    } else {
896        FindingSeverity::Moderate
897    };
898
899    let cyc = if cyclomatic >= cyclomatic_critical {
900        FindingSeverity::Critical
901    } else if cyclomatic >= cyclomatic_high {
902        FindingSeverity::High
903    } else {
904        FindingSeverity::Moderate
905    };
906
907    let crap_sev = crap.map_or(FindingSeverity::Moderate, |c| {
908        if c >= DEFAULT_CRAP_CRITICAL {
909            FindingSeverity::Critical
910        } else if c >= DEFAULT_CRAP_HIGH {
911            FindingSeverity::High
912        } else {
913            FindingSeverity::Moderate
914        }
915    });
916
917    cog.max(cyc).max(crap_sev)
918}
919
920/// A function exceeding the very-high-risk size threshold (>60 LOC).
921#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
922#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
923pub struct LargeFunctionEntry {
924    /// File path relative to the project root.
925    #[serde(serialize_with = "fallow_types::serde_path::serialize")]
926    pub path: std::path::PathBuf,
927    /// Function name, or a synthesized name for anonymous functions.
928    pub name: String,
929    /// 1-based line the function starts on.
930    pub line: u32,
931    /// Lines of code in the function body.
932    pub line_count: u32,
933}
934
935/// Summary statistics for the health report.
936#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
937#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
938pub struct HealthSummary {
939    /// Files included in the health analysis.
940    pub files_analyzed: usize,
941    /// Functions and template units checked for threshold findings across the
942    /// analyzed files. Synthetic module-scope units are excluded. Cyclomatic
943    /// aggregates include module units too; `vital_signs.cyclomatic_population`
944    /// reports the disjoint authored-function, module, and template populations
945    /// behind those aggregates.
946    pub functions_analyzed: usize,
947    /// Functions exceeding at least one complexity or CRAP threshold.
948    pub functions_above_threshold: usize,
949    /// Global cyclomatic-complexity ceiling for this run.
950    pub max_cyclomatic_threshold: u16,
951    /// Cyclomatic policy of the gates (`health.cyclomaticPolicy`). Absent
952    /// under the default `standard` policy.
953    #[serde(default, skip_serializing_if = "Option::is_none")]
954    pub cyclomatic_policy: Option<fallow_config::CyclomaticPolicy>,
955    /// Global cognitive-complexity ceiling for this run.
956    pub max_cognitive_threshold: u16,
957    /// Global CRAP-score ceiling for this run.
958    pub max_crap_threshold: f64,
959    /// Effective global unit-size ceiling (`health.maxUnitSize`, maximum
960    /// function length in lines) for this run. Sits alongside the other three
961    /// `max_*_threshold` siblings so a consumer reading the summary sees every
962    /// configured threshold. Per-file `thresholdOverrides` are not reflected
963    /// here; this is the global default.
964    pub max_unit_size_threshold: u32,
965    /// Files with a computed maintainability score; absent when file scoring
966    /// did not run.
967    #[serde(default, skip_serializing_if = "Option::is_none")]
968    pub files_scored: Option<usize>,
969    /// Mean maintainability index over scored files (0-100).
970    #[serde(default, skip_serializing_if = "Option::is_none")]
971    pub average_maintainability: Option<f64>,
972    /// Coverage model behind the CRAP scores, when coverage was used.
973    #[serde(default, skip_serializing_if = "Option::is_none")]
974    pub coverage_model: Option<CoverageModel>,
975    /// Input format of the measured coverage (`istanbul` or `v8`). Present
976    /// only with `coverage_model: "istanbul"`.
977    #[serde(default, skip_serializing_if = "Option::is_none")]
978    pub coverage_input_format: Option<CoverageInputFormat>,
979    /// Whether CRAP findings mix coverage sources.
980    #[serde(default, skip_serializing_if = "Option::is_none")]
981    pub coverage_source_consistency: Option<CoverageSourceConsistency>,
982    /// Functions matched against the Istanbul coverage file, in Istanbul mode.
983    #[serde(default, skip_serializing_if = "Option::is_none")]
984    pub istanbul_matched: Option<usize>,
985    /// Functions in the Istanbul coverage file, in Istanbul mode.
986    #[serde(default, skip_serializing_if = "Option::is_none")]
987    pub istanbul_total: Option<usize>,
988    /// Analyzed files the Istanbul coverage file carried an entry for.
989    /// Read against `istanbul_files_total`, this separates a coverage file
990    /// that did not join from code the coverage file says nothing ran in.
991    #[serde(default, skip_serializing_if = "Option::is_none")]
992    pub istanbul_files_matched: Option<usize>,
993    /// Files described by the Istanbul coverage file, joined or not.
994    #[serde(default, skip_serializing_if = "Option::is_none")]
995    pub istanbul_files_total: Option<usize>,
996    /// Findings with critical severity.
997    pub severity_critical_count: usize,
998    /// Findings with high severity.
999    pub severity_high_count: usize,
1000    /// Findings with moderate severity.
1001    pub severity_moderate_count: usize,
1002    /// Baseline staleness data, present only when a baseline was loaded.
1003    #[serde(default, skip_serializing_if = "Option::is_none")]
1004    pub baseline_staleness: Option<crate::BaselineStaleness>,
1005}
1006
1007impl Default for HealthSummary {
1008    fn default() -> Self {
1009        Self {
1010            files_analyzed: 0,
1011            functions_analyzed: 0,
1012            functions_above_threshold: 0,
1013            max_cyclomatic_threshold: 20,
1014            cyclomatic_policy: None,
1015            max_cognitive_threshold: 15,
1016            max_crap_threshold: 30.0,
1017            max_unit_size_threshold: DEFAULT_MAX_UNIT_SIZE,
1018            files_scored: None,
1019            average_maintainability: None,
1020            coverage_model: None,
1021            coverage_input_format: None,
1022            coverage_source_consistency: None,
1023            istanbul_matched: None,
1024            istanbul_total: None,
1025            istanbul_files_matched: None,
1026            istanbul_files_total: None,
1027            severity_critical_count: 0,
1028            severity_high_count: 0,
1029            severity_moderate_count: 0,
1030            baseline_staleness: None,
1031        }
1032    }
1033}
1034
1035/// Per-file health score combining complexity, coupling, and dead code metrics.
1036#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1037#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1038pub struct FileHealthScore {
1039    /// File path relative to the project root.
1040    #[serde(serialize_with = "fallow_types::serde_path::serialize")]
1041    pub path: std::path::PathBuf,
1042    /// Modules importing this file.
1043    pub fan_in: usize,
1044    /// Modules this file imports.
1045    pub fan_out: usize,
1046    /// Unused exports as a fraction of the file's exports, in `[0, 1]`.
1047    pub dead_code_ratio: f64,
1048    /// Total cyclomatic complexity per line of code.
1049    pub complexity_density: f64,
1050    /// Maintainability index (0-100); higher is healthier.
1051    pub maintainability_index: f64,
1052    /// Summed cyclomatic complexity over all units, including module and template scope.
1053    pub total_cyclomatic: u32,
1054    /// Summed cognitive complexity over all units, including module and template scope.
1055    pub total_cognitive: u32,
1056    /// Complexity units in the file, including synthetic module and template units.
1057    pub function_count: usize,
1058    /// Lines of code in the file.
1059    pub lines: u32,
1060    /// Highest CRAP score among the file's functions. Always the raw measured
1061    /// value; threshold overrides and suppressions never rewrite it.
1062    pub crap_max: f64,
1063    /// Unsuppressed functions whose rounded CRAP score meets or exceeds their effective
1064    /// ceiling, resolved from `health.thresholdOverrides` over the global
1065    /// `maxCrap` / `--max-crap` value. Zero when CRAP enforcement is disabled
1066    /// (global ceiling `0`).
1067    pub crap_above_threshold: usize,
1068    /// Functions whose rounded CRAP score is at or above the canonical 30.0
1069    /// baseline but below their effective ceiling: the count the configuration
1070    /// let through. Stays `0` when the effective ceiling is stricter than 30.
1071    /// When CRAP enforcement is disabled (global ceiling `0`), counts every
1072    /// function at or above the canonical baseline. Omitted when zero.
1073    #[serde(default, skip_serializing_if = "is_zero_usize")]
1074    #[cfg_attr(feature = "schema", schemars(default))]
1075    pub crap_exempted: usize,
1076    /// Concrete scored functions accepted by a complexity suppression, including
1077    /// subthreshold functions and disabled-enforcement runs. Omitted when zero.
1078    /// May overlap with configured exemptions; the counts are not a partition.
1079    #[serde(default, skip_serializing_if = "is_zero_usize")]
1080    #[cfg_attr(feature = "schema", schemars(default))]
1081    pub crap_suppressed: usize,
1082    /// Highest rounded CRAP among unsuppressed scored functions, including
1083    /// subthreshold and configured-exempt functions. Present with the paired
1084    /// unsuppressed threshold whenever a scored suppression exists. Explicitly
1085    /// zero when every scored function is suppressed; raw crap_max is unchanged.
1086    #[serde(default, skip_serializing_if = "Option::is_none")]
1087    pub crap_unsuppressed_max: Option<f64>,
1088    /// Lowest effective ceiling among unsuppressed scored functions, or the run
1089    /// global when none remain. Emitted with crap_unsuppressed_max. Consumers
1090    /// receiving an absent or partial pair fall back to the complete legacy pair:
1091    /// crap_max and crap_effective_threshold (or the run global when absent).
1092    #[serde(default, skip_serializing_if = "Option::is_none")]
1093    pub crap_unsuppressed_effective_threshold: Option<f64>,
1094    /// Lowest effective CRAP ceiling among the file's functions, present only
1095    /// when it differs from the run global (`summary.max_crap_threshold`).
1096    /// Consumers fall back to `summary.max_crap_threshold` when absent.
1097    #[serde(default, skip_serializing_if = "Option::is_none")]
1098    pub crap_effective_threshold: Option<f64>,
1099}
1100
1101/// A hotspot: a file that is both complex and frequently changing.
1102#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1104pub struct HotspotEntry {
1105    /// File path relative to the project root.
1106    #[serde(serialize_with = "fallow_types::serde_path::serialize")]
1107    pub path: std::path::PathBuf,
1108    /// Churn-times-complexity hotspot score; higher is riskier.
1109    pub score: f64,
1110    /// Commits touching the file in the analysis window.
1111    pub commits: u32,
1112    /// Recency-weighted commit count.
1113    pub weighted_commits: f64,
1114    /// Lines added to the file in the analysis window.
1115    pub lines_added: u32,
1116    /// Lines deleted from the file in the analysis window.
1117    pub lines_deleted: u32,
1118    /// Total cyclomatic complexity per line of code.
1119    pub complexity_density: f64,
1120    /// Modules importing this file.
1121    pub fan_in: usize,
1122    /// Whether churn is rising, falling, or steady over the window.
1123    pub trend: fallow_types::churn::ChurnTrend,
1124    /// Change dispersion: the median number of analyzed files in the commits
1125    /// that changed this file, over the change coupling history window. Bulk
1126    /// commits above the coupling file cap are not counted. Absent when the
1127    /// history has no commit ids (for example imported churn without them).
1128    #[serde(default, skip_serializing_if = "Option::is_none")]
1129    pub median_files_per_commit: Option<f64>,
1130    /// Change dispersion: the median number of changed lines (added plus
1131    /// deleted, analyzed files only) in the commits that changed this file.
1132    /// Same window and rules as `median_files_per_commit`.
1133    #[serde(default, skip_serializing_if = "Option::is_none")]
1134    pub median_lines_per_commit: Option<f64>,
1135    /// Ownership metrics, when ownership analysis ran.
1136    #[serde(default, skip_serializing_if = "Option::is_none")]
1137    pub ownership: Option<OwnershipMetrics>,
1138    /// True for files matched by test-path patterns; omitted when false.
1139    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1140    #[cfg_attr(feature = "schema", schemars(default))]
1141    pub is_test_path: bool,
1142}
1143
1144/// One contributor row in ownership metrics.
1145#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1146#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1147pub struct ContributorEntry {
1148    /// Contributor identifier, encoded per `format`.
1149    pub identifier: String,
1150    /// How `identifier` is encoded (raw, handle, anonymized, or hash).
1151    pub format: ContributorIdentifierFormat,
1152    /// Contributor's share of the file's commits, in `[0, 1]`.
1153    pub share: f64,
1154    /// Days since the contributor's last commit to the file.
1155    pub stale_days: u64,
1156    /// Contributor's commits touching the file in the window.
1157    pub commits: u32,
1158}
1159
1160/// Encoding applied to a [`ContributorEntry::identifier`].
1161#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1162#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1163#[serde(rename_all = "kebab-case")]
1164pub enum ContributorIdentifierFormat {
1165    /// Raw git author identity.
1166    Raw,
1167    /// Platform handle, e.g. a GitHub username.
1168    Handle,
1169    /// Anonymized label that stays stable within the report.
1170    Anonymized,
1171    /// One-way hash of the identity.
1172    Hash,
1173}
1174
1175/// Ownership lifecycle state of a file.
1176#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1177#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1178#[serde(rename_all = "snake_case")]
1179pub enum OwnershipState {
1180    /// A declared or de-facto owner is still actively contributing.
1181    Active,
1182    /// No owner could be resolved.
1183    Unowned,
1184    /// The declared owner has stopped contributing.
1185    DeclaredInactive,
1186    /// Recent contributions come from outside the declared ownership.
1187    Drifting,
1188}
1189
1190/// Ownership metrics for a hotspot file, derived from git history and
1191/// CODEOWNERS declarations.
1192#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1193#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1194pub struct OwnershipMetrics {
1195    /// Minimum contributors covering half the file's commits.
1196    pub bus_factor: u32,
1197
1198    /// Distinct contributors touching the file in the window.
1199    pub contributor_count: u32,
1200
1201    /// Contributor with the largest commit share.
1202    pub top_contributor: ContributorEntry,
1203
1204    /// Contributors active in the recent window; omitted when empty.
1205    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1206    #[cfg_attr(feature = "schema", schemars(default))]
1207    pub recent_contributors: Vec<ContributorEntry>,
1208
1209    /// Contributors best positioned to review changes; omitted when empty.
1210    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1211    #[cfg_attr(feature = "schema", schemars(default))]
1212    pub suggested_reviewers: Vec<ContributorEntry>,
1213
1214    /// Owner declared in CODEOWNERS, when one matches the file.
1215    #[serde(default, skip_serializing_if = "Option::is_none")]
1216    pub declared_owner: Option<String>,
1217
1218    /// Whether no owner could be resolved; `null` when ownership resolution
1219    /// did not run.
1220    pub unowned: Option<bool>,
1221
1222    /// Ownership lifecycle state.
1223    pub ownership_state: OwnershipState,
1224
1225    /// True when recent contributions drift away from the declared ownership.
1226    pub drift: bool,
1227
1228    /// Human-readable explanation of the drift, when drifting.
1229    #[serde(default, skip_serializing_if = "Option::is_none")]
1230    pub drift_reason: Option<String>,
1231}
1232
1233/// Where the run's reference epoch came from.
1234///
1235/// Churn recency weighting and ownership staleness are measured against one
1236/// instant. `head_commit` and `environment` resolve to the same value on every
1237/// run over the same commit; `wall_clock` does not.
1238#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1239#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1240#[serde(rename_all = "snake_case")]
1241pub enum ClockSource {
1242    /// Pinned by the `FALLOW_CLOCK_EPOCH` environment variable.
1243    Environment,
1244    /// HEAD's committer timestamp.
1245    HeadCommit,
1246    /// The system wall clock, because no commit timestamp was readable.
1247    WallClock,
1248}
1249
1250/// The instant a run measured commit ages and staleness against.
1251///
1252/// A consumer reading `weighted_commits`, `stale_days`, or anything derived
1253/// from them needs to know whether re-running over the same commit yields the
1254/// same number. The human report says so in a warning that `--quiet` removes,
1255/// which left the JSON consumer, who cannot see stderr at all, with no way to
1256/// find out.
1257#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1258#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1259pub struct ClockProvenance {
1260    /// Which of the three sources supplied the epoch.
1261    pub source: ClockSource,
1262    /// The reference epoch itself, in unix seconds. Pass it back as
1263    /// `FALLOW_CLOCK_EPOCH` to reproduce this run's churn-derived numbers.
1264    pub epoch_secs: u64,
1265    /// False only for `wall_clock`, where the numbers drift between runs.
1266    pub reproducible: bool,
1267}
1268
1269/// Scope metadata for the hotspot analysis.
1270#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1271#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1272pub struct HotspotSummary {
1273    /// Start of the churn window, as passed to `git log --since`.
1274    pub since: String,
1275    /// Minimum commit count for a file to qualify as a hotspot.
1276    pub min_commits: u32,
1277    /// Files with churn data in the window.
1278    pub files_analyzed: usize,
1279    /// Files excluded by test-path and ignore filters.
1280    pub files_excluded: usize,
1281    /// True when the repository is a shallow clone, so churn counts are
1282    /// truncated.
1283    pub shallow_clone: bool,
1284    /// Provenance of the instant every churn and staleness number was measured
1285    /// against. Absent only when a caller assembled a summary without one.
1286    #[serde(default, skip_serializing_if = "Option::is_none")]
1287    pub clock: Option<ClockProvenance>,
1288}
1289
1290#[cfg(test)]
1291mod tests {
1292    use super::*;
1293
1294    #[test]
1295    fn file_score_suppression_metadata_preserves_legacy_and_explicit_zero() {
1296        let legacy = serde_json::json!({
1297            "path": "src/legacy.ts", "fan_in": 1, "fan_out": 0,
1298            "dead_code_ratio": 0.0, "complexity_density": 0.77, "maintainability_index": 88.0,
1299            "total_cyclomatic": 20, "total_cognitive": 18, "function_count": 2, "lines": 26,
1300            "crap_max": 110.0, "crap_above_threshold": 0
1301        });
1302        let restored: FileHealthScore =
1303            serde_json::from_value(legacy.clone()).expect("legacy file score");
1304        assert_eq!(restored.crap_suppressed, 0);
1305        assert_eq!(
1306            serde_json::to_value(restored).expect("serialize legacy file score"),
1307            legacy
1308        );
1309
1310        let mut suppressed = legacy;
1311        suppressed["crap_suppressed"] = serde_json::json!(2);
1312        suppressed["crap_unsuppressed_max"] = serde_json::json!(0.0);
1313        suppressed["crap_unsuppressed_effective_threshold"] = serde_json::json!(30.0);
1314        suppressed["crap_exempted"] = serde_json::json!(1);
1315        suppressed["crap_effective_threshold"] = serde_json::json!(500.0);
1316        let restored: FileHealthScore =
1317            serde_json::from_value(suppressed.clone()).expect("suppressed file score");
1318        assert_eq!(restored.crap_max, 110.0);
1319        assert_eq!(restored.crap_unsuppressed_max, Some(0.0));
1320        assert_eq!(restored.crap_unsuppressed_effective_threshold, Some(30.0));
1321        assert_eq!(
1322            serde_json::to_value(restored).expect("serialize suppressed file score"),
1323            suppressed
1324        );
1325    }
1326
1327    /// The three tokens are the wire contract a consumer branches on, so they
1328    /// are pinned here rather than left to the derive.
1329    #[test]
1330    fn clock_source_serializes_as_snake_case_tokens() {
1331        let tokens: Vec<String> = [
1332            ClockSource::Environment,
1333            ClockSource::HeadCommit,
1334            ClockSource::WallClock,
1335        ]
1336        .into_iter()
1337        .map(|source| serde_json::to_string(&source).expect("clock source should serialize"))
1338        .collect();
1339        assert_eq!(
1340            tokens,
1341            [r#""environment""#, r#""head_commit""#, r#""wall_clock""#]
1342        );
1343    }
1344
1345    /// A summary without a clock stays byte-identical to the shape consumers
1346    /// already parse; one with a clock publishes all three members.
1347    #[test]
1348    fn hotspot_summary_clock_is_omitted_when_absent_and_complete_when_present() {
1349        let mut summary = HotspotSummary {
1350            since: "6 months".to_owned(),
1351            min_commits: 3,
1352            files_analyzed: 4,
1353            files_excluded: 1,
1354            shallow_clone: false,
1355            clock: None,
1356        };
1357        let json = serde_json::to_string(&summary).expect("summary should serialize");
1358        assert!(!json.contains("clock"), "{json}");
1359
1360        summary.clock = Some(ClockProvenance {
1361            source: ClockSource::WallClock,
1362            epoch_secs: 1_788_782_400,
1363            reproducible: false,
1364        });
1365        let value: serde_json::Value =
1366            serde_json::to_value(&summary).expect("summary should serialize");
1367        assert_eq!(value["clock"]["source"], "wall_clock");
1368        assert_eq!(value["clock"]["epoch_secs"], 1_788_782_400_u64);
1369        assert_eq!(value["clock"]["reproducible"], false);
1370    }
1371
1372    #[test]
1373    fn exceeded_threshold_serializes_as_snake_case() {
1374        let json = serde_json::to_string(&ExceededThreshold::Both)
1375            .expect("threshold variant should serialize");
1376        assert_eq!(json, r#""both""#);
1377
1378        let json = serde_json::to_string(&ExceededThreshold::Cyclomatic)
1379            .expect("threshold variant should serialize");
1380        assert_eq!(json, r#""cyclomatic""#);
1381    }
1382
1383    #[test]
1384    fn exceeded_threshold_all_variants_serialize() {
1385        for (variant, expected) in [
1386            (ExceededThreshold::Cyclomatic, r#""cyclomatic""#),
1387            (ExceededThreshold::Cognitive, r#""cognitive""#),
1388            (ExceededThreshold::Both, r#""both""#),
1389            (ExceededThreshold::Crap, r#""crap""#),
1390            (ExceededThreshold::CyclomaticCrap, r#""cyclomatic_crap""#),
1391            (ExceededThreshold::CognitiveCrap, r#""cognitive_crap""#),
1392            (ExceededThreshold::All, r#""all""#),
1393        ] {
1394            let json = serde_json::to_string(&variant).expect("threshold variant should serialize");
1395            assert_eq!(json, expected, "wire form for {variant:?} should be stable");
1396        }
1397    }
1398
1399    #[test]
1400    fn letter_grade_boundaries() {
1401        assert_eq!(letter_grade(100.0), "A");
1402        assert_eq!(letter_grade(85.0), "A");
1403        assert_eq!(letter_grade(84.9), "B");
1404        assert_eq!(letter_grade(70.0), "B");
1405        assert_eq!(letter_grade(69.9), "C");
1406        assert_eq!(letter_grade(55.0), "C");
1407        assert_eq!(letter_grade(54.9), "D");
1408        assert_eq!(letter_grade(40.0), "D");
1409        assert_eq!(letter_grade(39.9), "F");
1410        assert_eq!(letter_grade(0.0), "F");
1411    }
1412
1413    #[test]
1414    fn coverage_tier_boundaries() {
1415        assert_eq!(CoverageTier::from_pct(0.0), CoverageTier::None);
1416        assert_eq!(CoverageTier::from_pct(0.1), CoverageTier::Partial);
1417        assert_eq!(CoverageTier::from_pct(69.9), CoverageTier::Partial);
1418        assert_eq!(CoverageTier::from_pct(70.0), CoverageTier::High);
1419        assert_eq!(CoverageTier::from_pct(100.0), CoverageTier::High);
1420    }
1421
1422    #[test]
1423    fn hotspot_score_threshold_is_50() {
1424        assert!((HOTSPOT_SCORE_THRESHOLD - 50.0).abs() < f64::EPSILON);
1425    }
1426
1427    #[test]
1428    fn health_score_serializes_correctly() {
1429        let score = HealthScore {
1430            formula_version: HEALTH_SCORE_FORMULA_VERSION,
1431            score: 78.5,
1432            grade: "B",
1433            penalties: HealthScorePenalties {
1434                dead_files: Some(3.1),
1435                dead_exports: Some(6.0),
1436                complexity: 0.0,
1437                p90_complexity: 0.0,
1438                maintainability: None,
1439                hotspots: None,
1440                unused_deps: Some(5.0),
1441                circular_deps: Some(4.0),
1442                unit_size: None,
1443                coupling: None,
1444                duplication: None,
1445                prop_drilling: None,
1446            },
1447        };
1448        let json = serde_json::to_string(&score).expect("health score should serialize");
1449        let parsed: serde_json::Value =
1450            serde_json::from_str(&json).expect("health score JSON should parse");
1451        assert_eq!(parsed["formula_version"], HEALTH_SCORE_FORMULA_VERSION);
1452        assert_eq!(parsed["score"], 78.5);
1453        assert_eq!(parsed["grade"], "B");
1454        assert_eq!(parsed["penalties"]["dead_files"], 3.1);
1455        assert!(!json.contains("maintainability"));
1456        assert!(!json.contains("hotspots"));
1457        assert!(!json.contains("duplication"));
1458    }
1459
1460    #[test]
1461    fn styling_health_serializes_correctly() {
1462        let styling = StylingHealth {
1463            formula_version: STYLING_HEALTH_FORMULA_VERSION,
1464            score: 72.0,
1465            grade: "B",
1466            penalties: StylingHealthPenalties {
1467                duplication: 12.0,
1468                dead_surface: 8.0,
1469                broken_references: 4.0,
1470                token_erosion: 2.0,
1471                structural: 2.0,
1472            },
1473            confidence: StylingHealthConfidence::High,
1474            confidence_reason: None,
1475        };
1476        let json = serde_json::to_string(&styling).expect("styling health should serialize");
1477        let parsed: serde_json::Value =
1478            serde_json::from_str(&json).expect("styling health JSON should parse");
1479        assert_eq!(parsed["formula_version"], STYLING_HEALTH_FORMULA_VERSION);
1480        assert_eq!(parsed["score"], 72.0);
1481        assert_eq!(parsed["grade"], "B");
1482        assert_eq!(parsed["penalties"]["duplication"], 12.0);
1483        assert_eq!(parsed["penalties"]["dead_surface"], 8.0);
1484        assert_eq!(parsed["penalties"]["broken_references"], 4.0);
1485        assert_eq!(parsed["penalties"]["token_erosion"], 2.0);
1486        assert_eq!(parsed["penalties"]["structural"], 2.0);
1487        // `high` confidence omits the reason; the enum serializes lowercase.
1488        assert_eq!(parsed["confidence"], "high");
1489        assert!(parsed.get("confidence_reason").is_none());
1490    }
1491
1492    #[test]
1493    fn styling_health_low_confidence_serializes_reason() {
1494        let styling = StylingHealth {
1495            formula_version: STYLING_HEALTH_FORMULA_VERSION,
1496            score: 89.0,
1497            grade: "A",
1498            penalties: StylingHealthPenalties {
1499                duplication: 0.0,
1500                dead_surface: 0.0,
1501                broken_references: 0.0,
1502                token_erosion: 0.0,
1503                structural: 0.0,
1504            },
1505            confidence: StylingHealthConfidence::Low,
1506            confidence_reason: Some("graded from only 24 declarations across 2 stylesheets".into()),
1507        };
1508        let json = serde_json::to_string(&styling).expect("styling health should serialize");
1509        let parsed: serde_json::Value =
1510            serde_json::from_str(&json).expect("styling health JSON should parse");
1511        assert_eq!(parsed["confidence"], "low");
1512        assert_eq!(
1513            parsed["confidence_reason"],
1514            "graded from only 24 declarations across 2 stylesheets"
1515        );
1516    }
1517
1518    #[test]
1519    fn coverage_model_serializes_as_snake_case() {
1520        let json = serde_json::to_string(&CoverageModel::StaticBinary)
1521            .expect("coverage model should serialize");
1522        assert_eq!(json, r#""static_binary""#);
1523
1524        let json = serde_json::to_string(&CoverageModel::StaticEstimated)
1525            .expect("coverage model should serialize");
1526        assert_eq!(json, r#""static_estimated""#);
1527
1528        let json = serde_json::to_string(&CoverageModel::Istanbul)
1529            .expect("coverage model should serialize");
1530        assert_eq!(json, r#""istanbul""#);
1531    }
1532
1533    #[test]
1534    fn finding_severity_serializes_as_snake_case() {
1535        assert_eq!(
1536            serde_json::to_string(&FindingSeverity::Moderate)
1537                .expect("finding severity should serialize"),
1538            r#""moderate""#,
1539        );
1540        assert_eq!(
1541            serde_json::to_string(&FindingSeverity::High)
1542                .expect("finding severity should serialize"),
1543            r#""high""#,
1544        );
1545        assert_eq!(
1546            serde_json::to_string(&FindingSeverity::Critical)
1547                .expect("finding severity should serialize"),
1548            r#""critical""#,
1549        );
1550    }
1551
1552    #[test]
1553    fn finding_severity_ordering() {
1554        assert!(FindingSeverity::Moderate < FindingSeverity::High);
1555        assert!(FindingSeverity::High < FindingSeverity::Critical);
1556    }
1557
1558    #[test]
1559    fn compute_severity_moderate_when_below_high_thresholds() {
1560        let severity = compute_finding_severity(20, 25, None, 25, 40, 30, 50);
1561        assert_eq!(severity, FindingSeverity::Moderate);
1562    }
1563
1564    #[test]
1565    fn compute_severity_high_from_cognitive() {
1566        let severity = compute_finding_severity(25, 20, None, 25, 40, 30, 50);
1567        assert_eq!(severity, FindingSeverity::High);
1568    }
1569
1570    #[test]
1571    fn compute_severity_high_from_cyclomatic() {
1572        let severity = compute_finding_severity(20, 30, None, 25, 40, 30, 50);
1573        assert_eq!(severity, FindingSeverity::High);
1574    }
1575
1576    #[test]
1577    fn compute_severity_critical_from_cognitive() {
1578        let severity = compute_finding_severity(40, 20, None, 25, 40, 30, 50);
1579        assert_eq!(severity, FindingSeverity::Critical);
1580    }
1581
1582    #[test]
1583    fn compute_severity_critical_from_cyclomatic() {
1584        let severity = compute_finding_severity(20, 50, None, 25, 40, 30, 50);
1585        assert_eq!(severity, FindingSeverity::Critical);
1586    }
1587
1588    #[test]
1589    fn compute_severity_uses_highest_across_dimensions() {
1590        let severity = compute_finding_severity(45, 20, None, 25, 40, 30, 50);
1591        assert_eq!(severity, FindingSeverity::Critical);
1592    }
1593
1594    #[test]
1595    fn compute_severity_at_exact_boundaries() {
1596        let severity = compute_finding_severity(25, 30, None, 25, 40, 30, 50);
1597        assert_eq!(severity, FindingSeverity::High);
1598
1599        let severity = compute_finding_severity(24, 29, None, 25, 40, 30, 50);
1600        assert_eq!(severity, FindingSeverity::Moderate);
1601
1602        let severity = compute_finding_severity(40, 50, None, 25, 40, 30, 50);
1603        assert_eq!(severity, FindingSeverity::Critical);
1604    }
1605
1606    #[test]
1607    fn compute_severity_crap_contributes_high() {
1608        let severity = compute_finding_severity(10, 10, Some(60.0), 25, 40, 30, 50);
1609        assert_eq!(severity, FindingSeverity::High);
1610    }
1611
1612    #[test]
1613    fn compute_severity_crap_contributes_critical() {
1614        let severity = compute_finding_severity(10, 10, Some(120.0), 25, 40, 30, 50);
1615        assert_eq!(severity, FindingSeverity::Critical);
1616    }
1617
1618    #[test]
1619    fn compute_severity_crap_moderate_under_high() {
1620        let severity = compute_finding_severity(10, 10, Some(30.0), 25, 40, 30, 50);
1621        assert_eq!(severity, FindingSeverity::Moderate);
1622    }
1623
1624    #[test]
1625    fn exceeded_threshold_from_bools() {
1626        assert!(matches!(
1627            ExceededThreshold::from_bools(true, false, false),
1628            ExceededThreshold::Cyclomatic
1629        ));
1630        assert!(matches!(
1631            ExceededThreshold::from_bools(true, true, true),
1632            ExceededThreshold::All
1633        ));
1634        assert!(matches!(
1635            ExceededThreshold::from_bools(false, false, true),
1636            ExceededThreshold::Crap
1637        ));
1638        assert!(matches!(
1639            ExceededThreshold::from_bools(true, false, true),
1640            ExceededThreshold::CyclomaticCrap
1641        ));
1642    }
1643
1644    #[test]
1645    fn exceeded_threshold_includes_helpers() {
1646        let all = ExceededThreshold::All;
1647        assert!(all.includes_cyclomatic());
1648        assert!(all.includes_cognitive());
1649        assert!(all.includes_crap());
1650
1651        let crap_only = ExceededThreshold::Crap;
1652        assert!(!crap_only.includes_cyclomatic());
1653        assert!(!crap_only.includes_cognitive());
1654        assert!(crap_only.includes_crap());
1655
1656        assert!(ExceededThreshold::CyclomaticCrap.includes_crap());
1657        assert!(ExceededThreshold::CognitiveCrap.includes_crap());
1658        assert!(!ExceededThreshold::Both.includes_crap());
1659        assert!(!ExceededThreshold::Cyclomatic.includes_crap());
1660        assert!(!ExceededThreshold::Cognitive.includes_crap());
1661    }
1662
1663    #[test]
1664    fn coverage_source_consistency_omits_empty_sources() {
1665        let sources = Vec::new();
1666        assert_eq!(summarize_coverage_source_consistency(sources), None);
1667    }
1668
1669    #[test]
1670    fn coverage_source_consistency_reports_uniform_sources() {
1671        assert_eq!(
1672            summarize_coverage_source_consistency([
1673                CoverageSource::Estimated,
1674                CoverageSource::Estimated,
1675            ]),
1676            Some(CoverageSourceConsistency::Uniform)
1677        );
1678    }
1679
1680    #[test]
1681    fn coverage_source_consistency_reports_mixed_sources() {
1682        assert_eq!(
1683            summarize_coverage_source_consistency([
1684                CoverageSource::Istanbul,
1685                CoverageSource::Estimated,
1686            ]),
1687            Some(CoverageSourceConsistency::Mixed)
1688        );
1689    }
1690}