Skip to main content

fallow_output/
health_report.rs

1//! Top-level health report contract.
2
3use crate::{
4    ChangeCouplingReport, CoverageGaps, CoverageIntelligenceReport, CssAnalyticsReport,
5    FileHealthScore, FrameworkHealthDiagnostics, HealthActionsMeta, HealthFinding, HealthScore,
6    HealthSummary, HealthTrend, HotspotFinding, HotspotSummary, LargeFunctionEntry,
7    RefactoringTargetFinding, RuntimeCoverageReport, StylingFinding, StylingHealth,
8    TargetThresholds, ThresholdOverrideState, VitalSigns,
9};
10use fallow_types::output_dead_code::PropDrillingChainFinding;
11
12/// One health section that a run produced.
13///
14/// A section is in [`HealthReport::sections`] when the run computed it and
15/// the report carries its result, also when that result is empty. The value
16/// set is OPEN: a later release can add a section, so a consumer must accept
17/// a token that it does not know.
18#[derive(
19    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
20)]
21#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
22#[serde(rename_all = "kebab-case")]
23pub enum HealthSection {
24    /// The run lists the complexity findings in `findings`. An empty list then
25    /// means that no function above a threshold is left after the baseline.
26    /// Without this token, `findings` is empty because the run did not list
27    /// them, and `summary.functions_above_threshold` is the only count.
28    Complexity,
29    /// `vital_signs`.
30    VitalSigns,
31    /// `health_score`.
32    Score,
33    /// `file_scores`.
34    FileScores,
35    /// `coverage_gaps`.
36    CoverageGaps,
37    /// `hotspots`. The list can be empty when the run could not read the git
38    /// history; `workspace_diagnostics` then tells why.
39    Hotspots,
40    /// `change_coupling`. The pair list can be empty when the history cannot
41    /// show coupling; `workspace_diagnostics` then tells why.
42    ChangeCoupling,
43    /// `targets`.
44    Targets,
45    /// `health_trend`.
46    Trend,
47    /// `runtime_coverage`.
48    RuntimeCoverage,
49    /// `mutation`: the join of a saved mutation report with the findings
50    /// (`--mutation-report`).
51    Mutation,
52    /// `css_analytics`, `styling_health` and `styling_findings`.
53    Css,
54}
55
56/// Result of complexity analysis for reporting.
57#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
58#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
59pub struct HealthReport {
60    /// Functions and synthetic template entries exceeding complexity
61    /// thresholds, sorted by the --sort criteria. Each entry wraps its
62    /// inner `ComplexityViolation` payload (flattened on the wire) with
63    /// the typed `actions` list and an optional audit-mode `introduced`
64    /// flag.
65    pub findings: Vec<HealthFinding>,
66    /// Summary statistics.
67    pub summary: HealthSummary,
68    /// The sections that this run produced, in a fixed order. A renderer
69    /// reads it to tell an empty section from a section that the run did not
70    /// produce: `findings` is the complexity list only when `complexity` is
71    /// in this array. The value set is OPEN (see [`HealthSection`]). Absent
72    /// in an envelope from a fallow version before this member, and on a
73    /// report that no health run built.
74    #[serde(
75        default,
76        skip_serializing_if = "Option::is_none",
77        deserialize_with = "deserialize_open_sections"
78    )]
79    pub sections: Option<Vec<HealthSection>>,
80    /// Configured threshold override states. Entries are emitted for active
81    /// exceptions, stale exceptions, and full-run no-match cleanup hints.
82    #[serde(default, skip_serializing_if = "Vec::is_empty")]
83    pub threshold_overrides: Vec<ThresholdOverrideState>,
84    /// Project-wide vital signs (always computed from available data).
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub vital_signs: Option<VitalSigns>,
87    /// Project-wide health score (only populated with `--score`).
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub health_score: Option<HealthScore>,
90    /// Per-file health scores. Only present when --file-scores is used. Sorted
91    /// by risk-aware triage concern, combining low maintainability and high
92    /// CRAP risk. Zero-function files (barrels) are excluded by default.
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub file_scores: Vec<FileHealthScore>,
95    /// Static coverage gaps.
96    ///
97    /// Populated when coverage gaps are explicitly requested, or when the
98    /// top-level `health` command allows config severity to surface them in the
99    /// default report.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub coverage_gaps: Option<CoverageGaps>,
102    /// Located prop-drilling chains (React/Preact props forwarded unchanged
103    /// through 3+ pass-through components). Only present when the opt-in
104    /// `prop-drilling` rule is enabled (it defaults to off). Each entry carries
105    /// the source, every pass-through hop, and the consumer with file + line +
106    /// component, so CI / an agent can act. Surfaced alongside hotspots as a
107    /// graph-derived health signal.
108    #[serde(default, skip_serializing_if = "Vec::is_empty")]
109    pub prop_drilling_chains: Vec<PropDrillingChainFinding>,
110    /// Hotspot entries combining git churn with complexity. Only present when
111    /// --hotspots is used. Sorted by score descending (highest risk first).
112    /// Each entry wraps its inner `HotspotEntry` payload (flattened on the
113    /// wire) with a typed `actions` list.
114    #[serde(default, skip_serializing_if = "Vec::is_empty")]
115    pub hotspots: Vec<HotspotFinding>,
116    /// Hotspot analysis summary.
117    ///
118    /// Set whenever the run measured churn, which needs readable git history;
119    /// `--hotspots` adds the per-file [`hotspots`](Self::hotspots) listing
120    /// beside it rather than gating this summary.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub hotspot_summary: Option<HotspotSummary>,
123    /// Hidden change coupling: file pairs that change together in the git
124    /// history without an import or re-export chain between them. Present
125    /// when the hotspots section ran and the run read the git history.
126    /// Advisory: no health score weight.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub change_coupling: Option<ChangeCouplingReport>,
129    /// Runtime coverage findings from the paid sidecar (only populated with
130    /// `--runtime-coverage`).
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub runtime_coverage: Option<RuntimeCoverageReport>,
133    /// The join of a saved mutation report with the complexity findings
134    /// (only populated with `--mutation-report`). It never changes the CRAP
135    /// scores or the health score.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub mutation: Option<crate::HealthMutationSummary>,
138    /// Combined coverage, runtime, complexity, and change-scope verdicts.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub coverage_intelligence: Option<CoverageIntelligenceReport>,
141    /// Functions exceeding 60 LOC (very high risk). Only present when unit size
142    /// very-high-risk bin >= 3%. Sorted by line count descending.
143    #[serde(default, skip_serializing_if = "Vec::is_empty")]
144    pub large_functions: Vec<LargeFunctionEntry>,
145    /// Ranked refactoring recommendations. Only present when --targets is used.
146    /// Sorted by efficiency (priority/effort) descending. Each entry wraps
147    /// its inner `RefactoringTarget` payload (flattened on the wire) with
148    /// a typed `actions` list.
149    #[serde(default, skip_serializing_if = "Vec::is_empty")]
150    pub targets: Vec<RefactoringTargetFinding>,
151    /// Adaptive thresholds used for target scoring (only set with `--targets`).
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub target_thresholds: Option<TargetThresholds>,
154    /// Health trend comparison against a previous snapshot (only set with `--trend`).
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub health_trend: Option<HealthTrend>,
157    /// Audit breadcrumb explaining systemic action-array adjustments. Present
158    /// only when at least one adjustment was made (e.g., health finding
159    /// suppression hints omitted because a baseline is active). When --group-by
160    /// is active, each entry of `groups` may carry its own `actions_meta`
161    /// describing the same omission so per-group consumers do not need to walk
162    /// back to the report root.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub actions_meta: Option<HealthActionsMeta>,
165    /// Optional framework-specific detector coverage. Present only when the
166    /// health run already needed the dead-code analysis output.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub framework_health: Option<FrameworkHealthDiagnostics>,
169    /// Structural CSS analytics (specificity hotspots, `!important` density,
170    /// over-complex selectors, deep nesting). Present only with `--css`.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub css_analytics: Option<CssAnalyticsReport>,
173    /// Styling-health score and letter grade: a SECOND health axis derived from
174    /// the CSS analytics (the design-system axis), orthogonal to the JS/TS code
175    /// `health_score`. Present only with `--css` (the same condition as
176    /// `css_analytics`), so a plain `fallow health` run is byte-unchanged. The
177    /// code score is never affected by this field.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub styling_health: Option<StylingHealth>,
180    /// Advisory STYLING FINDINGS: the graduation of the descriptive css
181    /// candidates into first-class, severity-aware, suppressible findings
182    /// surfaced in `fallow audit`. Verdict-neutral by default (the rule defaults
183    /// to `warn`); the styling domain's OWN findings collection, not the dead-code
184    /// `AnalysisResults`. Present only with `--css`; empty is skipped so a plain
185    /// run is byte-unchanged.
186    #[serde(default, skip_serializing_if = "Vec::is_empty")]
187    pub styling_findings: Vec<StylingFinding>,
188    /// Per-file top render fan-in for the descriptive human drill-down only.
189    #[serde(skip)]
190    pub render_fan_in_top: rustc_hash::FxHashMap<std::path::PathBuf, (String, u32)>,
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn health_report_skips_empty_collections() {
199        let report = HealthReport::default();
200        let json = serde_json::to_string(&report).expect("health report should serialize");
201        assert!(!json.contains("file_scores"));
202        assert!(!json.contains("hotspots"));
203        assert!(!json.contains("hotspot_summary"));
204        assert!(!json.contains("runtime_coverage"));
205        assert!(!json.contains("coverage_intelligence"));
206        assert!(!json.contains("large_functions"));
207        assert!(!json.contains("targets"));
208        assert!(!json.contains("threshold_overrides"));
209        assert!(!json.contains("vital_signs"));
210        assert!(!json.contains("health_score"));
211        assert!(!json.contains("framework_health"));
212        assert!(!json.contains("css_analytics"));
213        assert!(!json.contains("styling_health"));
214        assert!(!json.contains("styling_findings"));
215    }
216
217    #[test]
218    fn health_score_none_skipped_in_report() {
219        let report = HealthReport::default();
220        let json = serde_json::to_string(&report).expect("health report should serialize");
221        assert!(!json.contains("health_score"));
222    }
223}
224
225/// Read `sections` from a saved envelope. The value set is open, so a token
226/// from a later fallow version is skipped instead of failing the whole read.
227fn deserialize_open_sections<'de, D: serde::Deserializer<'de>>(
228    deserializer: D,
229) -> Result<Option<Vec<HealthSection>>, D::Error> {
230    let tokens: Option<Vec<serde_json::Value>> = serde::Deserialize::deserialize(deserializer)?;
231    Ok(tokens.map(|tokens| {
232        tokens
233            .into_iter()
234            .filter_map(|token| serde_json::from_value(token).ok())
235            .collect()
236    }))
237}
238
239#[cfg(test)]
240mod open_sections_tests {
241    use super::*;
242
243    #[derive(serde::Deserialize)]
244    struct Envelope {
245        #[serde(default, deserialize_with = "deserialize_open_sections")]
246        sections: Option<Vec<HealthSection>>,
247    }
248
249    #[test]
250    fn an_unknown_section_token_is_skipped() {
251        let envelope: Envelope =
252            serde_json::from_str(r#"{"sections":["complexity","later-section","hotspots"]}"#)
253                .expect("an unknown token must not fail the read");
254        assert_eq!(
255            envelope.sections,
256            Some(vec![HealthSection::Complexity, HealthSection::Hotspots])
257        );
258    }
259
260    #[test]
261    fn an_absent_member_stays_absent() {
262        let envelope: Envelope = serde_json::from_str("{}").expect("valid envelope");
263        assert_eq!(envelope.sections, None);
264    }
265}