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