fallow_types/output_health.rs
1//! Per-action types attached to each health finding by the JSON output
2//! layer.
3//!
4//! These types are the typed wire shape for the `actions[]` array on health
5//! findings, hotspots, refactoring targets, and coverage-gap entries. The
6//! JSON emission path constructs them through typed wrappers (for example
7//! `UntestedFileFinding` in `crates/cli/src/health_types/coverage.rs`) and
8//! serializes them via serde; the schemars derive renders the matching
9//! per-action shape in `docs/output-schema.json`.
10//!
11//! Whenever a new action variant or optional field is added, update the
12//! matching type here so the drift gate flags the divergence before review.
13
14use serde::Serialize;
15
16/// Suggested action attached to a [`ComplexityViolation`].
17///
18/// Each complexity finding carries an array of these on the JSON wire
19/// (`findings[].actions[]`). The action selector in
20/// `crates/cli/src/report/json.rs::build_health_finding_actions` picks the
21/// primary action based on which thresholds triggered the finding and the
22/// bucketed coverage tier. See [`HealthFindingActionType`] for the full
23/// discriminant list.
24///
25/// `note`, `comment`, and `placement` are populated per-variant: refactor
26/// actions carry a `note`, suppress-line / suppress-file actions carry
27/// `comment` plus `placement`, and the coverage-leaning actions
28/// (`add-tests`, `increase-coverage`) carry only `note`.
29///
30/// [`ComplexityViolation`]: ../../fallow-cli/src/health_types/scores.rs
31#[derive(Debug, Clone, Serialize)]
32#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
33pub struct HealthFindingAction {
34 /// Action type identifier. A single finding's `actions` array can carry
35 /// MULTIPLE entries of different types: e.g., a finding that exceeded
36 /// both cyclomatic and CRAP at `coverage_tier`: partial will get BOTH
37 /// `increase-coverage` AND `refactor-function`, plus `suppress-line`.
38 /// Consumers that select a single action should treat the FIRST
39 /// non-`suppress-{line,file}` action as primary. `add-tests` is emitted
40 /// when CRAP triggered the finding, the function has no test coverage
41 /// (`coverage_tier`: none), and full coverage can bring CRAP below
42 /// `max_crap_threshold` (cyclomatic < threshold, since CRAP bottoms out
43 /// at CC at 100% coverage). `increase-coverage` is emitted when CRAP
44 /// triggered the finding, some coverage exists (`coverage_tier`: partial
45 /// or high), and full coverage can bring CRAP below `max_crap_threshold`;
46 /// the description steers toward targeted branch coverage rather than
47 /// scaffolding new tests. `refactor-function` is emitted when
48 /// cyclomatic/cognitive triggered the finding, when full coverage still
49 /// cannot bring CRAP below `max_crap_threshold` (cyclomatic >=
50 /// threshold), or as a secondary action when cyclomatic is within the
51 /// configured `health.crapRefactorBand` of the cyclomatic threshold AND
52 /// cognitive is at or above `max_cognitive_threshold / 2` (the cognitive
53 /// floor suppresses false positives on flat type-tag dispatchers and JSX
54 /// render maps where high cyclomatic comes from a single switch with
55 /// near-zero cognitive load). `suppress-file` is emitted instead of
56 /// `suppress-line` for
57 /// synthetic Angular `<template>` findings on `.html` files, because
58 /// line-suppression comments cannot be expressed in HTML; the `comment`
59 /// field carries `<!-- fallow-ignore-file complexity -->` and
60 /// `placement` is `top-of-template`.
61 #[serde(rename = "type")]
62 pub kind: HealthFindingActionType,
63 /// Whether `fallow fix` can auto-apply this action. Today every health
64 /// finding action is manual, but the field is non-singleton so a future
65 /// auto-applier (e.g., an LLM-driven `refactor-function` worker) does
66 /// not need a schema change.
67 pub auto_fixable: bool,
68 /// Human-readable description of the action.
69 pub description: String,
70 /// Additional context (e.g., the canonical CRAP formula, or a hint
71 /// about which branch type to extract). Present on most action types;
72 /// dropped only when the description carries the full ask.
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub note: Option<String>,
75 /// The inline comment to insert (e.g.,
76 /// `// fallow-ignore-next-line complexity` or
77 /// `<!-- fallow-ignore-file complexity -->`). Present on
78 /// `suppress-line` and `suppress-file` action variants.
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub comment: Option<String>,
81 /// Where to insert the suppress comment
82 /// (e.g., `above-function-declaration`, `above-angular-decorator`,
83 /// `above-component-worst-method`, or `top-of-template`). Present on
84 /// `suppress-line` and `suppress-file` action variants.
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub placement: Option<String>,
87 /// Project-relative path the action should target when the finding's
88 /// remediation lives in a different file from where the finding is
89 /// anchored. Currently populated on the `increase-coverage` action for
90 /// synthetic Angular `<template>` findings whose CRAP is inherited from
91 /// the owning `.component.ts`: the action points at the component file
92 /// (where the user actually adds tests) rather than the `.html` template
93 /// (where the finding is anchored but which is not directly testable).
94 /// Absent when the action's target is the finding's own file.
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub target_path: Option<String>,
97}
98
99/// Discriminant for [`HealthFindingAction::kind`]. Mirrors the action types
100/// emitted by `build_health_finding_actions`. A single finding's `actions`
101/// array may carry multiple entries of different types: a finding that
102/// exceeded both cyclomatic and CRAP at `coverage_tier: partial` will get
103/// BOTH `increase-coverage` AND `refactor-function`, plus the trailing
104/// `suppress-line`.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
106#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
107#[serde(rename_all = "kebab-case")]
108pub enum HealthFindingActionType {
109 /// Refactor the function to reduce complexity. Emitted when
110 /// cyclomatic/cognitive triggered the finding, when full coverage
111 /// still cannot bring CRAP below `max_crap_threshold`, or as a
112 /// secondary action when cyclomatic is within the configured
113 /// `health.crapRefactorBand` of the cyclomatic threshold AND cognitive
114 /// is at or above the cognitive floor.
115 RefactorFunction,
116 /// Add tests for a CRAP-triggered finding whose coverage tier is
117 /// `none` (no test path reaches the function).
118 AddTests,
119 /// Increase test coverage for a CRAP-triggered finding whose coverage
120 /// tier is `partial` or `high` (some test path exists; add targeted
121 /// assertions for uncovered branches).
122 IncreaseCoverage,
123 /// Suppress with an HTML comment at the top of the template. Used for
124 /// synthetic Angular `<template>` findings on `.html` files where a
125 /// line suppression cannot be expressed.
126 SuppressFile,
127 /// Suppress with an inline `// fallow-ignore-next-line complexity`
128 /// comment above the function or Angular decorator.
129 SuppressLine,
130}
131
132/// Suggested action attached to a [`HotspotEntry`].
133///
134/// The action list always begins with `refactor-file` plus `add-tests`.
135/// Ownership-derived variants (`low-bus-factor`, `unowned-hotspot`,
136/// `ownership-drift`) are appended only when `--ownership` is active AND
137/// the corresponding signal fires for the hotspot.
138///
139/// [`HotspotEntry`]: ../../fallow-cli/src/health_types/scores.rs
140#[derive(Debug, Clone, Serialize)]
141#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
142pub struct HotspotAction {
143 /// Action type identifier.
144 #[serde(rename = "type")]
145 pub kind: HotspotActionType,
146 /// Whether `fallow fix` can auto-apply this action. Today every
147 /// hotspot action is manual.
148 pub auto_fixable: bool,
149 /// Human-readable description of the action.
150 pub description: String,
151 /// Additional context for the action. Absent on `low-bus-factor` when
152 /// the finding's description already carries the full ask (no
153 /// suggested reviewers and not a low-commit file).
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub note: Option<String>,
156 /// Suggested CODEOWNERS pattern. Present only on `unowned-hotspot`
157 /// actions. Derived per the [`heuristic`](Self::heuristic) field;
158 /// consumers should branch on [`heuristic`](Self::heuristic) rather
159 /// than assume a stable algorithm.
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub suggested_pattern: Option<String>,
162 /// Strategy used to derive [`suggested_pattern`](Self::suggested_pattern).
163 /// Reserved for future evolution (`codeowners-cluster`, etc.).
164 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub heuristic: Option<HotspotActionHeuristic>,
166}
167
168/// Discriminant for [`HotspotAction::kind`].
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
170#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
171#[serde(rename_all = "kebab-case")]
172pub enum HotspotActionType {
173 /// Refactor the hotspot file (high complexity plus frequent change).
174 RefactorFile,
175 /// Add test coverage to reduce change risk on the hotspot file.
176 AddTests,
177 /// Bus factor of 1: a single recent contributor owns the file.
178 /// Emitted only with `--ownership`.
179 LowBusFactor,
180 /// Hotspot matches no CODEOWNERS rule (a rules file exists but no
181 /// pattern matches). Emitted only with `--ownership`.
182 UnownedHotspot,
183 /// Ownership has drifted from the original author to a new top
184 /// contributor. Emitted only with `--ownership`.
185 OwnershipDrift,
186}
187
188/// Strategy discriminant for the suggested CODEOWNERS pattern attached to
189/// an `unowned-hotspot` action.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
191#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
192#[serde(rename_all = "kebab-case")]
193pub enum HotspotActionHeuristic {
194 /// Suggest the deepest directory containing the file (e.g.,
195 /// `/src/api/users/`). Keeps the suggestion reviewable while staying
196 /// a directory pattern rather than a per-file rule.
197 DirectoryDeepest,
198}
199
200/// Suggested action attached to a [`RefactoringTarget`].
201///
202/// The list always begins with `apply-refactoring`. A trailing
203/// `suppress-line` is appended only when the target carries `evidence`
204/// linking to specific functions (e.g., `extract_complex_functions`,
205/// `add_test_coverage`).
206///
207/// Unlike [`HealthFindingAction`], the `suppress-line` variant emitted
208/// here does NOT carry a `placement` field: the parent
209/// [`RefactoringTarget`] points at a file (not a specific function
210/// declaration site), so a per-line placement hint would have no
211/// referent. Consumers that want the placement metadata should follow
212/// the target's `evidence.complex_functions` back to the matching
213/// `ComplexityViolation` and read placement from THAT action instead.
214///
215/// [`RefactoringTarget`]: ../../fallow-cli/src/health_types/targets.rs
216#[derive(Debug, Clone, Serialize)]
217#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
218pub struct RefactoringTargetAction {
219 /// Action type identifier.
220 #[serde(rename = "type")]
221 pub kind: RefactoringTargetActionType,
222 /// Whether `fallow fix` can auto-apply this action. Today both
223 /// variants are manual.
224 pub auto_fixable: bool,
225 /// Human-readable description of the action. For `apply-refactoring`
226 /// this is the target's own `recommendation` string; for
227 /// `suppress-line` it is the suppression prompt.
228 pub description: String,
229 /// Recommendation category for `apply-refactoring` actions. Mirrors
230 /// the parent target's
231 /// [`category`](../../fallow-cli/src/health_types/targets.rs.html)
232 /// field so consumers can route on the action alone.
233 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub category: Option<String>,
235 /// The inline comment to insert. Present on `suppress-line` actions
236 /// when evidence exists.
237 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub comment: Option<String>,
239}
240
241/// Discriminant for [`RefactoringTargetAction::kind`].
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
243#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
244#[serde(rename_all = "kebab-case")]
245pub enum RefactoringTargetActionType {
246 /// Apply the recommended refactoring (extract, split, decouple, etc.).
247 ApplyRefactoring,
248 /// Suppress the underlying complexity finding with an inline comment.
249 SuppressLine,
250}
251
252/// Suggested action attached to an [`UntestedFile`] coverage-gap finding.
253///
254/// `build_untested_file_actions` emits a two-entry array on every
255/// untested-file item: an `add-tests` primary action (scaffold tests for
256/// the runtime file) and a `suppress-file` action
257/// (`// fallow-ignore-file coverage-gaps`). Both variants share the same
258/// struct shape; the field that is populated (`note` for `add-tests`,
259/// `comment` for `suppress-file`) depends on the `kind`.
260///
261/// [`UntestedFile`]: ../../fallow-cli/src/health_types/coverage.rs
262#[derive(Debug, Clone, Serialize)]
263#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
264pub struct UntestedFileAction {
265 /// Action type identifier.
266 #[serde(rename = "type")]
267 pub kind: UntestedFileActionType,
268 /// Whether `fallow fix` can auto-apply this action. Today both
269 /// variants are manual.
270 pub auto_fixable: bool,
271 /// Human-readable description of the action.
272 pub description: String,
273 /// Additional context for the `add-tests` variant (explains why no
274 /// test path reaches this file). Absent on `suppress-file`.
275 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub note: Option<String>,
277 /// The file-level comment to insert. Present on `suppress-file`
278 /// (`// fallow-ignore-file coverage-gaps`). Absent on `add-tests`.
279 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub comment: Option<String>,
281}
282
283/// Discriminant for [`UntestedFileAction::kind`]. Mirrors the action types
284/// emitted by `build_untested_file_actions`.
285#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
286#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
287#[serde(rename_all = "kebab-case")]
288pub enum UntestedFileActionType {
289 /// Scaffold tests that exercise the runtime file.
290 AddTests,
291 /// Suppress coverage-gap reporting for this file with a file-level
292 /// comment.
293 SuppressFile,
294}
295
296/// Suggested action attached to an [`UntestedExport`] coverage-gap
297/// finding.
298///
299/// `build_untested_export_actions` emits a two-entry array on every
300/// untested-export item: an `add-test-import` primary action (import the
301/// export from a test-reachable module) and a `suppress-file` action
302/// (`// fallow-ignore-file coverage-gaps`). The export-specific variant
303/// `add-test-import` reflects that a test-reachable reference chain, not
304/// just any test coverage, is what closes the gap.
305///
306/// [`UntestedExport`]: ../../fallow-cli/src/health_types/coverage.rs
307#[derive(Debug, Clone, Serialize)]
308#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
309pub struct UntestedExportAction {
310 /// Action type identifier.
311 #[serde(rename = "type")]
312 pub kind: UntestedExportActionType,
313 /// Whether `fallow fix` can auto-apply this action. Today both
314 /// variants are manual.
315 pub auto_fixable: bool,
316 /// Human-readable description of the action.
317 pub description: String,
318 /// Additional context for the `add-test-import` variant (explains the
319 /// runtime-reachable / test-unreachable asymmetry). Absent on
320 /// `suppress-file`.
321 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub note: Option<String>,
323 /// The file-level comment to insert. Present on `suppress-file`
324 /// (`// fallow-ignore-file coverage-gaps`). Absent on
325 /// `add-test-import`.
326 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub comment: Option<String>,
328}
329
330/// Discriminant for [`UntestedExportAction::kind`]. Mirrors the action
331/// types emitted by `build_untested_export_actions`.
332#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
333#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
334#[serde(rename_all = "kebab-case")]
335pub enum UntestedExportActionType {
336 /// Import and exercise the export from a test-reachable module.
337 AddTestImport,
338 /// Suppress coverage-gap reporting for the export's file with a
339 /// file-level comment.
340 SuppressFile,
341}