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}