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 health output data shared outside
5//! the CLI crate, including `actions[]` arrays and refactoring target
6//! evidence. The JSON emission path constructs action entries through typed
7//! wrappers (for example `UntestedFileFinding` in
8//! `crates/output/src/health_coverage_gaps.rs`) and serializes them via serde;
9//! the schemars derive renders matching shapes 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 std::path::PathBuf;
15
16use serde::Serialize;
17
18/// Evidence linking a refactoring target back to specific analysis data.
19#[derive(Debug, Clone, Default, Serialize)]
20#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
21pub struct TargetEvidence {
22 /// Names of unused exports.
23 #[serde(default, skip_serializing_if = "Vec::is_empty")]
24 pub unused_exports: Vec<String>,
25 /// Complex functions with line numbers and cognitive scores.
26 #[serde(default, skip_serializing_if = "Vec::is_empty")]
27 pub complex_functions: Vec<EvidenceFunction>,
28 /// Files forming the import cycle.
29 #[serde(default, skip_serializing_if = "Vec::is_empty")]
30 pub cycle_path: Vec<String>,
31 /// Files that directly import this target, with imported and local symbols.
32 #[serde(default, skip_serializing_if = "Vec::is_empty")]
33 pub direct_callers: Vec<DirectCallerEvidence>,
34 /// Other duplicate-code instances that share a clone group with this target.
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
36 pub clone_siblings: Vec<CloneSiblingEvidence>,
37}
38
39/// A function referenced in target evidence.
40#[derive(Debug, Clone, Serialize)]
41#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
42pub struct EvidenceFunction {
43 /// Function name.
44 pub name: String,
45 /// 1-based line number.
46 pub line: u32,
47 /// Cognitive complexity score.
48 pub cognitive: u16,
49}
50
51/// A direct importer referenced in target evidence.
52#[derive(Debug, Clone, Serialize)]
53#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
54pub struct DirectCallerEvidence {
55 /// File that directly imports the target.
56 #[serde(serialize_with = "crate::serde_path::serialize")]
57 pub path: PathBuf,
58 /// Symbols imported from the target by this file.
59 #[serde(default, skip_serializing_if = "Vec::is_empty")]
60 pub symbols: Vec<DirectCallerSymbolEvidence>,
61}
62
63/// Symbol details for a direct importer.
64#[derive(Debug, Clone, Serialize)]
65#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
66pub struct DirectCallerSymbolEvidence {
67 /// Imported binding name.
68 pub imported: String,
69 /// Local binding name in the importing file.
70 pub local: String,
71 /// Whether the import is type-only.
72 pub type_only: bool,
73}
74
75/// A duplicate-code sibling referenced in target evidence.
76#[derive(Debug, Clone, Serialize)]
77#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
78pub struct CloneSiblingEvidence {
79 /// File containing the sibling clone instance.
80 #[serde(serialize_with = "crate::serde_path::serialize")]
81 pub path: PathBuf,
82 /// 1-based start line of the sibling clone.
83 pub start_line: usize,
84 /// 1-based end line of the sibling clone.
85 pub end_line: usize,
86 /// Stable duplicate-group handle, matching `dupes --trace dup:<id>`.
87 pub fingerprint: String,
88}
89
90/// Suggested action attached to a [`ComplexityViolation`].
91///
92/// Each complexity finding carries an array of these on the JSON wire
93/// (`findings[].actions[]`). The action selector in
94/// `crates/cli/src/report/json.rs::build_health_finding_actions` picks the
95/// primary action based on which thresholds triggered the finding and the
96/// bucketed coverage tier. See [`HealthFindingActionType`] for the full
97/// discriminant list.
98///
99/// `note`, `comment`, and `placement` are populated per-variant: refactor
100/// actions carry a `note`, suppress-line / suppress-file actions carry
101/// `comment` plus `placement`, and the coverage-leaning actions
102/// (`add-tests`, `increase-coverage`) carry only `note`.
103///
104/// [`ComplexityViolation`]: ../../fallow-output/src/health_scores.rs
105#[derive(Debug, Clone, Serialize)]
106#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
107pub struct HealthFindingAction {
108 /// Action type identifier. A single finding's `actions` array can carry
109 /// MULTIPLE entries of different types: e.g., a finding that exceeded
110 /// both cyclomatic and CRAP at `coverage_tier`: partial will get BOTH
111 /// `increase-coverage` AND `refactor-function`, plus `suppress-line`.
112 /// Consumers that select a single action should treat the FIRST
113 /// non-`suppress-{line,file}` action as primary. `add-tests` is emitted
114 /// when CRAP triggered the finding, the function has no test coverage
115 /// (`coverage_tier`: none), and full coverage can bring CRAP below
116 /// `max_crap_threshold` (cyclomatic < threshold, since CRAP bottoms out
117 /// at CC at 100% coverage). `increase-coverage` is emitted when CRAP
118 /// triggered the finding, some coverage exists (`coverage_tier`: partial
119 /// or high), and full coverage can bring CRAP below `max_crap_threshold`;
120 /// the description steers toward targeted branch coverage rather than
121 /// scaffolding new tests. `refactor-function` is emitted when
122 /// cyclomatic/cognitive triggered the finding, when full coverage still
123 /// cannot bring CRAP below `max_crap_threshold` (cyclomatic >=
124 /// threshold), or as a secondary action when cyclomatic is within the
125 /// configured `health.crapRefactorBand` of the cyclomatic threshold AND
126 /// cognitive is at or above `max_cognitive_threshold / 2` (the cognitive
127 /// floor suppresses false positives on flat type-tag dispatchers and JSX
128 /// render maps where high cyclomatic comes from a single switch with
129 /// near-zero cognitive load). `suppress-file` is emitted instead of
130 /// `suppress-line` for
131 /// synthetic Angular `<template>` findings on `.html` files, because
132 /// line-suppression comments cannot be expressed in HTML; the `comment`
133 /// field carries `<!-- fallow-ignore-file complexity -->` and
134 /// `placement` is `top-of-template`.
135 #[serde(rename = "type")]
136 pub kind: HealthFindingActionType,
137 /// Whether `fallow fix` can auto-apply this action. Today every health
138 /// finding action is manual, but the field is non-singleton so a future
139 /// auto-applier (e.g., an LLM-driven `refactor-function` worker) does
140 /// not need a schema change.
141 pub auto_fixable: bool,
142 /// Human-readable description of the action.
143 pub description: String,
144 /// Additional context (e.g., the canonical CRAP formula, or a hint
145 /// about which branch type to extract). Present on most action types;
146 /// dropped only when the description carries the full ask.
147 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub note: Option<String>,
149 /// The inline comment to insert (e.g.,
150 /// `// fallow-ignore-next-line complexity` or
151 /// `<!-- fallow-ignore-file complexity -->`). Present on
152 /// `suppress-line` and `suppress-file` action variants.
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub comment: Option<String>,
155 /// Where to insert the suppress comment
156 /// (e.g., `above-function-declaration`, `above-angular-decorator`,
157 /// `above-template-anchor-line`, `above-component-worst-method`, or
158 /// `top-of-template`). Present on `suppress-line` and `suppress-file`
159 /// action variants. `above-template-anchor-line` is used for
160 /// single-file-component markup (`.svelte`, `.vue`, `.astro`), where the
161 /// synthetic `<template>` unit is anchored at its first contributing
162 /// construct rather than at the top of the file, so the comment belongs on
163 /// the line immediately preceding the reported line.
164 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub placement: Option<String>,
166 /// Project-relative path the action should target when the finding's
167 /// remediation lives in a different file from where the finding is
168 /// anchored. Currently populated on the `increase-coverage` action for
169 /// synthetic Angular `<template>` findings whose CRAP is inherited from
170 /// the owning `.component.ts`: the action points at the component file
171 /// (where the user actually adds tests) rather than the `.html` template
172 /// (where the finding is anchored but which is not directly testable).
173 /// Absent when the action's target is the finding's own file.
174 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub target_path: Option<String>,
176}
177
178/// Discriminant for [`HealthFindingAction::kind`]. Mirrors the action types
179/// emitted by `build_health_finding_actions`. A single finding's `actions`
180/// array may carry multiple entries of different types: a finding that
181/// exceeded both cyclomatic and CRAP at `coverage_tier: partial` will get
182/// BOTH `increase-coverage` AND `refactor-function`, plus the trailing
183/// `suppress-line`.
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
185#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
186#[serde(rename_all = "kebab-case")]
187pub enum HealthFindingActionType {
188 /// Refactor the function to reduce complexity. Emitted when
189 /// cyclomatic/cognitive triggered the finding, when full coverage
190 /// still cannot bring CRAP below `max_crap_threshold`, or as a
191 /// secondary action when cyclomatic is within the configured
192 /// `health.crapRefactorBand` of the cyclomatic threshold AND cognitive
193 /// is at or above the cognitive floor.
194 RefactorFunction,
195 /// Add tests for a CRAP-triggered finding whose coverage tier is
196 /// `none` (no test path reaches the function).
197 AddTests,
198 /// Increase test coverage for a CRAP-triggered finding whose coverage
199 /// tier is `partial` or `high` (some test path exists; add targeted
200 /// assertions for uncovered branches).
201 IncreaseCoverage,
202 /// Suppress with an HTML comment at the top of the template. Used for
203 /// synthetic Angular `<template>` findings on `.html` files where a
204 /// line suppression cannot be expressed.
205 SuppressFile,
206 /// Suppress with a next-line comment above the reported line: the
207 /// inline `// fallow-ignore-next-line complexity` form above a function
208 /// or Angular decorator, or the markup
209 /// `<!-- fallow-ignore-next-line complexity -->` form for synthetic
210 /// `<template>` findings in `.svelte`, `.vue`, and `.astro` files.
211 SuppressLine,
212}
213
214/// Suggested action attached to a [`HotspotEntry`].
215///
216/// The action list always begins with `refactor-file` plus `add-tests`.
217/// Ownership-derived variants (`low-bus-factor`, `unowned-hotspot`,
218/// `ownership-drift`) are appended only when `--ownership` is active AND
219/// the corresponding signal fires for the hotspot.
220///
221/// [`HotspotEntry`]: ../../fallow-output/src/health_scores.rs
222#[derive(Debug, Clone, Serialize)]
223#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
224pub struct HotspotAction {
225 /// Action type identifier.
226 #[serde(rename = "type")]
227 pub kind: HotspotActionType,
228 /// Whether `fallow fix` can auto-apply this action. Today every
229 /// hotspot action is manual.
230 pub auto_fixable: bool,
231 /// Human-readable description of the action.
232 pub description: String,
233 /// Additional context for the action. Absent on `low-bus-factor` when
234 /// the finding's description already carries the full ask (no
235 /// suggested reviewers and not a low-commit file).
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub note: Option<String>,
238 /// Suggested CODEOWNERS pattern. Present only on `unowned-hotspot`
239 /// actions. Derived per the [`heuristic`](Self::heuristic) field;
240 /// consumers should branch on [`heuristic`](Self::heuristic) rather
241 /// than assume a stable algorithm.
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub suggested_pattern: Option<String>,
244 /// Strategy used to derive [`suggested_pattern`](Self::suggested_pattern).
245 /// Reserved for future evolution (`codeowners-cluster`, etc.).
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub heuristic: Option<HotspotActionHeuristic>,
248}
249
250/// Discriminant for [`HotspotAction::kind`].
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
252#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
253#[serde(rename_all = "kebab-case")]
254pub enum HotspotActionType {
255 /// Refactor the hotspot file (high complexity plus frequent change).
256 RefactorFile,
257 /// Add test coverage to reduce change risk on the hotspot file.
258 AddTests,
259 /// Bus factor of 1: a single recent contributor owns the file.
260 /// Emitted only with `--ownership`.
261 LowBusFactor,
262 /// Hotspot matches no CODEOWNERS rule (a rules file exists but no
263 /// pattern matches). Emitted only with `--ownership`.
264 UnownedHotspot,
265 /// Ownership has drifted from the original author to a new top
266 /// contributor. Emitted only with `--ownership`.
267 OwnershipDrift,
268}
269
270/// Strategy discriminant for the suggested CODEOWNERS pattern attached to
271/// an `unowned-hotspot` action.
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
273#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
274#[serde(rename_all = "kebab-case")]
275pub enum HotspotActionHeuristic {
276 /// Suggest the deepest directory containing the file (e.g.,
277 /// `/src/api/users/`). Keeps the suggestion reviewable while staying
278 /// a directory pattern rather than a per-file rule.
279 DirectoryDeepest,
280}
281
282/// Suggested action attached to a [`RefactoringTarget`].
283///
284/// The list always begins with `apply-refactoring`. A trailing
285/// `suppress-line` is appended only when the target carries `evidence`
286/// linking to specific functions (e.g., `extract_complex_functions`,
287/// `add_test_coverage`).
288///
289/// Unlike [`HealthFindingAction`], the `suppress-line` variant emitted
290/// here does NOT carry a `placement` field: the parent
291/// [`RefactoringTarget`] points at a file (not a specific function
292/// declaration site), so a per-line placement hint would have no
293/// referent. Consumers that want the placement metadata should follow
294/// the target's `evidence.complex_functions` back to the matching
295/// `ComplexityViolation` and read placement from THAT action instead.
296///
297/// [`RefactoringTarget`]: ../../fallow-output/src/health_targets.rs
298#[derive(Debug, Clone, Serialize)]
299#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
300pub struct RefactoringTargetAction {
301 /// Action type identifier.
302 #[serde(rename = "type")]
303 pub kind: RefactoringTargetActionType,
304 /// Whether `fallow fix` can auto-apply this action. Today both
305 /// variants are manual.
306 pub auto_fixable: bool,
307 /// Human-readable description of the action. For `apply-refactoring`
308 /// this is the target's own `recommendation` string; for
309 /// `suppress-line` it is the suppression prompt.
310 pub description: String,
311 /// Recommendation category for `apply-refactoring` actions. Mirrors
312 /// the parent target's
313 /// [`category`](../../fallow-output/src/health_targets.rs.html)
314 /// field so consumers can route on the action alone.
315 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub category: Option<String>,
317 /// The inline comment to insert. Present on `suppress-line` actions
318 /// when evidence exists.
319 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub comment: Option<String>,
321}
322
323/// Discriminant for [`RefactoringTargetAction::kind`].
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
325#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
326#[serde(rename_all = "kebab-case")]
327pub enum RefactoringTargetActionType {
328 /// Apply the recommended refactoring (extract, split, decouple, etc.).
329 ApplyRefactoring,
330 /// Suppress the underlying complexity finding with an inline comment.
331 SuppressLine,
332}
333
334/// Suggested action attached to an [`UntestedFile`] coverage-gap finding.
335///
336/// `build_untested_file_actions` emits a two-entry array on every
337/// untested-file item: an `add-tests` primary action (scaffold tests for
338/// the runtime file) and a `suppress-file` action
339/// (`// fallow-ignore-file coverage-gaps`). Both variants share the same
340/// struct shape; the field that is populated (`note` for `add-tests`,
341/// `comment` for `suppress-file`) depends on the `kind`.
342///
343/// [`UntestedFile`]: ../../fallow-output/src/health_coverage_gaps.rs
344#[derive(Debug, Clone, Serialize)]
345#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
346pub struct UntestedFileAction {
347 /// Action type identifier.
348 #[serde(rename = "type")]
349 pub kind: UntestedFileActionType,
350 /// Whether `fallow fix` can auto-apply this action. Today both
351 /// variants are manual.
352 pub auto_fixable: bool,
353 /// Human-readable description of the action.
354 pub description: String,
355 /// Additional context for the `add-tests` variant (explains why no
356 /// test path reaches this file). Absent on `suppress-file`.
357 #[serde(default, skip_serializing_if = "Option::is_none")]
358 pub note: Option<String>,
359 /// The file-level comment to insert. Present on `suppress-file`
360 /// (`// fallow-ignore-file coverage-gaps`). Absent on `add-tests`.
361 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub comment: Option<String>,
363}
364
365/// Discriminant for [`UntestedFileAction::kind`]. Mirrors the action types
366/// emitted by `build_untested_file_actions`.
367#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
368#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
369#[serde(rename_all = "kebab-case")]
370pub enum UntestedFileActionType {
371 /// Scaffold tests that exercise the runtime file.
372 AddTests,
373 /// Suppress coverage-gap reporting for this file with a file-level
374 /// comment.
375 SuppressFile,
376}
377
378/// Suggested action attached to an [`UntestedExport`] coverage-gap
379/// finding.
380///
381/// `build_untested_export_actions` emits a two-entry array on every
382/// untested-export item: an `add-test-import` primary action (import the
383/// export from a test-reachable module) and a `suppress-file` action
384/// (`// fallow-ignore-file coverage-gaps`). The export-specific variant
385/// `add-test-import` reflects that a test-reachable reference chain, not
386/// just any test coverage, is what closes the gap.
387///
388/// [`UntestedExport`]: ../../fallow-output/src/health_coverage_gaps.rs
389#[derive(Debug, Clone, Serialize)]
390#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
391pub struct UntestedExportAction {
392 /// Action type identifier.
393 #[serde(rename = "type")]
394 pub kind: UntestedExportActionType,
395 /// Whether `fallow fix` can auto-apply this action. Today both
396 /// variants are manual.
397 pub auto_fixable: bool,
398 /// Human-readable description of the action.
399 pub description: String,
400 /// Additional context for the `add-test-import` variant (explains the
401 /// runtime-reachable / test-unreachable asymmetry). Absent on
402 /// `suppress-file`.
403 #[serde(default, skip_serializing_if = "Option::is_none")]
404 pub note: Option<String>,
405 /// The file-level comment to insert. Present on `suppress-file`
406 /// (`// fallow-ignore-file coverage-gaps`). Absent on
407 /// `add-test-import`.
408 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub comment: Option<String>,
410}
411
412/// Discriminant for [`UntestedExportAction::kind`]. Mirrors the action
413/// types emitted by `build_untested_export_actions`.
414#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
415#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
416#[serde(rename_all = "kebab-case")]
417pub enum UntestedExportActionType {
418 /// Import and exercise the export from a test-reachable module.
419 AddTestImport,
420 /// Suppress coverage-gap reporting for the export's file with a
421 /// file-level comment.
422 SuppressFile,
423}