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