Skip to main content

fallow_output/
health_scores.rs

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