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