Skip to main content

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}